carrier-api 3.6.2__tar.gz → 3.7.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {carrier_api-3.6.2/src/carrier_api.egg-info → carrier_api-3.7.0}/PKG-INFO +4 -1
- {carrier_api-3.6.2 → carrier_api-3.7.0}/README.md +3 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/api_connection_graphql.py +187 -54
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/const.py +1 -1
- {carrier_api-3.6.2 → carrier_api-3.7.0/src/carrier_api.egg-info}/PKG-INFO +4 -1
- {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_api_connection_graphql.py +399 -255
- {carrier_api-3.6.2 → carrier_api-3.7.0}/LICENSE +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/pyproject.toml +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/setup.cfg +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/__init__.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/api_websocket.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/api_websocket_data_updater.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/config.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/energy.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/entry_level.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/errors.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/live_smoke_test.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/profile.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/py.typed +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/status.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/system.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api/util.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api.egg-info/SOURCES.txt +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api.egg-info/dependency_links.txt +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api.egg-info/requires.txt +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/src/carrier_api.egg-info/top_level.txt +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_api_websocket.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_config.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_entry_level.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_fixture_contracts.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_live_smoke_test.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_models.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_websocket_data_updater.py +0 -0
- {carrier_api-3.6.2 → carrier_api-3.7.0}/tests/test_workflows.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: carrier_api
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.7.0
|
|
4
4
|
Summary: Carrier Api Wrapper
|
|
5
5
|
Author-email: Bren Dahl <dahl.brendan@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -179,6 +179,9 @@ Available update helpers include:
|
|
|
179
179
|
- `set_config_hold(...)`
|
|
180
180
|
- `resume_schedule(...)`
|
|
181
181
|
- `set_config_manual_activity(...)`
|
|
182
|
+
- `set_program_day(...)`
|
|
183
|
+
|
|
184
|
+
`set_program_day(...)` replaces one day of a zone's program (`updateInfinityProgramDay`). Send every period of the day, enabled or not, with the `etag` the systems query returns (`load_data()` now reads the program's and each day's `etag`). It has not yet been verified against a live system.
|
|
182
185
|
|
|
183
186
|
These methods can change real HVAC settings. Validate the selected system, zone, mode, and set points before calling them from automation.
|
|
184
187
|
|
|
@@ -155,6 +155,9 @@ Available update helpers include:
|
|
|
155
155
|
- `set_config_hold(...)`
|
|
156
156
|
- `resume_schedule(...)`
|
|
157
157
|
- `set_config_manual_activity(...)`
|
|
158
|
+
- `set_program_day(...)`
|
|
159
|
+
|
|
160
|
+
`set_program_day(...)` replaces one day of a zone's program (`updateInfinityProgramDay`). Send every period of the day, enabled or not, with the `etag` the systems query returns (`load_data()` now reads the program's and each day's `etag`). It has not yet been verified against a live system.
|
|
158
161
|
|
|
159
162
|
These methods can change real HVAC settings. Validate the selected system, zone, mode, and set points before calling them from automation.
|
|
160
163
|
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
"""GraphQL client for Carrier authentication, queries, and config updates."""
|
|
2
2
|
|
|
3
|
+
from asyncio import Lock
|
|
3
4
|
from datetime import UTC, datetime, timedelta
|
|
4
5
|
from logging import getLogger
|
|
5
6
|
from typing import Any, Literal
|
|
6
7
|
|
|
7
|
-
from aiohttp import ClientError,
|
|
8
|
+
from aiohttp import ClientError, ClientSession
|
|
8
9
|
from gql import Client, GraphQLRequest, gql
|
|
9
10
|
from gql.transport.aiohttp import AIOHTTPTransport
|
|
10
11
|
from gql.transport.exceptions import (
|
|
@@ -34,6 +35,24 @@ GRAPHQL_EXECUTE_TIMEOUT_SECONDS = 60
|
|
|
34
35
|
|
|
35
36
|
_CONNECTION_ERRORS = (GraphqlTransportError, ClientError, TimeoutError, OSError)
|
|
36
37
|
_AUTH_HTTP_STATUSES = {401, 403}
|
|
38
|
+
GRAPHQL_NO_AUTH_URL = "https://dataservice.infinity.iot.carrier.com/graphql-no-auth"
|
|
39
|
+
FETCH_ACCESS_TOKEN_MUTATION = gql(
|
|
40
|
+
"""
|
|
41
|
+
mutation fetchAccessToken($input: FetchAccessTokenInput!) {
|
|
42
|
+
fetchAccessToken(input: $input) {
|
|
43
|
+
success
|
|
44
|
+
errorMessage
|
|
45
|
+
data {
|
|
46
|
+
token_type
|
|
47
|
+
expires_in
|
|
48
|
+
access_token
|
|
49
|
+
scope
|
|
50
|
+
refresh_token
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
"""
|
|
55
|
+
)
|
|
37
56
|
|
|
38
57
|
|
|
39
58
|
def _is_auth_transport_error(error: BaseException) -> bool:
|
|
@@ -49,25 +68,6 @@ def _is_auth_transport_error(error: BaseException) -> bool:
|
|
|
49
68
|
return isinstance(error, TransportServerError) and error.code in _AUTH_HTTP_STATUSES
|
|
50
69
|
|
|
51
70
|
|
|
52
|
-
async def _response_json_object(response: Any) -> dict[str, Any]:
|
|
53
|
-
"""Read a JSON object response body when available.
|
|
54
|
-
|
|
55
|
-
Args:
|
|
56
|
-
response: aiohttp-like response object with an async ``json`` method.
|
|
57
|
-
|
|
58
|
-
Returns:
|
|
59
|
-
The decoded JSON object, or an empty object when the body is malformed
|
|
60
|
-
or is not a JSON object.
|
|
61
|
-
"""
|
|
62
|
-
try:
|
|
63
|
-
data = await response.json()
|
|
64
|
-
except ClientError, TimeoutError, OSError, TypeError, ValueError:
|
|
65
|
-
return {}
|
|
66
|
-
if not isinstance(data, dict):
|
|
67
|
-
return {}
|
|
68
|
-
return data
|
|
69
|
-
|
|
70
|
-
|
|
71
71
|
class ApiConnectionGraphql:
|
|
72
72
|
"""Async Carrier GraphQL API connection with token and websocket support."""
|
|
73
73
|
|
|
@@ -93,6 +93,7 @@ class ApiConnectionGraphql:
|
|
|
93
93
|
"""
|
|
94
94
|
self.username = username
|
|
95
95
|
self.password = password
|
|
96
|
+
self._auth_lock = Lock()
|
|
96
97
|
if client_session is None:
|
|
97
98
|
self.api_session = ClientSession(raise_for_status=False)
|
|
98
99
|
else:
|
|
@@ -112,9 +113,7 @@ class ApiConnectionGraphql:
|
|
|
112
113
|
CarrierApiAuthError: If the assisted login mutation reports an unsuccessful
|
|
113
114
|
authentication result.
|
|
114
115
|
"""
|
|
115
|
-
transport = AIOHTTPTransport(
|
|
116
|
-
url="https://dataservice.infinity.iot.carrier.com/graphql-no-auth", ssl=True
|
|
117
|
-
)
|
|
116
|
+
transport = AIOHTTPTransport(url=GRAPHQL_NO_AUTH_URL, ssl=True)
|
|
118
117
|
try:
|
|
119
118
|
async with Client(
|
|
120
119
|
transport=transport,
|
|
@@ -154,6 +153,10 @@ class ApiConnectionGraphql:
|
|
|
154
153
|
self.token_type = result["assistedLogin"]["data"]["token_type"]
|
|
155
154
|
self.access_token = result["assistedLogin"]["data"]["access_token"]
|
|
156
155
|
self.refresh_token = result["assistedLogin"]["data"]["refresh_token"]
|
|
156
|
+
_LOGGER.debug(
|
|
157
|
+
"Carrier login succeeded; access token expires at %s",
|
|
158
|
+
self.expires_at.isoformat(timespec="seconds"),
|
|
159
|
+
)
|
|
157
160
|
if self.api_websocket is None:
|
|
158
161
|
self.api_websocket = ApiWebsocket(self)
|
|
159
162
|
else:
|
|
@@ -171,49 +174,90 @@ class ApiConnectionGraphql:
|
|
|
171
174
|
raise CarrierApiGraphqlError("Carrier authentication GraphQL request failed") from error
|
|
172
175
|
|
|
173
176
|
async def check_auth_expiration(self) -> None:
|
|
174
|
-
"""Ensure the connection has a valid access token before API use.
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
177
|
+
"""Ensure the connection has a valid access token before API use.
|
|
178
|
+
|
|
179
|
+
Checks are serialized so concurrent callers never send the same
|
|
180
|
+
refresh token twice. When Carrier rejects the refresh token, the
|
|
181
|
+
connection falls back to one password login. Transient refresh
|
|
182
|
+
failures keep the stored token for the next attempt.
|
|
183
|
+
|
|
184
|
+
Raises:
|
|
185
|
+
CarrierApiAuthError: If the fallback password login is rejected.
|
|
186
|
+
CarrierApiTokenRefreshError: If token refresh fails transiently.
|
|
187
|
+
"""
|
|
188
|
+
async with self._auth_lock:
|
|
189
|
+
if self.refresh_token is None:
|
|
190
|
+
await self.login()
|
|
191
|
+
if self.expires_at < datetime.now(UTC):
|
|
192
|
+
try:
|
|
193
|
+
await self.refresh_auth_token()
|
|
194
|
+
except CarrierApiAuthError:
|
|
195
|
+
_LOGGER.info("Carrier refresh token was rejected; logging in again")
|
|
196
|
+
self.refresh_token = None
|
|
197
|
+
await self.login()
|
|
179
198
|
|
|
180
199
|
async def refresh_auth_token(self) -> None:
|
|
181
|
-
"""Refresh the
|
|
200
|
+
"""Refresh the access token through Carrier's ``fetchAccessToken`` mutation.
|
|
201
|
+
|
|
202
|
+
Carrier accounts may get tokens from Okta or from Microsoft Entra
|
|
203
|
+
External ID. Carrier's GraphQL service forwards the refresh to
|
|
204
|
+
whichever identity provider issued the account's tokens, so the
|
|
205
|
+
library does not need to know which one an account uses.
|
|
182
206
|
|
|
183
207
|
Raises:
|
|
184
|
-
CarrierApiAuthError: If Carrier rejects the refresh token
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
returns a valid OAuth response.
|
|
208
|
+
CarrierApiAuthError: If Carrier rejects the refresh token.
|
|
209
|
+
CarrierApiTokenRefreshError: If the refresh request fails or
|
|
210
|
+
returns a malformed token payload.
|
|
188
211
|
"""
|
|
189
|
-
url =
|
|
190
|
-
json_body = {
|
|
191
|
-
"client_id": "0oa1ce7hwjuZbfOMB4x7",
|
|
192
|
-
"grant_type": "refresh_token",
|
|
193
|
-
"refresh_token": self.refresh_token,
|
|
194
|
-
"scope": "offline_access",
|
|
195
|
-
}
|
|
196
|
-
response: Any | None = None
|
|
212
|
+
transport = AIOHTTPTransport(url=GRAPHQL_NO_AUTH_URL, ssl=True)
|
|
197
213
|
try:
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
214
|
+
async with Client(
|
|
215
|
+
transport=transport,
|
|
216
|
+
fetch_schema_from_transport=False,
|
|
217
|
+
) as session:
|
|
218
|
+
result = await session.execute(
|
|
219
|
+
FETCH_ACCESS_TOKEN_MUTATION,
|
|
220
|
+
variable_values={
|
|
221
|
+
"input": {"username": self.username, "refreshToken": self.refresh_token}
|
|
222
|
+
},
|
|
223
|
+
operation_name="fetchAccessToken",
|
|
224
|
+
)
|
|
225
|
+
except TransportQueryError as error:
|
|
226
|
+
raise CarrierApiTokenRefreshError("Carrier token refresh failed") from error
|
|
227
|
+
except _CONNECTION_ERRORS as error:
|
|
228
|
+
if _is_auth_transport_error(error):
|
|
206
229
|
raise CarrierApiAuthError("Carrier token refresh was rejected") from error
|
|
207
230
|
raise CarrierApiTokenRefreshError("Carrier token refresh failed") from error
|
|
208
|
-
except
|
|
231
|
+
except GraphQLError as error:
|
|
209
232
|
raise CarrierApiTokenRefreshError("Carrier token refresh failed") from error
|
|
233
|
+
|
|
234
|
+
response = result.get("fetchAccessToken") if isinstance(result, dict) else None
|
|
235
|
+
if not isinstance(response, dict):
|
|
236
|
+
raise CarrierApiTokenRefreshError("Carrier token refresh failed")
|
|
237
|
+
if not response.get("success"):
|
|
238
|
+
_LOGGER.debug("Carrier token refresh was rejected: %s", response.get("errorMessage"))
|
|
239
|
+
raise CarrierApiAuthError("Carrier token refresh was rejected", payload=response)
|
|
240
|
+
|
|
241
|
+
data = response.get("data")
|
|
242
|
+
if not isinstance(data, dict):
|
|
243
|
+
raise CarrierApiTokenRefreshError("Carrier token refresh failed")
|
|
244
|
+
previous_refresh_token = self.refresh_token
|
|
210
245
|
try:
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
self.refresh_token = data["refresh_token"]
|
|
246
|
+
expires_at = datetime.now(UTC) + timedelta(seconds=data["expires_in"])
|
|
247
|
+
token_type = data["token_type"]
|
|
248
|
+
access_token = data["access_token"]
|
|
215
249
|
except (KeyError, TypeError) as error:
|
|
216
250
|
raise CarrierApiTokenRefreshError("Carrier token refresh failed") from error
|
|
251
|
+
self.expires_at = expires_at
|
|
252
|
+
self.token_type = token_type
|
|
253
|
+
self.access_token = access_token
|
|
254
|
+
# Okta keeps the refresh token; Entra rotates it.
|
|
255
|
+
self.refresh_token = data.get("refresh_token") or previous_refresh_token
|
|
256
|
+
_LOGGER.debug(
|
|
257
|
+
"Carrier token refresh succeeded; access token expires at %s, refresh token %s",
|
|
258
|
+
self.expires_at.isoformat(timespec="seconds"),
|
|
259
|
+
"unchanged" if self.refresh_token == previous_refresh_token else "rotated",
|
|
260
|
+
)
|
|
217
261
|
|
|
218
262
|
async def authed_query(
|
|
219
263
|
self, operation_name: str, query: GraphQLRequest, variable_values: dict[str, Any]
|
|
@@ -434,8 +478,10 @@ class ApiConnectionGraphql:
|
|
|
434
478
|
occEnabled
|
|
435
479
|
program {
|
|
436
480
|
id
|
|
481
|
+
etag
|
|
437
482
|
day {
|
|
438
483
|
id
|
|
484
|
+
etag
|
|
439
485
|
zoneId
|
|
440
486
|
period {
|
|
441
487
|
id
|
|
@@ -811,6 +857,42 @@ class ApiConnectionGraphql:
|
|
|
811
857
|
_LOGGER.warning("No API websocket connection")
|
|
812
858
|
return response
|
|
813
859
|
|
|
860
|
+
async def _update_infinity_program_day(self, variables: dict[str, Any]) -> dict[str, Any]:
|
|
861
|
+
"""Run the Carrier zone program day mutation.
|
|
862
|
+
|
|
863
|
+
Args:
|
|
864
|
+
variables: GraphQL variables containing an ``InfinityProgramDayInput``.
|
|
865
|
+
|
|
866
|
+
Returns:
|
|
867
|
+
The decoded mutation response.
|
|
868
|
+
"""
|
|
869
|
+
query = gql(
|
|
870
|
+
"""
|
|
871
|
+
mutation updateInfinityProgramDay($input: InfinityProgramDayInput!) {
|
|
872
|
+
updateInfinityProgramDay(input: $input) {
|
|
873
|
+
id
|
|
874
|
+
etag
|
|
875
|
+
zoneId
|
|
876
|
+
period {
|
|
877
|
+
id
|
|
878
|
+
activity
|
|
879
|
+
time
|
|
880
|
+
enabled
|
|
881
|
+
}
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
"""
|
|
885
|
+
)
|
|
886
|
+
_LOGGER.debug("updateInfinityProgramDay: %s", variables)
|
|
887
|
+
response = await self.authed_query(
|
|
888
|
+
operation_name="updateInfinityProgramDay", query=query, variable_values=variables
|
|
889
|
+
)
|
|
890
|
+
if self.api_websocket is not None:
|
|
891
|
+
await self.api_websocket.send_reconcile()
|
|
892
|
+
else:
|
|
893
|
+
_LOGGER.warning("No API websocket connection")
|
|
894
|
+
return response
|
|
895
|
+
|
|
814
896
|
async def set_config_mode(self, system_serial: str, mode: SystemModes) -> dict[str, Any]:
|
|
815
897
|
"""Update a Carrier system's operating mode.
|
|
816
898
|
|
|
@@ -1084,3 +1166,54 @@ class ApiConnectionGraphql:
|
|
|
1084
1166
|
cool_set_point=cool_set_point,
|
|
1085
1167
|
fan_mode=fan_mode,
|
|
1086
1168
|
)
|
|
1169
|
+
|
|
1170
|
+
async def set_program_day(
|
|
1171
|
+
self,
|
|
1172
|
+
system_serial: str,
|
|
1173
|
+
zone_id: str,
|
|
1174
|
+
day_id: str,
|
|
1175
|
+
periods: list[dict[str, str]],
|
|
1176
|
+
etag: str,
|
|
1177
|
+
) -> dict[str, Any]:
|
|
1178
|
+
"""Replace one day of a zone's program.
|
|
1179
|
+
|
|
1180
|
+
The Carrier replaces the day with the periods sent, so pass every period
|
|
1181
|
+
of the day, enabled or not, as the systems query returns them.
|
|
1182
|
+
|
|
1183
|
+
Args:
|
|
1184
|
+
system_serial: Serial number of the system to update.
|
|
1185
|
+
zone_id: Carrier zone identifier.
|
|
1186
|
+
day_id: Carrier day identifier as the program returns it (``"0"`` to
|
|
1187
|
+
``"6"``).
|
|
1188
|
+
periods: The day's periods, each with ``id`` and ``activity`` and
|
|
1189
|
+
optionally ``time`` (``HH:MM``) and ``enabled`` (``on``/``off``).
|
|
1190
|
+
etag: Etag read from the systems query, which the mutation requires.
|
|
1191
|
+
|
|
1192
|
+
Returns:
|
|
1193
|
+
The decoded mutation response.
|
|
1194
|
+
|
|
1195
|
+
Raises:
|
|
1196
|
+
ValueError: If a period is missing ``id`` or ``activity``, has an
|
|
1197
|
+
unknown key, or names an activity that is not a valid
|
|
1198
|
+
``ActivityTypes`` value.
|
|
1199
|
+
"""
|
|
1200
|
+
activities = {activity.value for activity in ActivityTypes}
|
|
1201
|
+
allowed_keys = {"id", "activity", "time", "enabled"}
|
|
1202
|
+
for period in periods:
|
|
1203
|
+
unknown_keys = set(period) - allowed_keys
|
|
1204
|
+
if unknown_keys:
|
|
1205
|
+
raise ValueError(f"unknown program period keys: {sorted(unknown_keys)}")
|
|
1206
|
+
if not period.get("id"):
|
|
1207
|
+
raise ValueError("program period is missing its id")
|
|
1208
|
+
if period.get("activity") not in activities:
|
|
1209
|
+
raise ValueError(f"{period.get('activity')} is not a valid activity type")
|
|
1210
|
+
variables = {
|
|
1211
|
+
"input": {
|
|
1212
|
+
"serial": system_serial,
|
|
1213
|
+
"etag": etag,
|
|
1214
|
+
"zoneId": zone_id,
|
|
1215
|
+
"dayId": day_id,
|
|
1216
|
+
"period": [dict(period) for period in periods],
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
return await self._update_infinity_program_day(variables=variables)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: carrier_api
|
|
3
|
-
Version: 3.
|
|
3
|
+
Version: 3.7.0
|
|
4
4
|
Summary: Carrier Api Wrapper
|
|
5
5
|
Author-email: Bren Dahl <dahl.brendan@gmail.com>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -179,6 +179,9 @@ Available update helpers include:
|
|
|
179
179
|
- `set_config_hold(...)`
|
|
180
180
|
- `resume_schedule(...)`
|
|
181
181
|
- `set_config_manual_activity(...)`
|
|
182
|
+
- `set_program_day(...)`
|
|
183
|
+
|
|
184
|
+
`set_program_day(...)` replaces one day of a zone's program (`updateInfinityProgramDay`). Send every period of the day, enabled or not, with the `etag` the systems query returns (`load_data()` now reads the program's and each day's `etag`). It has not yet been verified against a live system.
|
|
182
185
|
|
|
183
186
|
These methods can change real HVAC settings. Validate the selected system, zone, mode, and set points before calling them from automation.
|
|
184
187
|
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
"""Tests for Carrier GraphQL API connection helpers."""
|
|
2
2
|
|
|
3
|
+
import asyncio
|
|
3
4
|
from datetime import UTC, datetime, timedelta
|
|
4
|
-
from typing import Any, Self, cast
|
|
5
|
+
from typing import Any, ClassVar, Self, cast
|
|
5
6
|
|
|
6
|
-
from aiohttp import ClientConnectionError, ClientError,
|
|
7
|
+
from aiohttp import ClientConnectionError, ClientError, ClientSession
|
|
7
8
|
from gql import GraphQLRequest, gql
|
|
8
9
|
from gql.transport.exceptions import TransportQueryError, TransportServerError
|
|
9
10
|
import pytest
|
|
@@ -15,86 +16,17 @@ from carrier_api.const import ActivityTypes, FanModes, HeatSourceTypes, SystemMo
|
|
|
15
16
|
from carrier_api.system import System
|
|
16
17
|
|
|
17
18
|
|
|
18
|
-
class FakeResponse:
|
|
19
|
-
"""Minimal aiohttp response double for token refresh tests."""
|
|
20
|
-
|
|
21
|
-
def __init__(
|
|
22
|
-
self,
|
|
23
|
-
payload: object,
|
|
24
|
-
status_error: ClientResponseError | None = None,
|
|
25
|
-
json_error: BaseException | None = None,
|
|
26
|
-
) -> None:
|
|
27
|
-
"""Initialize the fake response with JSON payload data.
|
|
28
|
-
|
|
29
|
-
Args:
|
|
30
|
-
payload: Data returned from ``json``.
|
|
31
|
-
status_error: Optional error raised by ``raise_for_status``.
|
|
32
|
-
json_error: Optional error raised by ``json``.
|
|
33
|
-
"""
|
|
34
|
-
self.payload = payload
|
|
35
|
-
self.status_error = status_error
|
|
36
|
-
self.json_error = json_error
|
|
37
|
-
self.raise_for_status_called = False
|
|
38
|
-
|
|
39
|
-
def raise_for_status(self) -> None:
|
|
40
|
-
"""Record status validation and raise the configured status error.
|
|
41
|
-
|
|
42
|
-
Raises:
|
|
43
|
-
ClientResponseError: When ``status_error`` is configured.
|
|
44
|
-
"""
|
|
45
|
-
self.raise_for_status_called = True
|
|
46
|
-
if self.status_error is not None:
|
|
47
|
-
raise self.status_error
|
|
48
|
-
|
|
49
|
-
async def json(self) -> object:
|
|
50
|
-
"""Return the configured JSON payload.
|
|
51
|
-
|
|
52
|
-
Returns:
|
|
53
|
-
The fake response payload.
|
|
54
|
-
|
|
55
|
-
Raises:
|
|
56
|
-
BaseException: When ``json_error`` is configured.
|
|
57
|
-
"""
|
|
58
|
-
if self.json_error is not None:
|
|
59
|
-
raise self.json_error
|
|
60
|
-
return self.payload
|
|
61
|
-
|
|
62
|
-
|
|
63
19
|
class FakeSession:
|
|
64
20
|
"""Minimal session double used by connection tests."""
|
|
65
21
|
|
|
66
22
|
def __init__(self) -> None:
|
|
67
23
|
"""Initialize captured session state."""
|
|
68
24
|
self.closed = False
|
|
69
|
-
self.post_url: str | None = None
|
|
70
|
-
self.post_data: dict[str, Any] | None = None
|
|
71
|
-
self.response = FakeResponse(
|
|
72
|
-
{
|
|
73
|
-
"expires_in": 3600,
|
|
74
|
-
"token_type": "Bearer",
|
|
75
|
-
"access_token": "new-access",
|
|
76
|
-
"refresh_token": "new-refresh",
|
|
77
|
-
}
|
|
78
|
-
)
|
|
79
25
|
|
|
80
26
|
async def close(self) -> None:
|
|
81
27
|
"""Record that the session was closed."""
|
|
82
28
|
self.closed = True
|
|
83
29
|
|
|
84
|
-
async def post(self, url: str, data: dict[str, Any]) -> FakeResponse:
|
|
85
|
-
"""Capture a token refresh request.
|
|
86
|
-
|
|
87
|
-
Args:
|
|
88
|
-
url: Requested URL.
|
|
89
|
-
data: Submitted form data.
|
|
90
|
-
|
|
91
|
-
Returns:
|
|
92
|
-
The fake token refresh response.
|
|
93
|
-
"""
|
|
94
|
-
self.post_url = url
|
|
95
|
-
self.post_data = data
|
|
96
|
-
return self.response
|
|
97
|
-
|
|
98
30
|
|
|
99
31
|
class FakeGraphQLClient:
|
|
100
32
|
"""Minimal gql client double that captures constructor arguments."""
|
|
@@ -436,254 +368,256 @@ async def test_cleanup_wraps_session_close_errors(
|
|
|
436
368
|
assert error.value.__cause__ is cleanup_error
|
|
437
369
|
|
|
438
370
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
"""Refresh auth tokens from the OAuth response payload."""
|
|
442
|
-
session = FakeSession()
|
|
443
|
-
connection = ApiConnectionGraphql(
|
|
444
|
-
username="user@example.com",
|
|
445
|
-
password="password",
|
|
446
|
-
client_session=cast("ClientSession", session),
|
|
447
|
-
)
|
|
448
|
-
connection.refresh_token = "old-refresh"
|
|
371
|
+
class FetchAccessTokenClient:
|
|
372
|
+
"""GraphQL client double for ``fetchAccessToken`` refresh calls."""
|
|
449
373
|
|
|
450
|
-
|
|
374
|
+
calls: ClassVar[list[dict[str, Any]]] = []
|
|
375
|
+
result: ClassVar[object] = None
|
|
376
|
+
error: ClassVar[BaseException | None] = None
|
|
451
377
|
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
assert session.post_data == {
|
|
455
|
-
"client_id": "0oa1ce7hwjuZbfOMB4x7",
|
|
456
|
-
"grant_type": "refresh_token",
|
|
457
|
-
"refresh_token": "old-refresh",
|
|
458
|
-
"scope": "offline_access",
|
|
459
|
-
}
|
|
460
|
-
assert connection.token_type == "Bearer"
|
|
461
|
-
assert connection.access_token == "new-access"
|
|
462
|
-
assert connection.refresh_token == "new-refresh"
|
|
463
|
-
assert connection.expires_at > datetime.now(UTC)
|
|
378
|
+
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
|
379
|
+
"""Accept GraphQL client construction arguments."""
|
|
464
380
|
|
|
381
|
+
async def __aenter__(self) -> Self:
|
|
382
|
+
"""Return the fake session.
|
|
465
383
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
ClientConnectionError("token refresh connection failed"),
|
|
471
|
-
TimeoutError("token refresh timed out"),
|
|
472
|
-
OSError("token refresh socket failed"),
|
|
473
|
-
],
|
|
474
|
-
)
|
|
475
|
-
async def test_refresh_auth_token_wraps_refresh_failures(
|
|
476
|
-
refresh_error: ClientError | TimeoutError | OSError,
|
|
477
|
-
) -> None:
|
|
478
|
-
"""Raise Carrier token refresh errors instead of raw HTTP exceptions."""
|
|
384
|
+
Returns:
|
|
385
|
+
The fake GraphQL session.
|
|
386
|
+
"""
|
|
387
|
+
return self
|
|
479
388
|
|
|
480
|
-
|
|
481
|
-
"""
|
|
389
|
+
async def __aexit__(self, *args: object) -> None:
|
|
390
|
+
"""Exit the fake session context.
|
|
482
391
|
|
|
483
|
-
|
|
484
|
-
|
|
392
|
+
Args:
|
|
393
|
+
args: Context manager exception details.
|
|
394
|
+
"""
|
|
485
395
|
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
396
|
+
async def execute(
|
|
397
|
+
self,
|
|
398
|
+
query: GraphQLRequest,
|
|
399
|
+
*,
|
|
400
|
+
variable_values: dict[str, Any],
|
|
401
|
+
operation_name: str,
|
|
402
|
+
) -> object:
|
|
403
|
+
"""Record the refresh call and return or raise the configured outcome.
|
|
489
404
|
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
raise refresh_error
|
|
405
|
+
Args:
|
|
406
|
+
query: GraphQL request.
|
|
407
|
+
variable_values: Variables supplied to the mutation.
|
|
408
|
+
operation_name: GraphQL operation name.
|
|
495
409
|
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
password="password",
|
|
499
|
-
client_session=cast("ClientSession", FailingSession()),
|
|
500
|
-
)
|
|
501
|
-
connection.refresh_token = "old-refresh"
|
|
410
|
+
Returns:
|
|
411
|
+
The configured GraphQL result.
|
|
502
412
|
|
|
503
|
-
|
|
504
|
-
|
|
413
|
+
Raises:
|
|
414
|
+
BaseException: When ``error`` is configured.
|
|
415
|
+
"""
|
|
416
|
+
FetchAccessTokenClient.calls.append(
|
|
417
|
+
{"operation_name": operation_name, "variables": variable_values}
|
|
418
|
+
)
|
|
419
|
+
if FetchAccessTokenClient.error is not None:
|
|
420
|
+
raise FetchAccessTokenClient.error
|
|
421
|
+
return FetchAccessTokenClient.result
|
|
505
422
|
|
|
506
|
-
assert error.value.__cause__ is refresh_error
|
|
507
423
|
|
|
424
|
+
@pytest.fixture
|
|
425
|
+
def fetch_access_token(monkeypatch: pytest.MonkeyPatch) -> type[FetchAccessTokenClient]:
|
|
426
|
+
"""Route GraphQL clients to the ``fetchAccessToken`` double.
|
|
508
427
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
""
|
|
428
|
+
Args:
|
|
429
|
+
monkeypatch: Pytest helper for replacing the GraphQL client.
|
|
430
|
+
|
|
431
|
+
Returns:
|
|
432
|
+
The configured client double class.
|
|
433
|
+
"""
|
|
434
|
+
FetchAccessTokenClient.calls = []
|
|
435
|
+
FetchAccessTokenClient.result = None
|
|
436
|
+
FetchAccessTokenClient.error = None
|
|
437
|
+
monkeypatch.setattr("carrier_api.api_connection_graphql.Client", FetchAccessTokenClient)
|
|
438
|
+
return FetchAccessTokenClient
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def _fetch_result(**data: object) -> dict[str, Any]:
|
|
442
|
+
"""Build a successful ``fetchAccessToken`` result.
|
|
519
443
|
|
|
520
444
|
Args:
|
|
521
|
-
|
|
522
|
-
|
|
445
|
+
data: Token fields overriding the defaults.
|
|
446
|
+
|
|
447
|
+
Returns:
|
|
448
|
+
A GraphQL result payload.
|
|
523
449
|
"""
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
450
|
+
token_data: dict[str, object] = {
|
|
451
|
+
"token_type": "Bearer",
|
|
452
|
+
"expires_in": 3600,
|
|
453
|
+
"access_token": "new-access",
|
|
454
|
+
"scope": "login",
|
|
455
|
+
"refresh_token": "new-refresh",
|
|
456
|
+
**data,
|
|
457
|
+
}
|
|
458
|
+
return {"fetchAccessToken": {"success": True, "errorMessage": None, "data": token_data}}
|
|
530
459
|
|
|
531
|
-
|
|
532
|
-
|
|
460
|
+
|
|
461
|
+
def _refresh_connection() -> ApiConnectionGraphql:
|
|
462
|
+
"""Build a connection holding a stored refresh token.
|
|
463
|
+
|
|
464
|
+
Returns:
|
|
465
|
+
A connection ready for ``refresh_auth_token``.
|
|
466
|
+
"""
|
|
533
467
|
connection = ApiConnectionGraphql(
|
|
534
468
|
username="user@example.com",
|
|
535
469
|
password="password",
|
|
536
|
-
client_session=cast("ClientSession",
|
|
470
|
+
client_session=cast("ClientSession", FakeSession()),
|
|
537
471
|
)
|
|
538
472
|
connection.refresh_token = "old-refresh"
|
|
473
|
+
return connection
|
|
539
474
|
|
|
540
|
-
with pytest.raises(errors.CarrierApiAuthError) as error:
|
|
541
|
-
await connection.refresh_auth_token()
|
|
542
475
|
|
|
543
|
-
|
|
476
|
+
@pytest.mark.asyncio
|
|
477
|
+
async def test_refresh_auth_token_uses_fetch_access_token(
|
|
478
|
+
fetch_access_token: type[FetchAccessTokenClient],
|
|
479
|
+
caplog: pytest.LogCaptureFixture,
|
|
480
|
+
) -> None:
|
|
481
|
+
"""Refresh through Carrier's mutation and store the rotated token state.
|
|
482
|
+
|
|
483
|
+
Args:
|
|
484
|
+
fetch_access_token: GraphQL client double for the refresh mutation.
|
|
485
|
+
caplog: Pytest log capture fixture.
|
|
486
|
+
"""
|
|
487
|
+
caplog.set_level("DEBUG", logger="carrier_api.api_connection_graphql")
|
|
488
|
+
fetch_access_token.result = _fetch_result()
|
|
489
|
+
connection = _refresh_connection()
|
|
490
|
+
|
|
491
|
+
await connection.refresh_auth_token()
|
|
492
|
+
|
|
493
|
+
assert fetch_access_token.calls == [
|
|
494
|
+
{
|
|
495
|
+
"operation_name": "fetchAccessToken",
|
|
496
|
+
"variables": {"input": {"username": "user@example.com", "refreshToken": "old-refresh"}},
|
|
497
|
+
}
|
|
498
|
+
]
|
|
499
|
+
assert connection.token_type == "Bearer"
|
|
500
|
+
assert connection.access_token == "new-access"
|
|
501
|
+
assert connection.refresh_token == "new-refresh"
|
|
502
|
+
assert connection.expires_at > datetime.now(UTC)
|
|
503
|
+
assert "refresh token rotated" in caplog.text
|
|
504
|
+
assert "new-refresh" not in caplog.text
|
|
505
|
+
assert "new-access" not in caplog.text
|
|
544
506
|
|
|
545
507
|
|
|
546
508
|
@pytest.mark.asyncio
|
|
547
|
-
@pytest.mark.parametrize(
|
|
548
|
-
|
|
549
|
-
[
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
],
|
|
553
|
-
)
|
|
554
|
-
async def test_refresh_auth_token_normalizes_unreadable_error_payloads(
|
|
555
|
-
json_error: TimeoutError | OSError,
|
|
509
|
+
@pytest.mark.parametrize("refresh_token", ["old-refresh", None])
|
|
510
|
+
async def test_refresh_auth_token_keeps_unrotated_refresh_token(
|
|
511
|
+
fetch_access_token: type[FetchAccessTokenClient],
|
|
512
|
+
caplog: pytest.LogCaptureFixture,
|
|
513
|
+
refresh_token: str | None,
|
|
556
514
|
) -> None:
|
|
557
|
-
"""
|
|
515
|
+
"""Keep the stored refresh token when the provider does not rotate it.
|
|
558
516
|
|
|
559
517
|
Args:
|
|
560
|
-
|
|
518
|
+
fetch_access_token: GraphQL client double for the refresh mutation.
|
|
519
|
+
caplog: Pytest log capture fixture.
|
|
520
|
+
refresh_token: Refresh token returned by the mutation.
|
|
561
521
|
"""
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
status=401,
|
|
566
|
-
message="unauthorized",
|
|
567
|
-
)
|
|
568
|
-
|
|
569
|
-
session = FakeSession()
|
|
570
|
-
session.response = FakeResponse(
|
|
571
|
-
{},
|
|
572
|
-
status_error=refresh_error,
|
|
573
|
-
json_error=json_error,
|
|
574
|
-
)
|
|
575
|
-
connection = ApiConnectionGraphql(
|
|
576
|
-
username="user@example.com",
|
|
577
|
-
password="password",
|
|
578
|
-
client_session=cast("ClientSession", session),
|
|
579
|
-
)
|
|
580
|
-
connection.refresh_token = "old-refresh"
|
|
522
|
+
caplog.set_level("DEBUG", logger="carrier_api.api_connection_graphql")
|
|
523
|
+
fetch_access_token.result = _fetch_result(refresh_token=refresh_token)
|
|
524
|
+
connection = _refresh_connection()
|
|
581
525
|
|
|
582
|
-
|
|
583
|
-
await connection.refresh_auth_token()
|
|
526
|
+
await connection.refresh_auth_token()
|
|
584
527
|
|
|
585
|
-
assert
|
|
528
|
+
assert connection.refresh_token == "old-refresh"
|
|
529
|
+
assert connection.access_token == "new-access"
|
|
530
|
+
assert "refresh token unchanged" in caplog.text
|
|
586
531
|
|
|
587
532
|
|
|
588
533
|
@pytest.mark.asyncio
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
payload: object,
|
|
534
|
+
async def test_refresh_auth_token_treats_unsuccessful_result_as_auth_error(
|
|
535
|
+
fetch_access_token: type[FetchAccessTokenClient],
|
|
592
536
|
) -> None:
|
|
593
|
-
"""
|
|
537
|
+
"""Raise auth errors when Carrier reports the refresh as unsuccessful.
|
|
594
538
|
|
|
595
539
|
Args:
|
|
596
|
-
|
|
540
|
+
fetch_access_token: GraphQL client double for the refresh mutation.
|
|
597
541
|
"""
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
session = FakeSession()
|
|
606
|
-
session.response = FakeResponse(payload, status_error=refresh_error)
|
|
607
|
-
connection = ApiConnectionGraphql(
|
|
608
|
-
username="user@example.com",
|
|
609
|
-
password="password",
|
|
610
|
-
client_session=cast("ClientSession", session),
|
|
611
|
-
)
|
|
612
|
-
connection.refresh_token = "old-refresh"
|
|
542
|
+
rejection = {
|
|
543
|
+
"success": False,
|
|
544
|
+
"errorMessage": "AADSTS9002313: Invalid request.",
|
|
545
|
+
"data": None,
|
|
546
|
+
}
|
|
547
|
+
fetch_access_token.result = {"fetchAccessToken": rejection}
|
|
548
|
+
connection = _refresh_connection()
|
|
613
549
|
|
|
614
|
-
with pytest.raises(errors.
|
|
550
|
+
with pytest.raises(errors.CarrierApiAuthError) as error:
|
|
615
551
|
await connection.refresh_auth_token()
|
|
616
552
|
|
|
617
|
-
assert error.value.
|
|
553
|
+
assert error.value.payload == rejection
|
|
554
|
+
assert connection.refresh_token == "old-refresh"
|
|
618
555
|
|
|
619
556
|
|
|
620
557
|
@pytest.mark.asyncio
|
|
621
|
-
|
|
622
|
-
""
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
558
|
+
@pytest.mark.parametrize(
|
|
559
|
+
("refresh_error", "expected"),
|
|
560
|
+
[
|
|
561
|
+
(TransportQueryError("refresh failed"), errors.CarrierApiTokenRefreshError),
|
|
562
|
+
(ClientConnectionError("connection failed"), errors.CarrierApiTokenRefreshError),
|
|
563
|
+
(TimeoutError("timed out"), errors.CarrierApiTokenRefreshError),
|
|
564
|
+
(OSError("socket failed"), errors.CarrierApiTokenRefreshError),
|
|
565
|
+
(TransportServerError("server error", 500), errors.CarrierApiTokenRefreshError),
|
|
566
|
+
(TransportServerError("unauthorized", 401), errors.CarrierApiAuthError),
|
|
567
|
+
],
|
|
568
|
+
)
|
|
569
|
+
async def test_refresh_auth_token_normalizes_request_failures(
|
|
570
|
+
fetch_access_token: type[FetchAccessTokenClient],
|
|
571
|
+
refresh_error: BaseException,
|
|
572
|
+
expected: type[errors.CarrierApiError],
|
|
573
|
+
) -> None:
|
|
574
|
+
"""Map refresh request failures to Carrier errors.
|
|
629
575
|
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
connection =
|
|
637
|
-
username="user@example.com",
|
|
638
|
-
password="password",
|
|
639
|
-
client_session=cast("ClientSession", session),
|
|
640
|
-
)
|
|
641
|
-
connection.refresh_token = "old-refresh"
|
|
576
|
+
Args:
|
|
577
|
+
fetch_access_token: GraphQL client double for the refresh mutation.
|
|
578
|
+
refresh_error: Error raised by the GraphQL client.
|
|
579
|
+
expected: Carrier error expected from the refresh.
|
|
580
|
+
"""
|
|
581
|
+
fetch_access_token.error = refresh_error
|
|
582
|
+
connection = _refresh_connection()
|
|
642
583
|
|
|
643
|
-
with pytest.raises(
|
|
584
|
+
with pytest.raises(expected) as error:
|
|
644
585
|
await connection.refresh_auth_token()
|
|
645
586
|
|
|
587
|
+
assert type(error.value) is expected
|
|
646
588
|
assert error.value.__cause__ is refresh_error
|
|
647
589
|
|
|
648
590
|
|
|
649
591
|
@pytest.mark.asyncio
|
|
650
592
|
@pytest.mark.parametrize(
|
|
651
|
-
|
|
593
|
+
"result",
|
|
652
594
|
[
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
KeyError,
|
|
660
|
-
),
|
|
661
|
-
(None, TypeError),
|
|
595
|
+
None,
|
|
596
|
+
{},
|
|
597
|
+
{"fetchAccessToken": None},
|
|
598
|
+
{"fetchAccessToken": {"success": True, "errorMessage": None, "data": None}},
|
|
599
|
+
{"fetchAccessToken": {"success": True, "data": {}}},
|
|
600
|
+
{"fetchAccessToken": {"success": True, "data": {"token_type": "Bearer"}}},
|
|
662
601
|
],
|
|
663
602
|
)
|
|
664
|
-
async def
|
|
665
|
-
|
|
666
|
-
|
|
603
|
+
async def test_refresh_auth_token_rejects_malformed_results(
|
|
604
|
+
fetch_access_token: type[FetchAccessTokenClient],
|
|
605
|
+
result: object,
|
|
667
606
|
) -> None:
|
|
668
|
-
"""Raise token refresh errors for malformed
|
|
607
|
+
"""Raise token refresh errors for malformed mutation results.
|
|
669
608
|
|
|
670
609
|
Args:
|
|
671
|
-
|
|
672
|
-
|
|
610
|
+
fetch_access_token: GraphQL client double for the refresh mutation.
|
|
611
|
+
result: Malformed GraphQL result.
|
|
673
612
|
"""
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
connection = ApiConnectionGraphql(
|
|
677
|
-
username="user@example.com",
|
|
678
|
-
password="password",
|
|
679
|
-
client_session=cast("ClientSession", session),
|
|
680
|
-
)
|
|
681
|
-
connection.refresh_token = "old-refresh"
|
|
613
|
+
fetch_access_token.result = result
|
|
614
|
+
connection = _refresh_connection()
|
|
682
615
|
|
|
683
|
-
with pytest.raises(errors.CarrierApiTokenRefreshError)
|
|
616
|
+
with pytest.raises(errors.CarrierApiTokenRefreshError):
|
|
684
617
|
await connection.refresh_auth_token()
|
|
685
618
|
|
|
686
|
-
assert
|
|
619
|
+
assert connection.refresh_token == "old-refresh"
|
|
620
|
+
assert connection.access_token is None
|
|
687
621
|
|
|
688
622
|
|
|
689
623
|
@pytest.mark.asyncio
|
|
@@ -704,6 +638,107 @@ async def test_check_auth_expiration_logs_in_then_refreshes_when_expired(
|
|
|
704
638
|
assert connection.refresh_count == 1
|
|
705
639
|
|
|
706
640
|
|
|
641
|
+
class RefreshSpyConnection(ApiConnectionGraphql):
|
|
642
|
+
"""Connection double whose token refresh outcome is configurable."""
|
|
643
|
+
|
|
644
|
+
def __init__(self, refresh_error: BaseException | None = None) -> None:
|
|
645
|
+
"""Initialize an expired connection with a stored refresh token.
|
|
646
|
+
|
|
647
|
+
Args:
|
|
648
|
+
refresh_error: Optional error raised by every token refresh.
|
|
649
|
+
"""
|
|
650
|
+
super().__init__(
|
|
651
|
+
username="user@example.com",
|
|
652
|
+
password="password",
|
|
653
|
+
client_session=cast("ClientSession", FakeSession()),
|
|
654
|
+
)
|
|
655
|
+
self.refresh_error = refresh_error
|
|
656
|
+
self.login_error: BaseException | None = None
|
|
657
|
+
self.refresh_token = "stored-refresh"
|
|
658
|
+
self.expires_at = datetime.now(UTC) - timedelta(seconds=1)
|
|
659
|
+
self.login_count = 0
|
|
660
|
+
self.refreshed_tokens: list[str | None] = []
|
|
661
|
+
|
|
662
|
+
async def login(self) -> None:
|
|
663
|
+
"""Record login calls and seed fresh token state.
|
|
664
|
+
|
|
665
|
+
Raises:
|
|
666
|
+
BaseException: When ``login_error`` is configured.
|
|
667
|
+
"""
|
|
668
|
+
self.login_count += 1
|
|
669
|
+
if self.login_error is not None:
|
|
670
|
+
raise self.login_error
|
|
671
|
+
self.refresh_token = "login-refresh"
|
|
672
|
+
self.expires_at = datetime.now(UTC) + timedelta(hours=1)
|
|
673
|
+
|
|
674
|
+
async def refresh_auth_token(self) -> None:
|
|
675
|
+
"""Record the refresh token used and rotate it like Okta.
|
|
676
|
+
|
|
677
|
+
Raises:
|
|
678
|
+
BaseException: When ``refresh_error`` is configured.
|
|
679
|
+
"""
|
|
680
|
+
self.refreshed_tokens.append(self.refresh_token)
|
|
681
|
+
await asyncio.sleep(0)
|
|
682
|
+
if self.refresh_error is not None:
|
|
683
|
+
raise self.refresh_error
|
|
684
|
+
self.refresh_token = f"rotated-{len(self.refreshed_tokens)}"
|
|
685
|
+
self.expires_at = datetime.now(UTC) + timedelta(hours=1)
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
@pytest.mark.asyncio
|
|
689
|
+
async def test_check_auth_expiration_logs_in_when_refresh_is_rejected() -> None:
|
|
690
|
+
"""Fall back to one password login when Carrier rejects the refresh token."""
|
|
691
|
+
connection = RefreshSpyConnection(errors.CarrierApiAuthError("rejected"))
|
|
692
|
+
|
|
693
|
+
await connection.check_auth_expiration()
|
|
694
|
+
|
|
695
|
+
assert connection.refreshed_tokens == ["stored-refresh"]
|
|
696
|
+
assert connection.login_count == 1
|
|
697
|
+
assert connection.refresh_token == "login-refresh"
|
|
698
|
+
assert connection.expires_at > datetime.now(UTC)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
@pytest.mark.asyncio
|
|
702
|
+
async def test_check_auth_expiration_raises_when_fallback_login_is_rejected() -> None:
|
|
703
|
+
"""Surface auth errors when the fallback password login is also rejected."""
|
|
704
|
+
connection = RefreshSpyConnection(errors.CarrierApiAuthError("rejected"))
|
|
705
|
+
login_error = errors.CarrierApiAuthError("bad password")
|
|
706
|
+
connection.login_error = login_error
|
|
707
|
+
|
|
708
|
+
with pytest.raises(errors.CarrierApiAuthError) as error:
|
|
709
|
+
await connection.check_auth_expiration()
|
|
710
|
+
|
|
711
|
+
assert error.value is login_error
|
|
712
|
+
assert connection.refresh_token is None
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
@pytest.mark.asyncio
|
|
716
|
+
async def test_check_auth_expiration_keeps_token_on_transient_refresh_failure() -> None:
|
|
717
|
+
"""Do not log in again when token refresh fails for transient reasons."""
|
|
718
|
+
connection = RefreshSpyConnection(errors.CarrierApiTokenRefreshError("network"))
|
|
719
|
+
|
|
720
|
+
with pytest.raises(errors.CarrierApiTokenRefreshError):
|
|
721
|
+
await connection.check_auth_expiration()
|
|
722
|
+
|
|
723
|
+
assert connection.login_count == 0
|
|
724
|
+
assert connection.refresh_token == "stored-refresh"
|
|
725
|
+
|
|
726
|
+
|
|
727
|
+
@pytest.mark.asyncio
|
|
728
|
+
async def test_check_auth_expiration_serializes_concurrent_refreshes() -> None:
|
|
729
|
+
"""Send a single-use refresh token only once when callers race after expiry."""
|
|
730
|
+
connection = RefreshSpyConnection()
|
|
731
|
+
|
|
732
|
+
await asyncio.gather(
|
|
733
|
+
connection.check_auth_expiration(),
|
|
734
|
+
connection.check_auth_expiration(),
|
|
735
|
+
connection.check_auth_expiration(),
|
|
736
|
+
)
|
|
737
|
+
|
|
738
|
+
assert connection.refreshed_tokens == ["stored-refresh"]
|
|
739
|
+
assert connection.refresh_token == "rotated-1"
|
|
740
|
+
|
|
741
|
+
|
|
707
742
|
@pytest.mark.asyncio
|
|
708
743
|
async def test_login_wraps_graphql_query_errors(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
709
744
|
"""Raise Carrier GraphQL errors instead of raw GraphQL query exceptions.
|
|
@@ -1189,6 +1224,7 @@ async def test_update_methods_send_reconcile_when_websocket_exists() -> None:
|
|
|
1189
1224
|
config_variables: dict[str, Any] = {"input": {"serial": "SERIAL"}}
|
|
1190
1225
|
zone_activity_variables: dict[str, Any] = {"input": {"serial": "SERIAL"}}
|
|
1191
1226
|
zone_config_variables: dict[str, Any] = {"input": {"serial": "SERIAL"}}
|
|
1227
|
+
program_day_variables: dict[str, Any] = {"input": {"serial": "SERIAL"}}
|
|
1192
1228
|
|
|
1193
1229
|
assert await connection._update_infinity_config(config_variables) == {
|
|
1194
1230
|
"operation": "updateInfinityConfig",
|
|
@@ -1202,7 +1238,11 @@ async def test_update_methods_send_reconcile_when_websocket_exists() -> None:
|
|
|
1202
1238
|
"operation": "updateInfinityZoneConfig",
|
|
1203
1239
|
"variables": zone_config_variables,
|
|
1204
1240
|
}
|
|
1205
|
-
assert
|
|
1241
|
+
assert await connection._update_infinity_program_day(program_day_variables) == {
|
|
1242
|
+
"operation": "updateInfinityProgramDay",
|
|
1243
|
+
"variables": program_day_variables,
|
|
1244
|
+
}
|
|
1245
|
+
assert websocket.calls == 4
|
|
1206
1246
|
|
|
1207
1247
|
|
|
1208
1248
|
@pytest.mark.asyncio
|
|
@@ -1275,3 +1315,107 @@ async def test_load_entry_level_data_parses_systems(
|
|
|
1275
1315
|
assert len(systems) == 1
|
|
1276
1316
|
assert systems[0].serial == "SERIALXXX"
|
|
1277
1317
|
assert systems[0].zones[0].mode == "cool"
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
PROGRAM_DAY_PERIODS: list[dict[str, str]] = [
|
|
1321
|
+
{"id": "1", "activity": "wake", "time": "06:00", "enabled": "on"},
|
|
1322
|
+
{"id": "2", "activity": "away", "time": "08:00", "enabled": "on"},
|
|
1323
|
+
{"id": "3", "activity": "home", "time": "18:00", "enabled": "on"},
|
|
1324
|
+
{"id": "4", "activity": "sleep", "time": "22:00", "enabled": "on"},
|
|
1325
|
+
{"id": "5", "activity": "home", "time": "00:00", "enabled": "off"},
|
|
1326
|
+
]
|
|
1327
|
+
|
|
1328
|
+
|
|
1329
|
+
@pytest.mark.asyncio
|
|
1330
|
+
async def test_set_program_day_sends_the_day_with_its_etag(connection: SpyConnection) -> None:
|
|
1331
|
+
"""Send every period of the day unchanged with the caller's etag.
|
|
1332
|
+
|
|
1333
|
+
Args:
|
|
1334
|
+
connection: Spy connection under test.
|
|
1335
|
+
"""
|
|
1336
|
+
await connection.set_program_day(
|
|
1337
|
+
system_serial="SERIAL",
|
|
1338
|
+
zone_id="1",
|
|
1339
|
+
day_id="3",
|
|
1340
|
+
periods=PROGRAM_DAY_PERIODS,
|
|
1341
|
+
etag="ETAG",
|
|
1342
|
+
)
|
|
1343
|
+
|
|
1344
|
+
assert connection.authed_calls == [
|
|
1345
|
+
(
|
|
1346
|
+
"updateInfinityProgramDay",
|
|
1347
|
+
{
|
|
1348
|
+
"input": {
|
|
1349
|
+
"serial": "SERIAL",
|
|
1350
|
+
"etag": "ETAG",
|
|
1351
|
+
"zoneId": "1",
|
|
1352
|
+
"dayId": "3",
|
|
1353
|
+
"period": PROGRAM_DAY_PERIODS,
|
|
1354
|
+
}
|
|
1355
|
+
},
|
|
1356
|
+
)
|
|
1357
|
+
]
|
|
1358
|
+
|
|
1359
|
+
|
|
1360
|
+
@pytest.mark.asyncio
|
|
1361
|
+
async def test_set_program_day_omits_optional_period_fields(connection: SpyConnection) -> None:
|
|
1362
|
+
"""Send only the period fields the caller supplied.
|
|
1363
|
+
|
|
1364
|
+
Args:
|
|
1365
|
+
connection: Spy connection under test.
|
|
1366
|
+
"""
|
|
1367
|
+
await connection.set_program_day("SERIAL", "1", "0", [{"id": "1", "activity": "home"}], "E")
|
|
1368
|
+
|
|
1369
|
+
assert connection.authed_calls[0][1]["input"]["period"] == [{"id": "1", "activity": "home"}]
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
@pytest.mark.asyncio
|
|
1373
|
+
@pytest.mark.parametrize(
|
|
1374
|
+
"period",
|
|
1375
|
+
[
|
|
1376
|
+
{"activity": "home", "time": "06:00"},
|
|
1377
|
+
{"id": "1", "time": "06:00"},
|
|
1378
|
+
{"id": "1", "activity": "nap", "time": "06:00"},
|
|
1379
|
+
{"id": "1", "activity": "home", "colour": "blue"},
|
|
1380
|
+
],
|
|
1381
|
+
)
|
|
1382
|
+
async def test_set_program_day_rejects_invalid_periods(
|
|
1383
|
+
connection: SpyConnection, period: dict[str, str]
|
|
1384
|
+
) -> None:
|
|
1385
|
+
"""Reject periods the Carrier schema would refuse before sending anything.
|
|
1386
|
+
|
|
1387
|
+
Args:
|
|
1388
|
+
connection: Spy connection under test.
|
|
1389
|
+
period: Invalid period payload.
|
|
1390
|
+
"""
|
|
1391
|
+
with pytest.raises(ValueError):
|
|
1392
|
+
await connection.set_program_day("SERIAL", "1", "0", [period], "ETAG")
|
|
1393
|
+
|
|
1394
|
+
assert connection.authed_calls == []
|
|
1395
|
+
|
|
1396
|
+
|
|
1397
|
+
@pytest.mark.asyncio
|
|
1398
|
+
async def test_set_program_day_surfaces_a_stale_etag(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
1399
|
+
"""Raise a Carrier GraphQL error when the server refuses the day.
|
|
1400
|
+
|
|
1401
|
+
Args:
|
|
1402
|
+
monkeypatch: Pytest helper for replacing the GraphQL client.
|
|
1403
|
+
"""
|
|
1404
|
+
transport_error = TransportQueryError("etag mismatch")
|
|
1405
|
+
monkeypatch.setattr(
|
|
1406
|
+
"carrier_api.api_connection_graphql.Client", graphql_client_double(error=transport_error)
|
|
1407
|
+
)
|
|
1408
|
+
connection = ApiConnectionGraphql(
|
|
1409
|
+
username="user@example.com",
|
|
1410
|
+
password="password",
|
|
1411
|
+
client_session=cast("ClientSession", FakeSession()),
|
|
1412
|
+
)
|
|
1413
|
+
connection.refresh_token = "refresh"
|
|
1414
|
+
connection.token_type = "Bearer"
|
|
1415
|
+
connection.access_token = "access"
|
|
1416
|
+
connection.expires_at = datetime.now(UTC) + timedelta(hours=1)
|
|
1417
|
+
|
|
1418
|
+
with pytest.raises(errors.CarrierApiGraphqlError) as error:
|
|
1419
|
+
await connection.set_program_day("SERIAL", "1", "0", PROGRAM_DAY_PERIODS, "STALE")
|
|
1420
|
+
|
|
1421
|
+
assert error.value.__cause__ is transport_error
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|