Skip to content

Commit

Permalink
Add additional whirlpool_android client credentials, add support for …
Browse files Browse the repository at this point in the history
…shared devices (#49)

This replaces the whirlpool brand client_id and client_secret with
updated values that work, as the old ones were no longer valid.

This also adds the methods `_get_owned_appliances`,
`_get_shared_appliances`, and `_add_appliance` to `AppliancesManager`.
The `fetch_appliances` method will call `_get_owned_appliances` first
and then `_get_shared_appliances`, ensuring that both types are added to
the appliance list.

There are some other minor changes to fix failing tests, including the
addition of a noop async `wait_for_close` method to the `aiohttp` mock
and pinning `aiohttp` to below 3.9.0. The `wait_for_close` is actually
not required any longer with `aiohttp` pinned to a lower version, but I
figure it's better to leave it there to save the next person the
headache.

---------

Co-authored-by: Jessica Smith <(none)>
Co-authored-by: Abílio Costa <[email protected]>
  • Loading branch information
NodeJSmith and abmantis authored Feb 17, 2024
1 parent 18d3512 commit 180323d
Show file tree
Hide file tree
Showing 13 changed files with 494 additions and 113 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -131,3 +131,4 @@ dmypy.json

# Pyre type checker
.pyre/
.vscode
5 changes: 3 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "whirlpool_sixth_sense"
version = "0.18.5"
authors = [{name = "Abílio Costa", email = "[email protected]"}]
authors = [{ name = "Abílio Costa", email = "[email protected]" }]
description = "Unofficial API for Whirlpool's 6th Sense appliances"
classifiers = [
"Programming Language :: Python :: 3",
Expand All @@ -15,8 +15,9 @@ classifiers = [
requires-python = ">=3.6"
dependencies = [
"aioconsole>=0.3.1",
"aiohttp>=3.7.2",
"aiohttp>=3.9.1",
"websockets>=8.1",
"async-timeout>=4.0.3",
]

[project.readme]
Expand Down
3 changes: 2 additions & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
aioconsole>=0.3.1
aiohttp>=3.7.2
aiohttp>=3.9.1
websockets>=8.1
async-timeout>=4.0.3
3 changes: 3 additions & 0 deletions tests/aiohttp.py
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,9 @@ def raise_for_status(self):
def close(self):
"""Mock close."""

async def wait_for_close(self):
pass


# @contextmanager
# def mock_aiohttp_client():
Expand Down
36 changes: 36 additions & 0 deletions tests/data/owned_appliances.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"KEY1": [
{
"APPLIANCE_ID": 1033050,
"APPLIANCE_MASTER_ID": 3721,
"MODEL_SKU_ID": null,
"APPLIANCE_NAME": "Washer",
"SAID": "WPR4DL103WMLXV",
"NEST_AWAY": 0,
"CYCLE_HANDOFF": 0,
"NEST_THERMOSTAT_ID": 0,
"THERMOSTAT_INFLUENCE_THRESHOLD": null,
"THERMOSTAT_DESIRED_OFFSET": null,
"THERMOSTAT_OFFSET_NEEDED": null,
"DELETE_FLAG": 0,
"DISPLAY_POSITION": null,
"SERIAL": "CC26012420",
"LOCATION_ID": 15862310,
"MACHINE_ID": null,
"MACHINE_POSITION": 0,
"ISVOICEDEFAULT": 1,
"DEVICE_ID": "550e8400-e29b-41d4-a716-446655441234",
"APPLIANCE_TYPE_ID": null,
"STATUS": "CLAIMED",
"IS_ENROLLED": null,
"MACHINE_STATUS": null,
"APPLIANCE_MODE": 2,
"CREATED_AT": 1690565765000,
"UPDATED_AT": 1690565765000,
"DATA_MODEL_KEY": "DDM_LAUNDRY_VMAX20_WHIRLPOOL_WASHER8_V2",
"CATEGORY_NAME": "FabricCare",
"MODEL_NO": "WTW8127LW1",
"REPLENISHMENT_DEVICE_MODEL": null
}
]
}
43 changes: 43 additions & 0 deletions tests/data/shared_appliances.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
{
"sharedAppliances": [
{
"shareId": 12345,
"sharedFirstName": "TestUser",
"appliances": [
{
"APPLIANCE_ID": 1033047,
"APPLIANCE_MASTER_ID": 3720,
"MODEL_SKU_ID": null,
"APPLIANCE_NAME": "Washer",
"SAID": "WPR4DYW3WMLLL",
"NEST_AWAY": 0,
"CYCLE_HANDOFF": 0,
"NEST_THERMOSTAT_ID": 0,
"THERMOSTAT_INFLUENCE_THRESHOLD": null,
"THERMOSTAT_DESIRED_OFFSET": null,
"THERMOSTAT_OFFSET_NEEDED": null,
"DELETE_FLAG": 0,
"DISPLAY_POSITION": null,
"SERIAL": "CC26012451",
"LOCATION_ID": 15862309,
"MACHINE_ID": null,
"MACHINE_POSITION": 0,
"ISVOICEDEFAULT": 1,
"DEVICE_ID": "550e8400-e29b-41d4-a716-446655440000",
"APPLIANCE_TYPE_ID": null,
"STATUS": "CLAIMED",
"IS_ENROLLED": null,
"MACHINE_STATUS": null,
"APPLIANCE_MODE": 2,
"CREATED_AT": 1690565765000,
"UPDATED_AT": 1690565765000,
"DATA_MODEL_KEY": "DDM_LAUNDRY_VMAX20_WHIRLPOOL_WASHER8_V2",
"CATEGORY_NAME": "FabricCare",
"MODEL_NO": "WTW8127LW1",
"REPLENISHMENT_DEVICE_MODEL": null
}
],
"tsAppliances": null
}
]
}
47 changes: 40 additions & 7 deletions tests/mock_backendselector.py
Original file line number Diff line number Diff line change
@@ -1,20 +1,53 @@
class BackendSelectorMock:
from enum import Enum
from typing import Dict, List

from whirlpool.backendselector import BackendSelector


class DummyBrand(Enum):
DUMMY_BRAND = "dummy_brand"


class DummyRegion(Enum):
DUMMY_REGION = "dummy_region"


class BackendSelectorMock(BackendSelector):
def __init__(self):
super().__init__(DummyBrand.DUMMY_BRAND, DummyRegion.DUMMY_REGION)

@property
def brand(self):
return "dummy_brand"
return DummyBrand.DUMMY_BRAND

@property
def region(self):
return "dummy_region"
return DummyRegion.DUMMY_REGION

@property
def base_url(self):
return "http://dummy_base_url.com"

@property
def client_id(self):
return "dummy_client_id"
def client_credentials(self) -> List[Dict[str, str]]:
return [
{
"client_id": "dummy_client_id1",
"client_secret": "dummy_client_secret1",
},
]


class BackendSelectorMockMultipleCreds(BackendSelectorMock):
@property
def client_secret(self):
return "dummy_client_secret"
def client_credentials(self) -> List[Dict[str, str]]:
return [
{
"client_id": "dummy_client_id1",
"client_secret": "dummy_client_secret1",
},
{
"client_id": "dummy_client_id2",
"client_secret": "dummy_client_secret2",
},
]
115 changes: 115 additions & 0 deletions tests/test_appliancesmanager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import asyncio

import pytest

from whirlpool.appliancesmanager import AppliancesManager
from whirlpool.auth import Auth

from .aiohttp import AiohttpClientMocker
from .mock_backendselector import BackendSelectorMock
from .utils import (
ACCOUNT_ID,
get_mock_coro,
mock_appliancesmanager_get_account_id_get,
mock_appliancesmanager_get_owned_appliances_get,
mock_appliancesmanager_get_shared_appliances_get,
)

BACKEND_SELECTOR_MOCK = BackendSelectorMock()


def assert_appliances_manager_call(
http_client_mock: AiohttpClientMocker,
call_index: int,
path: str,
required_headers: dict = None,
):
mock_calls = http_client_mock.mock_calls

call = mock_calls[call_index]
assert call[0] == "GET"
assert call[1].path == path
# call[2] is body, which will be None

if required_headers is not None:
for k, v in required_headers.items():
assert call[3][k] == v


@pytest.mark.parametrize("account_id", [None, ACCOUNT_ID])
async def test_fetch_appliances_with_set_account_id(
account_id: str, http_client_mock: AiohttpClientMocker
):
get_appliances_idx = 0 if account_id is not None else 1

mock_appliancesmanager_get_account_id_get(http_client_mock, BACKEND_SELECTOR_MOCK)
mock_appliancesmanager_get_owned_appliances_get(
http_client_mock, BACKEND_SELECTOR_MOCK, ACCOUNT_ID
)
mock_appliancesmanager_get_shared_appliances_get(
http_client_mock, BACKEND_SELECTOR_MOCK
)

http_client_mock.create_session(asyncio.get_event_loop())
auth = Auth(BACKEND_SELECTOR_MOCK, "email", "secretpass", http_client_mock.session)

if account_id is not None:
auth._auth_dict["accountId"] = account_id

am = AppliancesManager(BACKEND_SELECTOR_MOCK, auth, http_client_mock.session)

await am.fetch_appliances()

if account_id is None:
# make sure that the first call in this case is to get the account id
assert_appliances_manager_call(http_client_mock, 0, "/api/v1/getUserDetails")

# this should always be called
assert_appliances_manager_call(
http_client_mock,
get_appliances_idx,
f"/api/v2/appliance/all/account/{ACCOUNT_ID}",
)

# this should always be called and requires the WP-CLIENT-BRAND header
assert_appliances_manager_call(
http_client_mock,
get_appliances_idx + 1,
"/api/v1/share-accounts/appliances",
{"WP-CLIENT-BRAND": "DUMMY_BRAND"},
)

# ensure that the washer_dryers list is populated
assert len(am.washer_dryers) == 2

await http_client_mock.close_session()


@pytest.mark.parametrize(
["owned_response", "shared_response"],
[(True, True), (True, False), (False, True), (False, False)],
)
async def test_fetch_appliances_returns_true_if_either_method_returns_true(
owned_response: bool,
shared_response: bool,
http_client_mock: AiohttpClientMocker,
):
mock_appliancesmanager_get_account_id_get(http_client_mock, BACKEND_SELECTOR_MOCK)
mock_appliancesmanager_get_owned_appliances_get(
http_client_mock, BACKEND_SELECTOR_MOCK, "12345"
)
mock_appliancesmanager_get_shared_appliances_get(
http_client_mock, BACKEND_SELECTOR_MOCK
)

http_client_mock.create_session(asyncio.get_event_loop())
auth = Auth(BACKEND_SELECTOR_MOCK, "email", "secretpass", http_client_mock.session)

am = AppliancesManager(BACKEND_SELECTOR_MOCK, auth, http_client_mock.session)
am._get_shared_appliances = get_mock_coro(shared_response)
am._get_owned_appliances = get_mock_coro(owned_response)

result = await am.fetch_appliances()

assert result == bool(owned_response or shared_response)
await http_client_mock.close_session()
49 changes: 46 additions & 3 deletions tests/test_auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,15 @@
from whirlpool.auth import Auth

from .aiohttp import AiohttpClientMocker
from .mock_backendselector import BackendSelectorMock
from .mock_backendselector import BackendSelectorMock, BackendSelectorMockMultipleCreds

BACKEND_SELECTOR_MOCK = BackendSelectorMock()
BACKEND_SELECTOR_MOCK_MULTIPLE_CREDS = BackendSelectorMockMultipleCreds()

AUTH_URL = f"{BACKEND_SELECTOR_MOCK.base_url}/oauth/token"
AUTH_DATA = {
"client_id": BACKEND_SELECTOR_MOCK.client_id,
"client_secret": BACKEND_SELECTOR_MOCK.client_secret,
"client_id": BACKEND_SELECTOR_MOCK.client_credentials[0]["client_id"],
"client_secret": BACKEND_SELECTOR_MOCK.client_credentials[0]["client_secret"],
"grant_type": "password",
"username": "email",
"password": "secretpass",
Expand Down Expand Up @@ -54,6 +55,48 @@ async def test_auth_success(http_client_mock: AiohttpClientMocker):
await http_client_mock.close_session()


async def test_auth_multiple_client_credentials(http_client_mock: AiohttpClientMocker):
http_client_mock.create_session(asyncio.get_event_loop())
auth = Auth(
BACKEND_SELECTOR_MOCK_MULTIPLE_CREDS,
"email",
"secretpass",
http_client_mock.session,
)
auth_data = AUTH_DATA.copy()

mock_resp_data = {
"access_token": "acess_token_123",
"token_type": "bearer",
"refresh_token": "refresher_123",
"expires_in": 21599,
"scope": "trust read write",
"accountId": 12345,
"SAID": ["SAID1", "SAID2"],
"jti": "?????",
}

# create mock for each client credential, all but the last one returning 404 so we can try the next
for i in BACKEND_SELECTOR_MOCK_MULTIPLE_CREDS.client_credentials:
status = (
HTTPStatus.NOT_FOUND
if i != len(BACKEND_SELECTOR_MOCK_MULTIPLE_CREDS.client_credentials)
else HTTPStatus.OK
)
http_client_mock.post(AUTH_URL, json=mock_resp_data, status=status)

await auth.do_auth(store=False)

# ensure that each client credential is used
for i, auth_data in enumerate(
BACKEND_SELECTOR_MOCK_MULTIPLE_CREDS.client_credentials
):
for k, v in auth_data.items():
assert http_client_mock.mock_calls[i][2][k] == v

await http_client_mock.close_session()


async def test_auth_bad_credentials(http_client_mock: AiohttpClientMocker):
http_client_mock.create_session(asyncio.get_event_loop())
auth = Auth(BACKEND_SELECTOR_MOCK, "email", "secretpass", http_client_mock.session)
Expand Down
Loading

0 comments on commit 180323d

Please sign in to comment.