diff --git a/README.rst b/README.rst index e4541a3..38ccf34 100644 --- a/README.rst +++ b/README.rst @@ -1,28 +1,16 @@ Python-xsense ============= -Python-xsense is a small library to interact with the API of XSense Home -Security. It allows to retrieve the status of various devices and the -basestation. +Python-xsense is an async-only client library for X-Sense cloud accounts, +AWS IoT shadows, MQTT updates, ADDX/VicoHome camera APIs, camera history, +live-view helpers, and supported device actions/settings. -Example sync usage ------------------- +This package contains reusable Python client logic only. Home Assistant +entity descriptions, icons, translations, blueprints, platform setup, and +registry cleanup belong in the Home Assistant integration. -:: - - >>> from xsense import XSense - >>> from xsense.utils import dump_environment - >>> api = XSense() - >>> api.init() - >>> api.login(username, password) - >>> api.load_all() - >>> for _, h in api.houses.items(): - >>> for _, s in h.stations.items(): - >>> api.get_state(s) - >>> dump_environment(api) - -Example async usage -------------------- +Client usage +------------ :: @@ -31,18 +19,36 @@ Example async usage >>> from xsense.utils import dump_environment >>> >>> async def run(username: str, password: str): - >>> api = AsyncXSense() - >>> await api.init() - >>> await api.login(username, password) - >>> for _, h in api.houses.items(): - >>> for _, s in h.stations.items(): - >>> await api.get_state(s) - >>> dump_environment(api) + >>> async with AsyncXSense() as api: + >>> await api.init() + >>> await api.login(username, password) + >>> await api.load_all() + >>> for house in api.houses.values(): + >>> for station in house.stations.values(): + >>> await api.get_state(station) + >>> dump_environment(api) >>> >>> asyncio.run(run(username, password)) +Reusable helpers +---------------- + +The main async client lives in ``xsense.AsyncXSense``. It exposes the current +X-Sense app API request shape, Cognito session refresh, AWS IoT shadow +reads/writes, ADDX/VicoHome camera discovery, camera thumbnail and live-view +helpers, and supported device actions/settings. + +Pure parsing helpers live in separate modules: + +* ``xsense.event_parser`` parses MQTT, camera history, AI detection, presence, + and self-test event payloads. +* ``xsense.mqtt_helper`` handles X-Sense MQTT connection setup, topics, + subscription helpers, payload parsing, and publish helpers. +* ``xsense.webrtc_signal`` parses ADDX WebRTC tickets and builds/parses the + signal-server SDP and ICE payloads. + Development ----------- -This library is in an early development stage and created primarily to -make an integration for home assistant. +This library is in an early development stage. It is maintained as a +shared upstream client for integrations and other async Python consumers. diff --git a/asynctest.py b/asynctest.py index 2670d16..42424be 100644 --- a/asynctest.py +++ b/asynctest.py @@ -1,26 +1,27 @@ import asyncio -from xsense.async_xsense import AsyncXSense +from xsense import AsyncXSense from xsense.utils import dump_environment, get_credentials async def run(username: str, password: str): - api = AsyncXSense() - await api.init() - await api.login(username, password) - await api.load_all() + async with AsyncXSense() as api: + await api.init() + await api.login(username, password) + await api.load_all() - for _, h in api.houses.items(): - await api.get_house_state(h) - for _, s in h.stations.items(): - await api.get_station_state(s) - await api.get_state(s) + for h in api.houses.values(): + await api.get_house_state(h) + for s in h.stations.values(): + await api.get_station_state(s) + await api.get_state(s) - if s.has_alarm: - await api.get_alarm_state(s) + if s.has_alarm: + await api.get_alarm_state(s) - dump_environment(api) + dump_environment(api) -username, password = get_credentials() -asyncio.run(run(username, password)) +if __name__ == "__main__": + username, password = get_credentials() + asyncio.run(run(username, password)) diff --git a/docs/protocol.rst b/docs/protocol.rst index 5e485ce..8dc8db4 100644 --- a/docs/protocol.rst +++ b/docs/protocol.rst @@ -43,9 +43,9 @@ See below Clienttype: 1 for IOS, 2 for Android. -App-version currently is "v1.17.2_20240115". +App-version currently is "v1.36.0_20260130". -AppCode looks like an encoded version of the App-version and is currenlty 1172 (v **1** . **17** . **2** _20240115) +AppCode looks like an encoded version of the App-version and is currently 1360 (v **1** . **36** . **0** _20260130) The bizCode specifies which command is requested. @@ -57,10 +57,8 @@ Some requests can be unauthenticated, other must be authenticated. Authention ca MAC-hash -------- -All requests must include a MAC-hash. This is a md5-hash calculated over the values of all custom field included in the -request combined with the secret key. -The implementation for the hash-calculation is probably incorrect. Keys in a python-dict are unsorted which result -in hashes that are unpredictable. +All requests must include a MAC-hash. This is a md5-hash calculated over the values of all custom fields included in the +request combined with the secret key. Container values are serialized in the compact JSON form used by the Android app. Commands ======== diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..2f21011 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["setuptools>=40.8.0", "wheel"] +build-backend = "setuptools.build_meta" diff --git a/setup.py b/setup.py index d8b1baa..fc328f4 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ from setuptools import setup, find_packages -VERSION = '0.0.17' -DESCRIPTION = 'XSense Python Module' +VERSION = '0.1.0' +DESCRIPTION = 'Async X-Sense cloud, MQTT, and camera client' with open('README.rst', 'r') as fd: LONG_DESCRIPTION = fd.read() @@ -11,26 +11,33 @@ version=VERSION, description=DESCRIPTION, long_description=LONG_DESCRIPTION, + long_description_content_type='text/x-rst', url='https://github.com/theosnel/python-xsense', license='MIT', author='Theo Snelleman', author_email='', packages=find_packages(), + python_requires='>=3.10', install_requires=[ - 'requests', 'boto3', 'botocore', 'pycognito', + 'aiohttp', 'paho-mqtt>=2.1.0', ], - extras_require={'async': ['aiohttp']}, + extras_require={ + 'test': ['pytest'], + }, keywords=['python', 'xsense'], classifiers=[ 'Development Status :: 3 - Alpha', - 'Intended Audience :: Education', - 'License :: OSI Approved :: MIT License', + 'Intended Audience :: Developers', 'Programming Language :: Python :: 3', + 'Programming Language :: Python :: 3.10', + 'Programming Language :: Python :: 3.11', + 'Programming Language :: Python :: 3.12', + 'Programming Language :: Python :: 3.13', 'Operating System :: OS Independent', ] -) \ No newline at end of file +) diff --git a/test.py b/test.py deleted file mode 100644 index c0f78d1..0000000 --- a/test.py +++ /dev/null @@ -1,35 +0,0 @@ -from contextlib import suppress - -from xsense import XSense -from xsense.exceptions import APIFailure, NotFoundError - -from xsense.utils import dump_environment, get_credentials - -api = XSense() -api.init() - -username, password = get_credentials() -api.login(username, password) -api.load_all() -for _, h in api.houses.items(): - try: - api.get_house_state(h) - except NotFoundError: - print(f'could not retrieve status for {h.name}') - for _, s in h.stations.items(): - try: - api.get_station_state(s) - api.get_state(s) - - if s.has_alarm: - api.get_alarm_state(s) - - for _, d in s.devices.items(): - with suppress(NotFoundError): - res = api.get_station_state(s) - res = api.get_state(s) - - except APIFailure as e: - print(f'ERROR: {e}') - -dump_environment(api) diff --git a/tests/test_auth_request_shape.py b/tests/test_auth_request_shape.py new file mode 100644 index 0000000..9245a21 --- /dev/null +++ b/tests/test_auth_request_shape.py @@ -0,0 +1,913 @@ +import asyncio +import base64 +from datetime import datetime, timezone +import hashlib +import json +from types import SimpleNamespace + +import pytest + +from xsense.async_xsense import AsyncXSense +from xsense.base import XSenseBase, shadow_update_body +from xsense.exceptions import APIFailure, SessionExpired + + +def _test_jwt(claims): + header = base64.urlsafe_b64encode(b'{"alg":"none"}').rstrip(b"=").decode() + payload = ( + base64.urlsafe_b64encode(json.dumps(claims).encode()).rstrip(b"=").decode() + ) + return f"{header}.{payload}." + + +def test_restore_session_sets_user_id_code_from_token(): + client = XSenseBase() + + client.restore_session( + "user@example.com", + "access", + "refresh", + _test_jwt({"user_id_code": "user-id-code"}), + ) + + assert client.user_id_code == "user-id-code" + + +def test_restore_session_ignores_malformed_user_id_code_token(): + client = XSenseBase() + + client.restore_session("user@example.com", "bad-token", "refresh", "also.bad") + + assert client.user_id_code is None + + +def test_parse_refresh_result_updates_user_id_code(): + client = XSenseBase() + + client._parse_refresh_result( + { + "AccessToken": _test_jwt({"user_id_code": "access-user-code"}), + "ExpiresIn": 3600, + } + ) + + assert client.user_id_code == "access-user-code" + + +def test_calculate_mac_uses_compact_app_json_for_container_values(): + client = XSenseBase() + client.clientsecret = b"secret" + data = { + "enabled": True, + "empty": [], + "missing": None, + "labels": ["front", "中文"], + "settings": {"label": "中文", "enabled": False}, + "items": [{"a": 1}], + } + + expected_input = ( + 'true' + 'null' + 'front' + '中文' + '{"label":"中文","enabled":false}' + '[{"a":1}]' + ) + expected = hashlib.md5(expected_input.encode("utf-8") + b"secret").hexdigest() + + assert client._calculate_mac(data) == expected + + +class AsyncFakeResponse: + def __init__(self, status=200, payload=None): + self.status = status + self._payload = payload or {"reCode": 200, "reData": {"ok": True}} + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback): + return None + + async def json(self): + return self._payload + + async def text(self): + return json.dumps(self._payload) + + +class AsyncFakeSession: + closed = False + + def __init__(self, responses=None): + self.calls = [] + self.responses = list(responses or []) + + def post(self, url, **kwargs): + self.calls.append({"url": url, **kwargs}) + if self.responses: + return self.responses.pop(0) + return AsyncFakeResponse() + + def get(self, url, **kwargs): + self.calls.append({"url": url, **kwargs}) + if self.responses: + return self.responses.pop(0) + return AsyncFakeResponse() + + +def test_async_app_call_uses_current_app_metadata_and_mac(): + session = AsyncFakeSession() + client = AsyncXSense(session) + client.clientsecret = b"secret" + client.access_token = "access-token" + client.access_token_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc) + + result = asyncio.run(client.api_call("701001", userId="user-id-code")) + + assert result == {"ok": True} + call = session.calls[0] + assert call["url"] == "https://api.x-sense-iot.com/app" + assert call["headers"] == {"Authorization": "access-token"} + assert call["json"]["userId"] == "user-id-code" + assert call["json"]["bizCode"] == "701001" + assert call["json"]["appCode"] == "1360" + assert call["json"]["appVersion"] == "v1.36.0_20260130" + assert call["json"]["clientType"] == "2" + assert call["json"]["mac"] == client._calculate_mac({"userId": "user-id-code"}) + + +class RefreshingClient(AsyncXSense): + def __init__(self, session): + super().__init__(session) + self.refreshes = 0 + + async def refresh(self): + self.refreshes += 1 + self.access_token = f"refreshed-token-{self.refreshes}" + self.access_token_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc) + + +def test_async_app_call_refreshes_expiring_access_token_before_request(): + session = AsyncFakeSession() + client = RefreshingClient(session) + client.clientsecret = b"secret" + client.access_token = "old-token" + client.access_token_expiry = datetime.now(timezone.utc) + + result = asyncio.run(client.api_call("701001", userId="user-id-code")) + + assert result == {"ok": True} + assert client.refreshes == 1 + assert session.calls[0]["headers"] == {"Authorization": "refreshed-token-1"} + + +def test_async_app_call_raises_session_expired_for_app_expiry_codes(): + session = AsyncFakeSession( + [ + AsyncFakeResponse( + payload={ + "reCode": 400, + "errCode": "10000008", + "reMsg": "session expired", + } + ) + ] + ) + client = AsyncXSense(session) + client.clientsecret = b"secret" + client.access_token = "access-token" + client.access_token_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc) + + with pytest.raises(SessionExpired, match="session expired"): + asyncio.run(client.api_call("701001", userId="user-id-code")) + + +def test_refresh_updates_tokens_and_user_id_code(): + id_token = _test_jwt({"user_id_code": "user-id-code"}) + session = AsyncFakeSession( + [ + AsyncFakeResponse( + payload={ + "AuthenticationResult": { + "IdToken": id_token, + "AccessToken": "new-access-token", + "RefreshToken": "new-refresh-token", + "ExpiresIn": 3600, + } + } + ) + ] + ) + client = AsyncXSense(session) + client.refresh_token = "old-refresh-token" + client.region = "us-east-1" + client.clientid = "client-id" + client.clientsecret = b"client-secret" + + asyncio.run(client.refresh()) + + assert client.id_token == id_token + assert client.access_token == "new-access-token" + assert client.refresh_token == "new-refresh-token" + assert client.user_id_code == "user-id-code" + assert session.calls[0]["url"] == "https://cognito-idp.us-east-1.amazonaws.com" + assert session.calls[0]["json"]["AuthFlow"] == "REFRESH_TOKEN_AUTH" + assert session.calls[0]["json"]["AuthParameters"] == { + "REFRESH_TOKEN": "old-refresh-token", + "SECRET_HASH": "client-secret", + } + assert session.calls[0]["json"]["ClientId"] == "client-id" + + +def test_refresh_raises_session_expired_on_http_failure(): + session = AsyncFakeSession( + [AsyncFakeResponse(status=400, payload={"message": "refresh failed"})] + ) + client = AsyncXSense(session) + client.refresh_token = "old-refresh-token" + client.region = "us-east-1" + client.clientid = "client-id" + client.clientsecret = b"client-secret" + + with pytest.raises(SessionExpired, match="refresh failed"): + asyncio.run(client.refresh()) + + +class ShadowSigner: + def __init__(self): + self.calls = [] + + def sign_headers(self, method, url, region, headers, data): + self.calls.append((method, url, region, headers, data)) + return {"Authorization": "signed"} + + +def _shadow_station(): + return SimpleNamespace( + house=SimpleNamespace(mqtt_region="us-east-1"), + sn="BASE123", + type="SBS10", + shadow_name="BASE123", + ) + + +def test_shadow_update_body_uses_compact_utf8_json(): + payload = {"state": {"desired": {"label": "中文", "enabled": True}}} + + assert ( + shadow_update_body(payload) + == '{"state":{"desired":{"label":"中文","enabled":true}}}' + ) + + +def test_async_do_thing_signs_and_sends_same_serialized_body(): + session = AsyncFakeSession([AsyncFakeResponse(payload={"ok": True})]) + client = AsyncXSense(session) + client.aws_access_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc) + client.aws_session_token = "aws-token" + client.signer = ShadowSigner() + payload = {"state": {"desired": {"label": "中文", "enabled": True}}} + + result = asyncio.run(client.do_thing(_shadow_station(), "infoDev", payload)) + + expected_body = shadow_update_body(payload) + assert result == {"ok": True} + assert session.calls[0]["data"] == expected_body + assert session.calls[0]["headers"]["Authorization"] == "signed" + assert client.signer.calls[0][4] == expected_body + + +class RefreshingAwsClient(AsyncXSense): + def __init__(self, session): + super().__init__(session) + self.aws_access_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc) + self.aws_session_token = "expired-aws-token" + self.signer = ShadowSigner() + self.aws_loads = 0 + + async def load_aws(self): + self.aws_loads += 1 + self.aws_session_token = f"fresh-aws-token-{self.aws_loads}" + self.signer = ShadowSigner() + + +def test_get_thing_refreshes_aws_token_and_retries_once_after_forbidden(): + session = AsyncFakeSession( + [ + AsyncFakeResponse( + status=403, + payload={"message": "Forbidden", "traceId": "trace"}, + ), + AsyncFakeResponse(payload={"state": {"reported": {"ok": True}}}), + ] + ) + client = RefreshingAwsClient(session) + station = _shadow_station() + + result = asyncio.run(client.get_thing(station, "infoDev")) + + assert result == {"state": {"reported": {"ok": True}}} + assert client.aws_loads == 1 + assert len(session.calls) == 2 + assert session.calls[0]["headers"]["X-Amz-Security-Token"] == "expired-aws-token" + assert session.calls[1]["headers"]["X-Amz-Security-Token"] == "fresh-aws-token-1" + + +def test_do_thing_refreshes_aws_token_and_retries_once_after_forbidden(): + session = AsyncFakeSession( + [ + AsyncFakeResponse(status=403, payload={"message": "Forbidden"}), + AsyncFakeResponse(payload={"ok": True}), + ] + ) + client = RefreshingAwsClient(session) + payload = {"state": {"desired": {"enabled": True}}} + + result = asyncio.run(client.do_thing(_shadow_station(), "infoDev", payload)) + + assert result == {"ok": True} + assert client.aws_loads == 1 + assert len(session.calls) == 2 + assert session.calls[0]["headers"]["X-Amz-Security-Token"] == "expired-aws-token" + assert session.calls[1]["headers"]["X-Amz-Security-Token"] == "fresh-aws-token-1" + + +def test_get_house_signs_shadow_read_request(): + session = AsyncFakeSession([AsyncFakeResponse(payload={"state": {"reported": {}}})]) + client = AsyncXSense(session) + client.aws_access_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc) + client.aws_session_token = "aws-token" + client.signer = ShadowSigner() + house = SimpleNamespace(house_id="house-id", mqtt_region="us-east-1") + + result = asyncio.run(client.get_house(house, "mainpage")) + + assert result == {"state": {"reported": {}}} + call = session.calls[0] + assert call["url"] == "https://us-east-1.x-sense-iot.com/things/house-id/shadow?name=mainpage" + assert call["headers"]["Authorization"] == "signed" + assert call["headers"]["X-Amz-Security-Token"] == "aws-token" + method, url, region, headers, body = client.signer.calls[0] + assert method == "GET" + assert url == "https://us-east-1.x-sense-iot.com/things/house-id/shadow?name=mainpage" + assert region == "us-east-1" + assert headers["Content-Type"] == "application/x-amz-json-1.0" + assert headers["User-Agent"] == "aws-sdk-iOS/2.26.5 iOS/17.3 nl_NL" + assert headers["X-Amz-Security-Token"] == "aws-token" + assert body is None + + +def test_get_thing_signs_shadow_read_with_station_shadow_name(): + session = AsyncFakeSession([AsyncFakeResponse(payload={"state": {"reported": {}}})]) + client = AsyncXSense(session) + client.aws_access_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc) + client.aws_session_token = "aws-token" + client.signer = ShadowSigner() + station = _shadow_station() + station.shadow_name = "shadow-thing-name" + + result = asyncio.run(client.get_thing(station, "infoDev")) + + assert result == {"state": {"reported": {}}} + call = session.calls[0] + assert call["url"] == "https://us-east-1.x-sense-iot.com/things/shadow-thing-name/shadow?name=infoDev" + assert call["headers"]["Authorization"] == "signed" + assert client.signer.calls[0][0] == "GET" + assert client.signer.calls[0][1] == call["url"] + assert client.signer.calls[0][4] is None + + +class RecordingHistoryClient(AsyncXSense): + def __init__(self): + super().__init__() + self.api_calls = [] + + async def api_call(self, code, unauth=False, **kwargs): + self.api_calls.append((code, kwargs)) + return {"items": []} + + +def _history_house_station_device(): + house = SimpleNamespace(house_id="house-id") + station = SimpleNamespace(house=house, entity_id="station-id") + device = SimpleNamespace(entity_id="device-id") + return house, station, device + + +def test_documented_history_helpers_use_app_request_shapes(): + house, station, device = _history_house_station_device() + client = RecordingHistoryClient() + + assert asyncio.run( + client.get_daily_history(house, "20260628", "America/St_Johns", "next") + ) == {"items": []} + assert asyncio.run( + client.get_monthly_history("house-id", "202606", "America/St_Johns") + ) == {"items": []} + assert asyncio.run( + client.get_station_history( + station, + "20260628", + "America/St_Johns", + device=device, + next_token="next", + ) + ) == {"items": []} + assert asyncio.run( + client.get_station_monthly_history( + station, "202606", "America/St_Johns", device="device-id" + ) + ) == {"items": []} + assert asyncio.run( + client.get_co_history_days(station, "America/St_Johns", device=device) + ) == {"items": []} + assert asyncio.run( + client.get_co_history_details( + station, "20260628", "America/St_Johns", device=device + ) + ) == {"items": []} + assert asyncio.run(client.get_temperature_history(station, "0", next_token="n")) == { + "items": [] + } + assert asyncio.run(client.get_dispatch_history("server-id", "next")) == { + "items": [] + } + + assert client.api_calls == [ + ( + "104001", + { + "houseId": "house-id", + "dayTime": "20260628", + "timeZone": "America/St_Johns", + "nextToken": "next", + }, + ), + ( + "104006", + { + "houseId": "house-id", + "hisMonth": "202606", + "timeZone": "America/St_Johns", + }, + ), + ( + "104007", + { + "houseId": "house-id", + "stationId": "station-id", + "dayTime": "20260628", + "timeZone": "America/St_Johns", + "deviceId": "device-id", + "nextToken": "next", + }, + ), + ( + "104008", + { + "houseId": "house-id", + "stationId": "station-id", + "hisMonth": "202606", + "timeZone": "America/St_Johns", + "deviceId": "device-id", + }, + ), + ( + "104009", + { + "stationId": "station-id", + "timeZone": "America/St_Johns", + "deviceId": "device-id", + }, + ), + ( + "104010", + { + "houseId": "house-id", + "stationId": "station-id", + "dayTime": "20260628", + "timeZone": "America/St_Johns", + "deviceId": "device-id", + }, + ), + ( + "104020", + { + "houseId": "house-id", + "stationId": "station-id", + "lastTime": "0", + "nextToken": "n", + }, + ), + ("505001", {"serverId": "server-id", "nextToken": "next"}), + ] + + +def test_ipc_language_uses_simple_app_language_code(): + from xsense.async_xsense import _ipc_language + + assert _ipc_language("de-DE") == "de" + assert _ipc_language("pt_BR") == "pt" + assert _ipc_language("") == "en" + assert _ipc_language(None) == "en" + + +def test_ipc_node_type_uses_mqtt_region_prefix(): + from xsense.async_xsense import _ipc_node_type + + assert _ipc_node_type("eu-central-1") == "EU" + assert _ipc_node_type("us-east-1") == "US" + assert _ipc_node_type("cn-north-1") == "CN" + assert _ipc_node_type("Canada") == "US" + assert _ipc_node_type(None) == "US" + + +def test_addx_body_uses_app_info(): + client = AsyncXSense() + + body = client._addx_body( + {"countryNo": "1", "language": "en"}, + {"serialNumber": "CAM123"}, + ) + + assert body["serialNumber"] == "CAM123" + assert body["countryNo"] == "1" + assert body["language"] == "en" + assert body["app"] == { + "appName": "VicoHome", + "appType": "Android", + "bundle": "com.ai.vicoo", + "channelId": 1000, + "countlyId": "b940908f19b8e858", + "tenantId": "guard", + "version": 200700500, + "versionName": "2.7.5", + } + + +def test_ipc_call_uses_current_app_metadata(): + session = AsyncFakeSession( + [AsyncFakeResponse(payload={"reCode": "200", "reData": {"token": "ipc"}})] + ) + client = AsyncXSense(session) + client.clientsecret = b"secret" + client.access_token = "access-token" + client.access_token_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc) + + result = asyncio.run(client.ipc_call("C10101", nodeType="US", language="en")) + + assert result == {"token": "ipc"} + call = session.calls[0] + assert call["url"] == "https://ipc.x-sense-iot.com/ipc" + assert call["headers"] == {"Authorization": "access-token"} + assert call["json"]["nodeType"] == "US" + assert call["json"]["language"] == "en" + assert call["json"]["bizCode"] == "C10101" + assert call["json"]["appCode"] == "1360" + assert call["json"]["appVersion"] == "v1.36.0_20260130" + assert call["json"]["clientType"] == "2" + + +class IpcRegistrationClient(AsyncXSense): + def __init__(self, *, language=None): + super().__init__(language=language) + self.ipc_calls = [] + + async def ipc_call(self, code: str, **kwargs): + self.ipc_calls.append((code, kwargs)) + return {"token": "addx-token", "nodeType": kwargs["nodeType"]} + + +def test_register_ipc_uses_house_region_language_and_username(): + client = IpcRegistrationClient(language="fr-CA") + client.username = "user@example.com" + client.houses = { + "house-id": SimpleNamespace(house_id="house-id", mqtt_region="eu-central-1") + } + + result = asyncio.run(client.register_ipc()) + + assert result == {"token": "addx-token", "nodeType": "EU"} + assert client.ipc_calls == [ + ( + "C10101", + { + "userName": "user@example.com", + "nodeType": "EU", + "language": "fr", + }, + ) + ] + + +def test_register_ipc_requires_loaded_house_data(): + client = IpcRegistrationClient() + + with pytest.raises(APIFailure, match="without an X-Sense house"): + asyncio.run(client.register_ipc()) + + +def test_ai_service_history_uses_app_service_code_and_server_id(): + session = AsyncFakeSession( + [AsyncFakeResponse(payload={"reCode": "200", "reData": {"items": []}})] + ) + client = AsyncXSense(session) + client.clientsecret = b"secret" + client.access_token = "access-token" + client.access_token_expiry = datetime(2099, 1, 1, tzinfo=timezone.utc) + + result = asyncio.run(client.get_ai_service_history("server-id", "next-token")) + + assert result == {"items": []} + call = session.calls[0] + assert call["url"] == "https://api.x-sense-iot.com/app" + assert call["json"]["bizCode"] == "701008" + assert call["json"]["serverId"] == "server-id" + assert call["json"]["nextToken"] == "next-token" + + +def test_camera_event_history_uses_addx_library_record_path(): + session = AsyncFakeSession( + [ + AsyncFakeResponse( + payload={ + "result": 0, + "data": {"records": [{"serialNumber": "CAM123"}]}, + } + ) + ] + ) + client = AsyncXSense(session) + client._addx_session = { + "nodeType": "US", + "token": "addx-token", + "countryNo": "1", + "language": "en", + } + + result = asyncio.run( + client.get_camera_event_history( + ["CAM123"], + 1710000000000, + 1710003600000, + start=20, + limit=40, + ) + ) + + assert result == {"records": [{"serialNumber": "CAM123"}]} + call = session.calls[0] + assert call["url"] == "https://api-us.vicohome.io/library/newselectlibrary" + assert call["headers"] == { + "Authorization": "addx-token", + "Content-Type": "application/json", + } + assert call["json"]["serialNumber"] == ["CAM123"] + assert call["json"]["startTimestamp"] == 1710000000000 + assert call["json"]["endTimestamp"] == 1710003600000 + assert call["json"]["from"] == 20 + assert call["json"]["to"] == 40 + assert call["json"]["tags"] == [] + assert call["json"]["marked"] == 0 + + +class RetryingAddxClient(AsyncXSense): + def __init__(self, session): + super().__init__(session) + self._addx_session = { + "nodeType": "US", + "token": "expired-addx-token", + "countryNo": "1", + "language": "en", + } + self.registers = 0 + + async def register_ipc(self): + self.registers += 1 + return { + "nodeType": "US", + "token": f"fresh-addx-token-{self.registers}", + "countryNo": "1", + "language": "en", + } + + +def test_addx_call_re_registers_ipc_and_retries_once_after_auth_failure(): + session = AsyncFakeSession( + [ + AsyncFakeResponse(status=401, payload={"msg": "expired"}), + AsyncFakeResponse(payload={"result": 0, "data": {"ok": True}}), + ] + ) + client = RetryingAddxClient(session) + + result = asyncio.run(client.addx_call("/device/list", serialNumber="CAM123")) + + assert result == {"ok": True} + assert client.registers == 1 + assert len(session.calls) == 2 + assert session.calls[0]["headers"]["Authorization"] == "expired-addx-token" + assert session.calls[1]["headers"]["Authorization"] == "fresh-addx-token-1" + + +def test_addx_call_does_not_retry_twice_after_auth_failure(): + session = AsyncFakeSession( + [ + AsyncFakeResponse(status=401, payload={"msg": "expired"}), + AsyncFakeResponse(status=403, payload={"message": "still expired"}), + ] + ) + client = RetryingAddxClient(session) + + with pytest.raises(APIFailure, match="still expired"): + asyncio.run(client.addx_call("/device/list", serialNumber="CAM123")) + + assert client.registers == 1 + assert len(session.calls) == 2 + + +class FakeCamera: + def __init__(self, data=None): + self.sn = "CAM123" + self.data = dict(data or {}) + + def set_data(self, values): + self.data.update(values) + + +class StubAddxClient(AsyncXSense): + def __init__(self, responses): + super().__init__(session=AsyncFakeSession()) + self.responses = list(responses) + self.addx_calls = [] + + async def addx_call(self, endpoint: str, **kwargs): + self.addx_calls.append((endpoint, kwargs)) + if self.responses: + return self.responses.pop(0) + return {} + + +def test_camera_live_resolution_prefers_saved_supported_resolution(): + from xsense.async_xsense import camera_live_resolution + + camera = FakeCamera( + { + "supportedRecordingResolutions": ["720P", "1080P"], + "liveResolution": "VIDEO_SIZE_1080P", + } + ) + + assert camera_live_resolution(camera) == "1920x1080" + + +def test_camera_live_resolution_falls_back_to_first_supported_resolution(): + from xsense.async_xsense import camera_live_resolution + + camera = FakeCamera( + { + "deviceSupportResolution": ["720P", "1080P"], + "liveResolution": "4K", + } + ) + + assert camera_live_resolution(camera) == "1280x720" + + +def test_camera_stream_protocol_helpers_detect_native_and_webrtc_modes(): + from xsense.async_xsense import ( + camera_online, + camera_stream_protocol, + is_native_stream_camera, + is_webrtc_camera, + stream_source_protocol, + ) + + rtsp_camera = FakeCamera({"streamProtocol": "RTSP"}) + rtsp_camera.online = True + webrtc_camera = FakeCamera({"streamProtocol": "webrtc"}) + unknown_camera = FakeCamera({}) + fallback_online_camera = FakeCamera({"online": 1}) + + assert camera_online(rtsp_camera) is True + assert camera_online(fallback_online_camera) is True + assert camera_stream_protocol(rtsp_camera) == "rtsp" + assert stream_source_protocol("RTSP://camera/live") == "rtsp" + assert stream_source_protocol("not-a-url") is None + assert is_native_stream_camera(rtsp_camera) is True + assert is_webrtc_camera(rtsp_camera) is False + assert is_native_stream_camera(webrtc_camera) is False + assert is_webrtc_camera(webrtc_camera) is True + assert is_native_stream_camera(unknown_camera) is False + assert is_webrtc_camera(unknown_camera) is True + + +def test_get_camera_webrtc_ticket_reuses_valid_cache(): + expires = int(datetime.now().timestamp() * 1000) + 60000 + ticket = {"ticket": "cached", "expirationTime": expires} + camera = FakeCamera({"cameraWebrtcTicket": ticket}) + client = StubAddxClient([{"ticket": "fresh"}]) + + result = asyncio.run(client.get_camera_webrtc_ticket(camera)) + + assert result == ticket + assert client.addx_calls == [] + + +def test_get_camera_webrtc_ticket_fetches_missing_expiration(): + camera = FakeCamera({"cameraWebrtcTicket": {"ticket": "stale"}}) + client = StubAddxClient([{"ticket": "fresh", "expirationTime": "9999999999999"}]) + + result = asyncio.run(client.get_camera_webrtc_ticket(camera)) + + assert result == {"ticket": "fresh", "expirationTime": "9999999999999"} + assert camera.data["cameraWebrtcTicket"] == result + assert client.addx_calls == [ + ( + "/device/getWebrtcTicket", + {"serialNumber": "CAM123", "verifyDormancyStatus": True}, + ) + ] + + +def test_start_camera_live_uses_direct_stream_source_endpoint(): + camera = FakeCamera({"supportedRecordingResolutions": ["1080P"]}) + client = StubAddxClient( + [ + { + "liveUrl": "rtsp://camera/live", + "audioUrl": "rtsp://camera/audio", + "liveId": "live-id", + } + ] + ) + + result = asyncio.run(client.start_camera_live(camera)) + + assert result == "rtsp://camera/live" + assert camera.data["cameraLiveUrl"] == "rtsp://camera/live" + assert camera.data["cameraAudioUrl"] == "rtsp://camera/audio" + assert camera.data["cameraLiveId"] == "live-id" + assert camera.data["cameraLiveProtocol"] == "rtsp" + assert client.addx_calls == [ + ( + "/device/newstartlive", + {"serialNumber": "CAM123", "liveResolution": "1920x1080"}, + ) + ] + + +def test_start_camera_live_reuses_recent_url(): + camera = FakeCamera( + { + "cameraLiveStartedAt": datetime.now(), + "cameraLiveUrl": "rtmp://camera/live", + } + ) + client = StubAddxClient([{"liveUrl": "rtsp://camera/new"}]) + + result = asyncio.run(client.start_camera_live(camera)) + + assert result == "rtmp://camera/live" + assert client.addx_calls == [] + + +def test_stop_camera_live_clears_live_and_webrtc_ticket_state(): + camera = FakeCamera( + { + "cameraAudioUrl": "rtsp://camera/audio", + "cameraLiveId": "live-id", + "cameraLiveStartedAt": datetime.now(), + "cameraLiveUrl": "rtsp://camera/live", + "cameraLiveProtocol": "rtsp", + "cameraWebrtcTicket": {"ticket": "ticket"}, + } + ) + client = StubAddxClient([{}]) + + asyncio.run(client.stop_camera_live(camera)) + + assert client.addx_calls == [ + ("/device/stoplive", {"serialNumber": "CAM123"}) + ] + assert camera.data["cameraAudioUrl"] is None + assert camera.data["cameraLiveId"] is None + assert camera.data["cameraLiveStartedAt"] is None + assert camera.data["cameraLiveUrl"] is None + assert camera.data["cameraLiveProtocol"] is None + assert camera.data["cameraWebrtcTicket"] is None + + +def test_camera_keepalive_and_wake_use_app_endpoints(): + camera = FakeCamera() + client = StubAddxClient([{}, {}]) + + asyncio.run(client.keep_camera_live_alive(camera)) + asyncio.run(client.wake_camera(camera)) + + assert client.addx_calls == [ + ("/device/keepalive", {"serialNumber": "CAM123", "seconds": 30}), + ("/device/wakeupDevice", {"serialNumber": "CAM123"}), + ] diff --git a/tests/test_aws_signer.py b/tests/test_aws_signer.py new file mode 100644 index 0000000..97a39a1 --- /dev/null +++ b/tests/test_aws_signer.py @@ -0,0 +1,92 @@ +import datetime as real_datetime +from unittest.mock import patch + +from xsense.aws_signer import AWSSigner + + +class FixedDateTime(real_datetime.datetime): + @classmethod + def now(cls, tz=None): + return cls(2026, 6, 24, 12, 34, 56, tzinfo=tz) + + +def test_sign_headers_uses_signed_shadow_headers_and_compact_body(): + signer = AWSSigner( + "AKIDEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "session/token+value", + ) + + with patch("xsense.aws_signer.datetime.datetime", FixedDateTime): + headers = signer.sign_headers( + "POST", + "https://example.iot.us-east-1.amazonaws.com/things/thing-1/shadow/name/info/update?b=2&a=1", + "us-east-1", + { + "x-amz-security-token": signer.token, + "content-type": "application/json", + }, + '{"state":{"desired":{"alarmVol":7}}}', + ) + + assert headers == { + "host": "example.iot.us-east-1.amazonaws.com", + "X-Amz-Date": "20260624T123456Z", + "Authorization": ( + "AWS4-HMAC-SHA256 " + "Credential=AKIDEXAMPLE/20260624/us-east-1/iotdata/aws4_request, " + "SignedHeaders=content-type;host;x-amz-date;x-amz-security-token, " + "Signature=a8e17c2bcba483356e251a4c478a5acfa2a1fbd86c3aa3960b55e743ac2a43ed" + ), + } + + +def test_sign_headers_accepts_dict_content_for_legacy_callers(): + signer = AWSSigner( + "AKIDEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "session/token+value", + ) + + with patch("xsense.aws_signer.datetime.datetime", FixedDateTime): + headers = signer.sign_headers( + "POST", + "https://example.iot.us-east-1.amazonaws.com/things/thing-1/shadow/name/info/update", + "us-east-1", + { + "x-amz-security-token": signer.token, + "content-type": "application/json", + }, + {"b": 2, "a": 1}, + ) + + assert headers["host"] == "example.iot.us-east-1.amazonaws.com" + assert headers["X-Amz-Date"] == "20260624T123456Z" + assert "Credential=AKIDEXAMPLE/20260624/us-east-1/iotdata/aws4_request" in ( + headers["Authorization"] + ) + assert "Signature=" in headers["Authorization"] + + +def test_presign_url_includes_session_token_and_signature(): + signer = AWSSigner( + "AKIDEXAMPLE", + "wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY", + "session/token+value", + ) + + with patch("xsense.aws_signer.datetime.datetime", FixedDateTime): + url = signer.presign_url( + "wss://example.iot.us-east-1.amazonaws.com/mqtt?b=2&a=1", + "us-east-1", + ) + + assert url == ( + "wss://example.iot.us-east-1.amazonaws.com/mqtt?" + "X-Amz-Algorithm=AWS4-HMAC-SHA256&" + "X-Amz-Credential=AKIDEXAMPLE%2F20260624%2Fus-east-1%2Fiotdata%2Faws4_request&" + "X-Amz-Date=20260624T123456Z&" + "X-Amz-SignedHeaders=host&" + "X-Amz-Security-Token=session/token%2Bvalue&" + "X-Amz-Signature=9971f6edd37544de95ff3aa24c852b703ee22714f38a654f88fd516eab053037" + ) diff --git a/tests/test_camera_discovery.py b/tests/test_camera_discovery.py new file mode 100644 index 0000000..aefbf4b --- /dev/null +++ b/tests/test_camera_discovery.py @@ -0,0 +1,245 @@ +import asyncio + +from xsense.async_xsense import AsyncXSense, _camera_data +from xsense.entity_map import EntityType +from xsense.house import House +from xsense.station import Station + + +class FakeSigner: + def presign_url(self, *args): + return "wss://mqtt.example/mqtt?sig=abc" + + +class CameraClient(AsyncXSense): + def __init__(self, responses): + super().__init__(session=None) + self.responses = list(responses) + self.calls = [] + + async def addx_call(self, endpoint: str, **kwargs): + self.calls.append((endpoint, kwargs)) + if self.responses: + response = self.responses.pop(0) + if isinstance(response, Exception): + raise response + return response + return None + + +class ThumbnailResponse: + def __init__(self, status=200, content=b"jpeg-bytes"): + self.status = status + self.content = content + + async def __aenter__(self): + return self + + async def __aexit__(self, exc_type, exc, traceback): + return None + + async def read(self): + return self.content + + +class ThumbnailSession: + closed = False + + def __init__(self, responses): + self.responses = list(responses) + self.calls = [] + + def get(self, url): + self.calls.append(url) + return self.responses.pop(0) + + +def _house(house_id="house-id"): + house = House(FakeSigner(), house_id, "Home", "US", "us-east-1", "mqtt.example") + house.set_rooms({"houseRooms": {}, "roomSort": []}) + house.set_stations({"stationSort": [], "stations": []}) + return house + + +def test_camera_data_normalizes_addx_support_and_status_fields(): + data = _camera_data( + { + "displayModelNo": "SSC0A", + "online": 1, + "awake": 0, + "deviceStatus": "2", + "statusCode": "3", + "thumbImgUrl": "https://example/thumb.jpg", + "sdCard": {"formatStatus": 0, "total": "128", "used": "64"}, + "deviceModel": { + "modelName": "SSC0A", + "streamProtocol": "webrtc", + "canStandby": 1, + "whiteLight": 0, + }, + "deviceSupport": { + "supportWebrtc": 1, + "supportLiveAudioToggle": 1, + "supportRecordingAudioToggle": 0, + "deviceDormancySupport": 1, + "deviceSupportResolution": ["1080P"], + }, + } + ) + + assert data["cameraModel"] == "SSC0A" + assert data["online"] == 1 + assert data["awake"] == 0 + assert data["deviceStatus"] == "2" + assert data["cameraStatusCode"] == "3" + assert data["thumbImgUrl"] == "https://example/thumb.jpg" + assert data["streamProtocol"] == "webrtc" + assert data["supportWebrtc"] is True + assert data["supportLiveAudio"] is True + assert data["supportRecordingAudio"] is False + assert data["supportBattery"] is True + assert data["supportLight"] is False + assert data["supportSdCard"] is True + assert data["supportSleep"] is True + assert data["supportedRecordingResolutions"] == ["1080P"] + + +def test_update_camera_data_creates_camera_from_addx_device_list(): + client = CameraClient( + [ + { + "list": [ + { + "serialNumber": "CAM123", + "deviceName": "Driveway Camera", + "houseId": "house-id", + "displayModelNo": "SSC0A", + "online": 1, + "deviceSupport": {"supportWebrtc": 1}, + } + ] + }, + {}, + {}, + None, + ] + ) + house = _house() + client.houses = {house.house_id: house} + + asyncio.run(client.update_camera_data()) + + camera = house.stations["CAM123"] + assert camera.name == "Driveway Camera" + assert camera.sn == "CAM123" + assert camera.type == "SSC0A" + assert camera.entity_type == EntityType.CAMERA + assert camera.online is True + assert camera.data["supportWebrtc"] is True + assert client.calls[0] == ("/device/listuserdevices", {}) + + +def test_update_camera_data_updates_existing_camera_metadata(): + house = _house() + station = Station( + house, + stationId="camera-id", + stationSn="CAM123", + stationName="Old Name", + category="SSC0B", + deviceType="SSC0B", + devices=[], + ) + station.entity_type = EntityType.CAMERA + station.set_devices({"devices": []}) + house.stations[station.entity_id] = station + + client = CameraClient( + [ + { + "list": [ + { + "serialNumber": "cam-123", + "deviceName": "Front Camera", + "displayModelNo": "SSC0A", + "online": 0, + } + ] + }, + {}, + {}, + None, + ] + ) + client.houses = {house.house_id: house} + + asyncio.run(client.update_camera_data()) + + assert station.name == "Front Camera" + assert station.type == "SSC0A" + assert station.online is False + + +def test_update_camera_data_does_not_place_unknown_camera_in_multi_house_account(): + client = CameraClient( + [ + { + "list": [ + { + "serialNumber": "CAM123", + "deviceName": "Floating Camera", + "displayModelNo": "SSC0A", + } + ] + } + ] + ) + first = _house("first") + second = _house("second") + client.houses = {first.house_id: first, second.house_id: second} + + asyncio.run(client.update_camera_data()) + + assert first.stations == {} + assert second.stations == {} + + +def test_get_camera_thumbnail_fetches_thumbnail_url_bytes(): + session = ThumbnailSession([ThumbnailResponse(content=b"thumb")]) + client = AsyncXSense(session) + camera = Station( + _house(), + stationId="camera-id", + stationSn="CAM123", + stationName="Camera", + category="SSC0A", + deviceType="SSC0A", + devices=[], + ) + camera.set_data({"thumbImgUrl": "https://example/thumb.jpg"}) + + result = asyncio.run(client.get_camera_thumbnail(camera)) + + assert result == b"thumb" + assert session.calls == ["https://example/thumb.jpg"] + + +def test_get_camera_thumbnail_returns_none_without_url_or_on_failure(): + session = ThumbnailSession([ThumbnailResponse(status=404, content=b"missing")]) + client = AsyncXSense(session) + camera = Station( + _house(), + stationId="camera-id", + stationSn="CAM123", + stationName="Camera", + category="SSC0A", + deviceType="SSC0A", + devices=[], + ) + + assert asyncio.run(client.get_camera_thumbnail(camera)) is None + + camera.set_data({"thumbImgUrl": "https://example/missing.jpg"}) + + assert asyncio.run(client.get_camera_thumbnail(camera)) is None + assert session.calls == ["https://example/missing.jpg"] diff --git a/tests/test_camera_settings.py b/tests/test_camera_settings.py new file mode 100644 index 0000000..796fbc1 --- /dev/null +++ b/tests/test_camera_settings.py @@ -0,0 +1,294 @@ +import asyncio + +from xsense.async_xsense import ( + AsyncXSense, + _camera_ai_assistant_data, + _camera_ai_notification_data, + _camera_ai_notification_payload, + _camera_audio_data, + _camera_config_data, + _camera_settings_options_data, +) + + +class FakeCamera: + def __init__(self, data=None): + self.sn = "CAM123" + self.type = "SSC0A" + self.data = dict(data or {}) + + def set_data(self, values): + self.data.update(values) + + +class StubAddxClient(AsyncXSense): + def __init__(self): + super().__init__(session=None) + self.calls = [] + + async def addx_call(self, endpoint: str, **kwargs): + self.calls.append((endpoint, kwargs)) + return {} + + +def test_camera_config_data_normalizes_boolean_and_default_fields(): + data = _camera_config_data( + { + "needMotion": 1, + "needVideo": 0, + "needNightVision": None, + "videoSeconds": 0, + "voiceVolumeSwitch": 1, + "cooldown": { + "deviceSupport": 1, + "userEnable": 0, + "value": "30", + "notCloseValues": [10, 30, 60], + }, + } + ) + + assert data["needMotion"] is True + assert data["needVideo"] is False + assert data["needNightVision"] is None + assert data["videoSeconds"] == -1 + assert data["voiceVolumeSwitch"] is True + assert data["cooldownSupported"] is True + assert data["cooldownEnabled"] is False + assert data["cooldownValue"] == "30" + assert data["cooldownOptions"] == [10, 30, 60] + + +def test_camera_form_and_audio_option_parsers(): + assert _camera_settings_options_data( + { + "deviceFormOptions": { + "videoSeconds": [ + {"value": 10, "enabled": True}, + {"value": 20, "enabled": False}, + {"value": 30}, + ], + "cooldown_in_s": [{"value": 15}, {"value": 60}], + } + } + ) == {"videoSecondsValues": [10, 30], "cooldownOptions": [15, 60]} + + assert _camera_audio_data( + { + "deviceAudio": { + "doorBellRingKey": "ding", + "supportDoorBellRingKey": [{"id": "ding"}, {"id": "dong"}, {}], + "liveAudioToggleOn": 1, + "liveSpeakerVolume": 55, + "recordingAudioToggleOn": 0, + } + } + ) == { + "doorBellRingKey": "ding", + "doorBellRingKeyOptions": ["ding", "dong"], + "liveAudioToggleOn": True, + "liveSpeakerVolume": 55, + "recordingAudioToggleOn": False, + } + + +def test_update_camera_config_uses_app_user_config_payload(): + camera = FakeCamera( + { + "motionSensitivity": 7, + "videoSeconds": 0, + "supportRocker": False, + "alarmSeconds": 0, + "nightThresholdLevel": 4, + } + ) + client = StubAddxClient() + + asyncio.run( + client.update_camera_config( + camera, + needMotion=True, + needVideo=True, + needAlarm=True, + needNightVision=True, + deviceCallToggleOn=1, + ignoredField=True, + ) + ) + + assert client.calls == [ + ( + "/device/updateuserconfig", + { + "serialNumber": "CAM123", + "needMotion": 1, + "needVideo": 1, + "needAlarm": 1, + "needNightVision": 1, + "deviceCallToggleOn": True, + "motionSensitivity": 7, + "videoSeconds": -1, + "alarmSeconds": 5, + "nightThresholdLevel": 4, + }, + ) + ] + assert camera.data["needMotion"] is True + assert camera.data["ignoredField"] is True + + +def test_camera_audio_and_direct_control_helpers_use_app_endpoints(): + camera = FakeCamera( + { + "doorBellRingKey": "ding", + "liveAudioToggleOn": True, + "liveSpeakerVolume": 30, + "recordingAudioToggleOn": False, + } + ) + client = StubAddxClient() + + asyncio.run(client.update_camera_audio(camera, liveSpeakerVolume=45)) + asyncio.run(client.update_camera_recording_resolution(camera, "1920x1080")) + asyncio.run(client.update_camera_default_codec(camera, "H265")) + asyncio.run(client.update_camera_cooldown(camera, user_enable=True, value=60)) + + assert client.calls == [ + ( + "/device/config/updatedeviceaudio", + { + "serialNumber": "CAM123", + "deviceAudio": { + "doorBellRingKey": "ding", + "liveAudioToggleOn": True, + "liveSpeakerVolume": 45, + "recordingAudioToggleOn": False, + }, + }, + ), + ( + "/device/updaterecresolution", + {"serialNumber": "CAM123", "recResolution": "1920x1080"}, + ), + ( + "/device/config/updatedefaultcodec", + {"serialNumber": "CAM123", "defaultCodec": "H265"}, + ), + ( + "/device/updateCooldown", + {"serialNumber": "CAM123", "cooldown": {"userEnable": True, "value": 60}}, + ), + ] + assert camera.data["liveSpeakerVolume"] == 45 + assert camera.data["recResolution"] == "1920x1080" + assert camera.data["defaultCodec"] == "H265" + assert camera.data["cooldownEnabled"] is True + assert camera.data["cooldownValue"] == 60 + + +def test_camera_ai_notification_parsing_and_payload_shape(): + parsed = _camera_ai_notification_data( + { + "list": [ + {"name": "person", "choice": True}, + { + "name": "vehicle", + "subEvent": [ + {"name": "vehicle_enter", "choice": True}, + {"name": "vehicle_out", "choice": False}, + ], + }, + {"name": "package", "choice": False}, + ] + } + ) + + assert parsed["aiNotificationPerson"] is True + assert parsed["aiNotificationVehicleEnter"] is True + assert parsed["aiNotificationVehicleOut"] is False + assert parsed["aiNotificationPackageExist"] is False + assert parsed["aiNotificationSupportedTypes"] == [ + "package_drop_off", + "package_exist", + "package_pick_up", + "person", + "vehicle_enter", + "vehicle_out", + ] + assert _camera_ai_notification_payload( + {"person", "vehicle_enter", "package_pick_up"} + ) == { + "vehicle": ["vehicle_enter"], + "package": ["package_pick_up"], + "person": [], + } + + +def test_camera_ai_assistant_data_and_update_payload(): + parsed = _camera_ai_assistant_data( + { + "data": [ + { + "serialNumber": "OTHER", + "list": [{"eventObject": "person", "checked": True}], + }, + { + "serialNumber": "CAM123", + "list": [ + {"eventObject": "person", "checked": True}, + {"eventObject": "package", "checked": False}, + ], + }, + ] + }, + "CAM123", + ) + + assert parsed == { + "aiAssistantPerson": True, + "aiAssistantPackage": False, + "aiAssistantSupportedTypes": ["person", "package"], + } + + camera = FakeCamera(parsed) + client = StubAddxClient() + asyncio.run(client.update_camera_ai_assistant(camera, "package", True)) + + assert client.calls == [ + ( + "/aiAssist/updateEventObjectSwitch", + { + "serialNumber": "CAM123", + "list": [{"checked": True, "eventObject": "package"}], + }, + ) + ] + assert camera.data["aiAssistantPackage"] is True + + +def test_update_camera_ai_notification_writes_full_payload(): + camera = FakeCamera( + { + "aiNotificationPerson": True, + "aiNotificationVehicleEnter": False, + "aiNotificationVehicleOut": True, + } + ) + client = StubAddxClient() + + asyncio.run(client.update_camera_ai_notification(camera, "vehicle_enter", True)) + + assert client.calls == [ + ( + "/device/updateMessageNotification/v1", + { + "serialNumber": "CAM123", + "eventObjectType": { + "vehicle": ["vehicle_enter", "vehicle_out"], + "package": [], + "person": [], + }, + }, + ) + ] + assert camera.data["aiNotificationVehicleEnter"] is True diff --git a/tests/test_device_settings_actions.py b/tests/test_device_settings_actions.py new file mode 100644 index 0000000..3594b9f --- /dev/null +++ b/tests/test_device_settings_actions.py @@ -0,0 +1,490 @@ +import asyncio +from types import SimpleNamespace + +from xsense.async_xsense import ( + AsyncXSense, + comfort_pair, + light_group_list, + light_schedule_list, + non_empty_strings, + schedule_time, + schedule_week_days, + typed_option, +) +from xsense.device import Device +from xsense.entity_map import EntityType +from xsense.exceptions import XSenseError +from xsense.house import House +from xsense.station import Station + + +class RecordingClient(AsyncXSense): + def __init__(self): + super().__init__(session=None) + self.userid = "user-id" + self.do_thing_calls = [] + self.api_calls = [] + + async def do_thing(self, station, page, data): + self.do_thing_calls.append((station, page, data)) + return {"ok": True} + + async def api_call(self, code, unauth=False, **kwargs): + self.api_calls.append((code, kwargs)) + if code == "405105": + return {"schedList": [{"schedId": "sched-1"}]} + if code == "405001": + return {"reData": {"groupList": [{"groupId": "group-1"}]}} + return {"ok": True} + + +class FakeEntity: + def __init__(self, *, station=None, entity_type=None, entity_id="entity-id"): + self.station = station + self.entity_type = entity_type + self.entity_id = entity_id + self.sn = "DEV123" + self.type = "STH0B" + self.data = {} + + @property + def shadow_name(self): + return f"{self.type}{self.sn}" + + def set_data(self, values): + self.data.update(values) + + +def _station(station_type="SBS50"): + station = SimpleNamespace() + station.entity_id = "station-id" + station.sn = "BASE123" + station.type = station_type + station.data = {} + station.station = station + station.entity_type = EntityType.BASESTATION + station.shadow_name = f"{station_type}{station.sn}" + return station + + +def test_station_shadow_setting_uses_app_payload_shape(): + station = _station("SBS10") + station.data = {"voiceVol": 4, "alarmTone": 2} + client = RecordingClient() + + asyncio.run(client.update_shadow_setting(station, "alarmVol", 7)) + + target, topic, payload = client.do_thing_calls[0] + desired = payload["state"]["desired"] + assert target is station + assert topic == "info_BASE123" + assert desired == { + "shadow": "infoBase", + "alarmVol": "7", + "stationSN": "BASE123", + "voiceVol": "4", + "alarmTone": "2", + } + + +def test_temperature_shadow_setting_includes_station_and_change_unit(): + station = _station("SBS50") + device = FakeEntity(station=station, entity_type=EntityType.TEMPERATURE) + client = RecordingClient() + + asyncio.run(client.update_shadow_setting(device, "tempUnit", "C")) + + target, topic, payload = client.do_thing_calls[0] + desired = payload["state"]["desired"] + assert target is station + assert topic == "2nd_cfg_DEV123" + assert desired == { + "shadow": "infoDev", + "tempUnit": "C", + "deviceSN": "DEV123", + "stationSN": "BASE123", + "changeUnit": "1", + } + + +def test_light_power_and_scene_use_app_shadow_payloads(): + station = _station("SBS50") + light = FakeEntity(station=station, entity_type=EntityType.LIGHT) + light.sn = "LIGHT123" + client = RecordingClient() + + asyncio.run(client.update_light_power(light, True)) + asyncio.run(client.update_light_scene(light, "2")) + + _, power_topic, power_payload = client.do_thing_calls[0] + power = power_payload["state"]["desired"] + assert power_topic == "2nd_lamppower" + assert power["isOn"] == "1" + assert power["dev"] == "LIGHT123" + assert power["shadow"] == "lampPower" + assert power["stationSN"] == "BASE123" + assert power["userId"] == "user-id" + + _, scene_topic, scene_payload = client.do_thing_calls[1] + scene = scene_payload["state"]["desired"] + assert scene_topic == "2nd_cfg_LIGHT123" + assert scene == { + "shadow": "infoDev", + "deviceSN": "LIGHT123", + "lightScene": "2", + "onEvent": "1", + "pirEnable": "0", + "awaitEnable": "1", + } + + +def test_group_light_power_uses_group_payload_shape(): + station = _station("SBS50") + group = FakeEntity(station=station, entity_type=EntityType.LIGHT) + group.type = "group-L" + group.data = {"groupId": 12, "devs": ["LIGHT1", "LIGHT2"]} + client = RecordingClient() + + asyncio.run(client.update_light_power(group, False)) + + _, topic, payload = client.do_thing_calls[0] + desired = payload["state"]["desired"] + assert topic == "2nd_grouppower" + assert desired["isOn"] == "0" + assert desired["groupId"] == 12 + assert desired["devs"] == ["LIGHT1", "LIGHT2"] + assert desired["shadow"] == "groupLampPower" + assert desired["stationSN"] == "BASE123" + assert desired["timeOut"] == "180" + + +def test_light_schedule_and_group_apis_use_app_biz_codes(): + station = _station("SBS50") + light = FakeEntity(station=station, entity_type=EntityType.LIGHT) + light.entity_id = "light-id" + client = RecordingClient() + + schedules = asyncio.run(client.query_light_schedules(light)) + asyncio.run( + client.create_light_schedule( + light, + name="Evening", + start_time="18:00", + end_time="23:00", + week_days=["1", "2"], + enabled=True, + time_zone="America/St_Johns", + ) + ) + groups = asyncio.run(client.query_light_groups(light)) + asyncio.run(client.bind_light_group(light, name="Hall", device_ids=["light-id"])) + + assert schedules == [{"schedId": "sched-1"}] + assert groups == [{"groupId": "group-1"}] + assert client.api_calls[0] == ( + "405105", + {"stationId": "station-id", "deviceId": "light-id"}, + ) + assert client.api_calls[1] == ( + "405101", + { + "stationId": "station-id", + "schedName": "Evening", + "deviceIds": ["light-id"], + "timeZone": "America/St_Johns", + "startTime": "1800", + "endTime": "2300", + "isEnable": "1", + "weekDays": ["1", "2"], + "newTimeZoneMode": "1", + }, + ) + assert client.api_calls[2] == ("405001", {"stationId": "station-id"}) + assert client.api_calls[3] == ( + "405005", + {"stationId": "station-id", "groupName": "Hall", "deviceIds": ["light-id"]}, + ) + + +def test_light_schedule_and_group_normalizers(): + assert schedule_time("7:05") == "0705" + assert schedule_time("2300") == "2300" + assert schedule_week_days([" 1 ", 7]) == ["1", "7"] + assert light_schedule_list({"schedule": [1]}) == [1] + assert light_schedule_list([2]) == [2] + assert light_group_list({"reData": {"groupList": [3]}}) == [3] + assert light_group_list({"groups": [4]}) == [4] + assert non_empty_strings([" one ", "", "two"], "ids") == ["one", "two"] + assert typed_option("2") == 2 + assert typed_option("eco") == "eco" + assert comfort_pair(["20", 26], [1.0, 2.0]) == [20.0, 26.0] + assert comfort_pair(["bad"], [1.0, 2.0]) == [1.0, 2.0] + + +def test_light_schedule_and_group_normalizers_reject_invalid_values(): + for value in ("24:00", "2360", "bad"): + try: + schedule_time(value) + except ValueError: + pass + else: + raise AssertionError(f"{value!r} should fail schedule time validation") + + try: + schedule_week_days(["0"]) + except ValueError: + pass + else: + raise AssertionError("weekday 0 should fail validation") + + try: + non_empty_strings(["", " "], "ids") + except ValueError: + pass + else: + raise AssertionError("empty id list should fail validation") + + +def test_co_pre_alarm_and_array_settings_use_app_payloads(): + station = _station("SBS50") + device = FakeEntity(station=station, entity_type=EntityType.CO) + client = RecordingClient() + + asyncio.run(client.update_co_pre_alarm(device, enabled=True, period=30)) + asyncio.run( + client.update_shadow_array_setting( + device, "tComfort", [18.0, 24.0], comfort_type="temp" + ) + ) + + _, warn_topic, warn_payload = client.do_thing_calls[0] + warn = warn_payload["state"]["desired"] + assert warn_topic == "2nd_warnperiod" + assert warn["shadow"] == "appWarnPerion" + assert warn["deviceSN"] == "DEV123" + assert warn["stationSN"] == "BASE123" + assert warn["userId"] == "user-id" + assert warn["warnIsOpen"] == "1" + assert warn["warnPeriod"] == "30" + + _, settings_topic, settings_payload = client.do_thing_calls[1] + settings = settings_payload["state"]["desired"] + assert settings_topic == "2nd_cfg_DEV123" + assert settings == { + "shadow": "infoDev", + "deviceSN": "DEV123", + "stationSN": "BASE123", + "tComfort": [18.0, 24.0], + "comfortType": "temp", + } + + +def test_apk_control_compatibility_methods_use_expected_topics_and_payloads(): + station = _station("SBS50") + device = FakeEntity(station=station, entity_type=EntityType.MOTION) + device.type = "SMS0A" + client = RecordingClient() + + asyncio.run(client.set_station_mode(station, "away", force_arm="1")) + asyncio.run(client.trigger_sos(station, "2")) + asyncio.run(client.cancel_sos(station)) + asyncio.run(client.cancel_alarm(station)) + asyncio.run(client.set_sos_sound(station, "3")) + asyncio.run(client.activate_device(device)) + asyncio.run(client.set_install_guide_test(device, detc_sens="2")) + asyncio.run(client.signal_test(device, test_time="9")) + asyncio.run(client.set_motion_test(device, active=False)) + asyncio.run(client.mute_driveway(device, mute=True)) + + calls = client.do_thing_calls + assert [topic for _, topic, _ in calls] == [ + "2nd_appmode", + "2nd_sosdown", + "sosdown", + "alarmcancel", + "2nd_sosparam", + "2nd_appactivate", + "2nd_appinstallguide", + "2nd_signaltest_DEV123", + "testir", + "2nd_driveway", + ] + assert calls[0][2]["state"]["desired"] == { + "shadow": "appMode", + "stationSN": "BASE123", + "userId": "user-id", + "userParam": "source=1", + "source": "1", + "safeMode": "away", + "forceArm": "1", + } + assert calls[1][2]["state"]["desired"]["sosType"] == "2" + assert calls[2][2]["state"]["desired"]["sosStatus"] == "0" + assert calls[3][2]["state"]["desired"]["shadow"] == "alarmCancel" + assert calls[4][2]["state"]["desired"]["sosSound"] == "3" + assert calls[5][2]["state"]["desired"]["activate"] == "1" + assert calls[6][2]["state"]["desired"]["detcSens"] == "2" + assert calls[7][2]["state"]["desired"]["testTime"] == "9" + assert calls[8][2]["state"]["desired"] == { + "shadow": "testIR", + "stationSN": "BASE123", + "deviceSN": "DEV123", + "devType": "SMS01", + "testIR": "0", + } + assert calls[9][2]["state"]["desired"]["mute"] == "1" + + +def test_apk_config_and_alarm_compatibility_methods_validate_and_write_payloads(): + station = _station("SBS50") + device = FakeEntity(station=station, entity_type=EntityType.WATER) + device.type = "SWS0A" + client = RecordingClient() + + asyncio.run(client.set_device_config(device, ledBrt="2")) + asyncio.run(client.set_alarm_volume(device, 75, alarm_tone="2", mute="0")) + asyncio.run(client.set_voice_volume(station, 33)) + asyncio.run(client.set_fire_drill(device, alarm_type="1", alarm_vol="75")) + asyncio.run(client.set_light_group_power(station, "group-1", ["LIGHT1"], True)) + asyncio.run(client.mute_water(device, trigger_source="leak")) + asyncio.run(client.mute_temperature_humidity(device, sensor_type="STH0B")) + + calls = client.do_thing_calls + assert [topic for _, topic, _ in calls] == [ + "2nd_cfg_DEV123", + "2nd_cfg_DEV123", + "2nd_cfg_BASE123", + "2nd_firedrill", + "2nd_grouppower", + "2nd_appwater", + "2nd_appmute", + ] + assert calls[0][2]["state"]["desired"] == { + "shadow": "infoDev", + "stationSN": "BASE123", + "deviceSN": "DEV123", + "ledBrt": "2", + } + assert calls[1][2]["state"]["desired"]["alarmVol"] == "75" + assert calls[1][2]["state"]["desired"]["alarmTone"] == "2" + assert calls[2][2]["state"]["desired"] == { + "shadow": "infoBase", + "stationSN": "BASE123", + "voiceVol": "33", + } + assert calls[3][2]["state"]["desired"]["drill"] == "1" + assert calls[3][2]["state"]["desired"]["deviceType"] == "SWS0A" + assert calls[4][2]["state"]["desired"]["devs"] == ["LIGHT1"] + assert calls[4][2]["state"]["desired"]["isOn"] == "1" + assert calls[5][2]["state"]["desired"]["triggerSource"] == "leak" + assert calls[6][2]["state"]["desired"]["type"] == "STH0B" + + try: + asyncio.run(client.set_voice_volume(station, 101)) + except XSenseError: + pass + else: + raise AssertionError("volume above 100 should fail") + + try: + asyncio.run(client.set_motion_test(device, active="yes")) + except XSenseError: + pass + else: + raise AssertionError("non boolean-like values should fail") + + +def test_action_uses_resolved_app_shadow_definition(): + house = House(None, "house-id", "Home", "US", "us-east-1", "mqtt.example") + station = Station( + house, + stationId="station-id", + stationSn="BASE123", + stationName="Base", + category="SBS50", + ) + device = Device( + station, + deviceId="device-id", + deviceSn="DEV123", + deviceType="SC07-MR", + deviceName="Smoke", + ) + client = RecordingClient() + + asyncio.run(client.action(device, "test")) + + target, topic, payload = client.do_thing_calls[0] + desired = payload["state"]["desired"] + assert target is station + assert topic + assert desired["deviceSN"] == "DEV123" + assert desired["stationSN"] == "BASE123" + assert desired["userId"] == "user-id" + assert desired["shadow"] + + +def test_station_less_xs01_wx_action_uses_station_as_its_own_target(): + house = House(None, "house-id", "Home", "US", "us-east-1", "mqtt.example") + station = Station( + house, + stationId="station-id", + stationSn="EN123", + stationName="Standalone Smoke", + category="XS01-WX", + ) + station.set_data({"smokeEdition": 9}) + client = RecordingClient() + + asyncio.run(client.action(station, "mute")) + + target, topic, payload = client.do_thing_calls[0] + desired = payload["state"]["desired"] + assert target.shadow_name == "XS01-WX-EN123" + assert topic == "2nd_appmute" + assert desired["deviceSN"] == "EN123" + assert desired["stationSN"] == "EN123" + assert desired["shadow"] == "appMute" + assert desired["muteType"] == "0" + + +def test_has_action_only_returns_true_for_known_resolvable_actions(): + house = House(None, "house-id", "Home", "US", "us-east-1", "mqtt.example") + station = Station( + house, + stationId="station-id", + stationSn="BASE123", + stationName="Base", + category="SBS50", + ) + device = Device( + station, + deviceId="device-id", + deviceSn="DEV123", + deviceType="SC07-MR", + deviceName="Smoke", + ) + camera = Device( + station, + deviceId="camera-id", + deviceSn="CAM123", + deviceType="SSC0A", + deviceName="Camera", + ) + client = RecordingClient() + + assert client.has_action(device, "test") is True + assert client.has_action(device, "mute") is True + assert client.has_action(device, "firedrill") is True + assert client.has_action(device, "missing") is False + assert client.has_action(camera, "test") is False + + +def test_has_action_requires_resolvable_device_identity(): + station = _station("SBS50") + device = FakeEntity(station=station, entity_type=EntityType.SMOKE) + device.type = "SC07-MR" + device.sn = "" + client = RecordingClient() + + assert client.has_action(device, "test") is False diff --git a/tests/test_event_parser.py b/tests/test_event_parser.py new file mode 100644 index 0000000..43e68c0 --- /dev/null +++ b/tests/test_event_parser.py @@ -0,0 +1,220 @@ +from xsense.event_parser import ( + camera_event_history_event_key, + camera_event_history_records, + camera_event_history_station_data, + is_presence_topic, + is_self_test_topic, + mqtt_identifier_candidates, + mqtt_reported_data, + mqtt_topic_kind, + normalize_self_test_report, + normalize_self_test_result, +) + + +def test_mqtt_camera_motion_event_preserves_event_time(): + data = mqtt_reported_data( + { + "eventType": 92, + "eventTime": "20260614221512", + "eventData": { + "serialNumber": "camera-sn", + "deviceName": "Front", + }, + } + ) + + assert data["serialNumber"] == "camera-sn" + assert data["eventType"] == 92 + assert data["time"] == "20260614221512" + assert data["eventTime"] == "20260614221512" + assert "isMoved" not in data + assert "lastMotionTime" not in data + + +def test_mqtt_camera_motion_event_accepts_json_event_data(): + data = mqtt_reported_data( + { + "eventType": "motion_detection", + "eventTime": "20260614221612", + "eventData": '{"serialNumber":"camera-sn","isMoved":"0"}', + } + ) + + assert data["serialNumber"] == "camera-sn" + assert data["eventType"] == "motion_detection" + assert data["eventTime"] == "20260614221612" + assert data["isMoved"] == "0" + + +def test_mqtt_camera_ai_event_maps_detection_objects(): + data = mqtt_reported_data( + { + "eventTime": "20260614231512", + "eventData": { + "serialNumber": "camera-sn", + "eventItems": [ + {"eventType": "person", "eventTime": "20260612120000"} + ], + }, + } + ) + + assert data["lastAiDetection"] == "person" + assert data["personDetected"] is True + assert data["petDetected"] is False + assert data["vehicleDetected"] is False + assert data["packageDetected"] is False + assert data["otherDetected"] is False + assert data["lastPersonDetectionTime"] == "20260612120000" + + +def test_mqtt_camera_ai_event_groups_vehicle_and_package_objects(): + data = mqtt_reported_data( + { + "eventTime": "20260614231612", + "eventData": { + "serialNumber": "camera-sn", + "eventItems": [ + {"eventType": "vehicle_held_up", "eventTime": "20260612120000"}, + {"eventType": "package_pick_up", "eventTime": "20260612120001"}, + ], + }, + } + ) + + assert data["lastAiDetection"] == "package_pick_up,vehicle_held_up" + assert data["vehicleDetected"] is True + assert data["packageDetected"] is True + assert data["vehicleHeldUpDetected"] is True + assert data["packagePickUpDetected"] is True + assert data["vehicleEnterDetected"] is False + assert data["lastVehicleDetectionTime"] == "20260612120000" + assert data["lastPackageDetectionTime"] == "20260612120001" + + +def test_mqtt_camera_ai_event_accepts_event_object_type_payload(): + data = mqtt_reported_data( + { + "eventTime": "20260614231712", + "eventData": { + "serialNumber": "camera-sn", + "eventObjectType": { + "person": [], + "pet": [], + "vehicle": ["vehicle_enter"], + "package": ["package_exist"], + }, + }, + } + ) + + assert data["personDetected"] is True + assert data["petDetected"] is True + assert data["vehicleDetected"] is True + assert data["packageDetected"] is True + assert data["lastAiDetection"] == "package_exist,person,pet,vehicle_enter" + assert data["vehicleEnterDetected"] is True + assert data["packageExistDetected"] is True + + +def test_mqtt_camera_ai_plan_event_uses_dispatch_device_identity(): + data = mqtt_reported_data( + { + "userId": "user-id", + "eventType": "ai_event", + "eventTime": "20260614230000", + "eventData": { + "serverId": "service-id", + "dispatchDevs": [ + { + "stationSn": "station-sn", + "deviceSn": "camera-sn", + "deviceType": "SSC0A", + "eventTime": "20260614230100", + } + ], + "eventItems": [ + {"eventType": "person", "eventTime": "20260614230200"} + ], + }, + } + ) + + assert data["stationSN"] == "station-sn" + assert data["deviceSN"] == "camera-sn" + assert data["serialNumber"] == "camera-sn" + assert data["lastAiDetection"] == "person" + assert data["lastPersonDetectionTime"] == "20260614230200" + + +def test_identifier_candidates_walk_nested_json_payloads(): + assert mqtt_identifier_candidates( + { + "eventData": '{"dispatchDevs":[{"stationSn":"station-sn","deviceSn":"camera-sn"}]}' + } + ) == ["station-sn", "camera-sn"] + + +def test_camera_event_history_records_and_station_data(): + history = { + "data": { + "list": [ + { + "serialNumber": "CAM123", + "traceId": "trace-1", + "timestamp": 1_718_000_000_000, + "videoEvent": "motion", + "eventInfoList": [ + {"eventType": "person", "eventTime": "20260614230200"} + ], + }, + "bad-record", + ] + } + } + + records = camera_event_history_records(history) + data = camera_event_history_station_data(records[0]) + + assert len(records) == 1 + assert camera_event_history_event_key(records[0]) == "camera-event:CAM123:trace-1" + assert data["serialNumber"] == "CAM123" + assert data["deviceSN"] == "CAM123" + assert data["eventType"] == "motion" + assert data["lastAiDetection"] == "person" + assert data["personDetected"] is True + assert data["lastPersonDetectionTime"] == "20260614230200" + + +def test_mqtt_topic_kind_classifies_xsense_topics(): + assert mqtt_topic_kind("$aws/events/presence/connected/thing") == "presence" + assert mqtt_topic_kind("@xsense/events/aiplan/user") == "ai_plan" + assert mqtt_topic_kind("@xsense/events/motion/house") == "house_event" + assert mqtt_topic_kind("$aws/things/thing/shadow/name/info/update") == "shadow" + assert mqtt_topic_kind("other/topic") == "other" + assert is_presence_topic("$aws/events/presence/connected/thing") is True + assert is_presence_topic("@xsense/events/motion/house") is False + + +def test_self_test_topic_detection_matches_app_report_topics(): + assert is_self_test_topic("$aws/things/base/shadow/name/2nd_selftestup/update") + assert is_self_test_topic("$aws/things/base/shadow/name/selftestup_v2/update") + assert is_self_test_topic("$aws/things/base/shadow/name/device_testup/update") + assert not is_self_test_topic("$aws/things/base/shadow/name/info/update") + + +def test_normalize_self_test_report_maps_result_and_time_fields(): + data = {"testResult": "passed", "timestamp": "20260625010203"} + + normalize_self_test_report(data) + + assert data["lastSelfTest"] == "0" + assert data["lastSelfTestTime"] == "20260625010203" + + +def test_normalize_self_test_result_preserves_numeric_values(): + assert normalize_self_test_result("failed") == "1" + assert normalize_self_test_result("error") == "1" + assert normalize_self_test_result("ok") == "0" + assert normalize_self_test_result(0) == 0 diff --git a/tests/test_model_normalization.py b/tests/test_model_normalization.py new file mode 100644 index 0000000..e32d80b --- /dev/null +++ b/tests/test_model_normalization.py @@ -0,0 +1,429 @@ +from datetime import datetime, timedelta, timezone + +from xsense.base import XSenseBase +from xsense.device import Device +from xsense.entity import Entity +from xsense.entity_map import EntityType +from xsense.house import House +from xsense.station import Station + + +class FakeSigner: + def presign_url(self, *args): + return "wss://mqtt.example/mqtt?sig=abc" + + +def _house(): + house = House(FakeSigner(), "house-id", "Home", "US", "us-east-1", "mqtt.example") + house.set_rooms( + { + "houseRooms": {"room-1": {"roomName": "Kitchen"}}, + "roomSort": ["room-1"], + } + ) + return house + + +def test_entity_online_state_uses_explicit_flag_and_report_time(): + entity = Entity(onLine=1) + entity.type = "XS01-WX" + + entity.set_data({"online": 0}) + + assert entity.online is False + + utc_time = datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + entity.set_data({"utcTime": utc_time, "onlineTime": utc_time}) + + assert entity.online is True + + +def test_shadow_name_follows_app_thing_name_rules(): + station = Station(_house(), stationId="sbs", stationSn="BASE123", category="SBS10") + assert station.shadow_name == "BASE123" + + wifi_station = Station( + _house(), stationId="wx", stationSn="ST123", category="SC07-WX" + ) + assert wifi_station.shadow_name == "SC07-WX-ST123" + + smoke_station = Station( + _house(), stationId="smoke", stationSn="EN123", category="XS01-WX" + ) + assert smoke_station.shadow_name == "XS01-WX-EN123" + + child = Device( + station, + deviceId="child", + deviceSn="CHILD123", + deviceType="STH0B", + deviceName="Thermo", + ) + assert child.shadow_name == "SBS50BASE123" + + +def test_house_set_stations_maps_app_camera_list(): + house = _house() + + house.set_stations( + { + "stationSort": [], + "stations": [], + "cameras": [ + { + "ipcId": "cam-id", + "ipcSn": "CAM123", + "ipcName": "Front", + "category": "SSC0A", + "roomId": "room-1", + "userId": "user-id", + "userName": "User", + } + ], + } + ) + + camera = house.stations["cam-id"] + assert camera.sn == "CAM123" + assert camera.name == "Front" + assert camera.type == "SSC0A" + assert camera.entity_type == EntityType.CAMERA + assert camera.online is True + + +def test_station_set_devices_adds_room_and_group_light_devices(): + house = _house() + station = Station( + house, + stationId="station-id", + stationSn="SBS50123", + stationName="Base", + category="SBS50", + roomId="room-1", + ) + + station.set_devices( + { + "roomId": "room-1", + "devices": [ + { + "deviceId": "light-1", + "deviceSn": "LIGHT001", + "deviceType": "LP/N-SA-0B", + "deviceName": "Light", + "roomId": "room-1", + "groupId": 12, + "online": 1, + "on": "1", + } + ], + "groupList": [ + {"groupId": 12, "groupName": "All Lights", "createTime": "171"} + ], + } + ) + + light = station.get_device_by_sn("LIGHT001") + group = station.get_group_device(12) + + assert light.data["roomName"] == "Kitchen" + assert group is not None + assert group.type == "group-L" + assert group.data["on"] is True + assert group.data["devs"] == ["LIGHT001"] + + +def test_parse_get_state_routes_child_shadow_payloads(): + house = _house() + station = Station( + house, + stationId="station-id", + stationSn="SBS50123", + stationName="Base", + category="SBS50", + ) + station.set_devices( + { + "devices": [ + { + "deviceId": "device-id", + "deviceSn": "DEV123", + "deviceType": "STH0B", + "deviceName": "Thermo", + } + ] + } + ) + + XSenseBase().parse_get_state( + station, + { + "activate": "1", + "devs": { + "DEV123": { + "temperature": "20.5", + "humidity": "44", + "online": "1", + } + }, + }, + ) + + device = station.get_device_by_sn("DEV123") + assert station.has_alarm is True + assert device.online is True + assert device.data["temperature"] == 20.5 + assert device.data["humidity"] == 44.0 + + +def test_sbs10_door_child_shadow_payload_sets_open_state(): + house = _house() + station = Station( + house, + stationId="station-id", + stationSn="BASE123", + stationName="Base", + category="SBS10", + ) + station.set_devices( + { + "devices": [ + { + "deviceId": "door-id", + "deviceSn": "DOOR123", + "deviceType": "SDS0A", + "deviceName": "Door", + } + ] + } + ) + + XSenseBase().parse_get_state( + station, + { + "devs": { + "DOOR123": { + "status": "open", + "online": "1", + } + } + }, + ) + + door = station.get_device_by_sn("DOOR123") + assert door.online is True + assert door.entity_type == EntityType.DOOR + assert door.data["isOpen"] is True + + +def test_door_status_aliases_normalize_to_is_open(): + house = _house() + station = Station( + house, + stationId="station-id", + stationSn="BASE123", + stationName="Base", + category="SBS10", + ) + device = Device( + station, + deviceId="door-id", + deviceSn="DOOR123", + deviceType="SES01", + deviceName="Door", + ) + + device.set_data({"a": "closed"}) + assert device.data["isOpen"] is False + + device.set_data({"doorStatus": "open"}) + assert device.data["isOpen"] is True + + +def test_security_line_fields_from_issue_29_are_normalized(): + house = _house() + station = Station( + house, + stationId="station-id", + stationSn="BASE123", + stationName="Base", + category="SBS50", + ) + station.set_devices( + { + "devices": [ + { + "deviceId": "door-id", + "deviceSn": "DOOR123", + "deviceType": "SDS0A", + "deviceName": "Door", + } + ] + } + ) + + XSenseBase().parse_get_state( + station, + { + "devs": { + "DOOR123": { + "isOpen": "1", + "openRemind": "1", + "online": "1", + "batInfo": "3", + "rfLevel": "2", + "alarmStatus": "0", + } + } + }, + ) + + door = station.get_device_by_sn("DOOR123") + assert door.data["isOpen"] is True + assert door.data["openRemind"] is True + assert door.online is True + assert door.data["batInfo"] == 3 + assert door.data["rfLevel"] == 2 + assert door.data["alarmStatus"] is False + + +def test_parse_get_state_updates_group_light_from_shadow_payload(): + house = _house() + station = Station( + house, + stationId="station-id", + stationSn="SBS50123", + stationName="Base", + category="SBS50", + ) + station.set_devices( + { + "devices": [], + "groupList": [{"groupId": 5, "groupName": "Hall", "createTime": "171"}], + } + ) + + XSenseBase().parse_get_state( + station, + { + "groupId": 5, + "isOn": "1", + "devs": [{"deviceSn": "A"}, {"deviceSn": "B"}], + }, + ) + + group = station.get_group_device(5) + assert group.data["on"] is True + assert group.data["devs"] == [{"deviceSn": "A"}, {"deviceSn": "B"}] + + +def test_apply_safe_mode_updates_station_attribute_and_data(): + station = Station( + _house(), + stationId="station-id", + stationSn="SBS50123", + stationName="Base", + category="SBS50", + ) + + XSenseBase().apply_safe_mode(station, "home") + + assert station.safe_mode == "home" + assert station.data["safeMode"] == "home" + + +def test_parse_get_state_keeps_safe_mode_in_sync_with_other_fields(): + station = Station( + _house(), + stationId="station-id", + stationSn="SBS50123", + stationName="Base", + category="SBS50", + ) + + XSenseBase().parse_get_state(station, {"safeMode": "away", "alarmStatus": "1"}) + + assert station.safe_mode == "away" + assert station.data["safeMode"] == "away" + assert station.data["alarmStatus"] is True + assert station.alarm_mode == "away" + assert station.is_armed is True + + +def test_station_alarm_mode_uses_alarm_data_before_safe_mode(): + station = Station( + _house(), + stationId="station-id", + stationSn="SBS50123", + stationName="Base", + category="SBS50", + safeMode="home", + ) + + assert station.alarm_mode == "home" + assert station.is_armed is True + + station.set_alarm_data({"mode": "disarmed", "safeMode": "away"}) + + assert station.alarm_mode == "disarmed" + assert station.is_armed is False + + +def test_station_lookup_helpers_find_station_by_serial_shadow_and_child_device(): + house = _house() + station = Station( + house, + stationId="station-id", + stationSn="BASE123", + stationName="Base", + category="SBS50", + ) + station.set_devices( + { + "devices": [ + { + "deviceId": "device-id", + "deviceSn": "DEV123", + "deviceType": "STH0B", + "deviceName": "Thermo", + } + ] + } + ) + house.stations[station.entity_id] = station + client = XSenseBase() + client.houses = {house.house_id: house} + + assert client.station_by_sn("BASE123") is station + assert client.station_by_shadow_name("SBS50BASE123") is station + assert client.station_by_device_sn("DEV123") is station + assert client.station_by_device_sn("BASE123") is station + assert client.station_by_sn("missing") is None + assert client.station_by_shadow_name(None) is None + assert client.station_by_device_sn("") is None + + +def test_action_support_requires_resolvable_route(): + house = _house() + station = Station( + house, + stationId="station-id", + stationSn="ST123", + stationName="Base", + category="SBS50", + ) + device = Device( + station, + deviceId="device-id", + deviceSn="DEV123", + deviceType="SC07-MR", + deviceName="Smoke", + ) + client = XSenseBase() + + assert client.has_action(device, "test") + assert client.action_definition(device, "test") is not None + + device.sn = None + + assert not client.has_action(device, "test") diff --git a/tests/test_mqtt_helper.py b/tests/test_mqtt_helper.py new file mode 100644 index 0000000..362a4d2 --- /dev/null +++ b/tests/test_mqtt_helper.py @@ -0,0 +1,186 @@ +import json + +import pytest + +from xsense import mqtt_helper + + +def test_mqtt_helper_defers_tls_context_loading_until_connect_setup(monkeypatch): + calls = [] + clients = [] + + class FakeClient: + def __init__(self, **kwargs): + self.kwargs = kwargs + self.contexts = [] + self.ws_paths = [] + clients.append(self) + + def username_pw_set(self, username, password): + self.username = username + self.password = password + + def tls_set_context(self, context): + self.contexts.append(context) + + def ws_set_options(self, path): + self.ws_paths.append(path) + + def fake_create_default_context(): + calls.append("created") + return "ssl-context" + + monkeypatch.setattr(mqtt_helper.mqtt_client, "Client", FakeClient) + monkeypatch.setattr( + mqtt_helper.ssl, "create_default_context", fake_create_default_context + ) + + helper = mqtt_helper.MQTTHelper( + signer=type( + "Signer", + (), + {"presign_url": lambda self, *args: "wss://mqtt.example/mqtt?sig=abc"}, + )(), + house=type( + "House", + (), + {"mqtt_server": "mqtt.example", "mqtt_region": "us-east-1"}, + )(), + ) + + assert calls == [] + assert clients[0].contexts == [] + assert clients[0].ws_paths == [] + + helper.prepare_connection() + + assert calls == ["created"] + assert clients[0].contexts == ["ssl-context"] + assert clients[0].ws_paths == ["/mqtt?sig=abc"] + + helper.prepare_connection() + + assert calls == ["created"] + assert clients[0].contexts == ["ssl-context"] + assert clients[0].ws_paths == ["/mqtt?sig=abc", "/mqtt?sig=abc"] + + +def _helper_with_client(client): + helper = object.__new__(mqtt_helper.MQTTHelper) + helper.client = client + return helper + + +def test_mqtt_helper_subscribe_uses_app_qos1_by_default(): + subscribed = [] + + class FakeClient: + def subscribe(self, topic, qos=0): + subscribed.append((topic, qos)) + return "subscribed" + + helper = _helper_with_client(FakeClient()) + + assert helper.subscribe("topic/name") == "subscribed" + assert subscribed == [("topic/name", 1)] + + +def test_mqtt_helper_publish_uses_compact_utf8_json(): + published = [] + + class FakeClient: + def publish(self, topic, payload, qos=0, retain=False): + published.append((topic, payload, qos, retain)) + return "published" + + helper = _helper_with_client(FakeClient()) + payload = {"label": "中文", "enabled": True} + + assert helper.publish("topic", payload) == "published" + assert published == [ + ( + "topic", + json.dumps(payload, ensure_ascii=False, separators=(",", ":")), + 0, + False, + ) + ] + + +def test_mqtt_topic_helpers_match_aws_and_app_topics(): + assert ( + mqtt_helper.shadow_update_topic("thing", "infoDev") + == "$aws/things/thing/shadow/name/infoDev/update" + ) + assert ( + mqtt_helper.shadow_wildcard_topic("thing") + == "$aws/things/thing/shadow/name/+/update" + ) + assert mqtt_helper.presence_topic("thing") == "$aws/events/presence/+/thing" + assert mqtt_helper.house_event_topic("house") == "@xsense/events/+/house" + + +def test_mqtt_payload_parser_and_shadow_ack_filtering(): + assert mqtt_helper.parse_message_payload(b'{"state":{"reported":{"on":"1"}}}') == { + "state": {"reported": {"on": "1"}} + } + assert mqtt_helper.parse_message_payload(None) == {} + assert mqtt_helper.parse_message_payload({"already": "dict"}) == {"already": "dict"} + assert mqtt_helper.should_ignore_shadow_topic( + "$aws/things/thing/shadow/name/infoDev/update/accepted" + ) + assert not mqtt_helper.should_ignore_shadow_topic( + "$aws/things/thing/shadow/name/infoDev/update" + ) + + +def test_live_update_topics_include_house_station_and_presence_topics(): + station = type("Station", (), {"shadow_name": "SBS50BASE123"})() + house = type( + "House", + (), + { + "house_id": "house-id", + "stations": {"station": station}, + }, + )() + helper = object.__new__(mqtt_helper.MQTTHelper) + helper.house = house + + assert helper.live_update_topics() == [ + "@xsense/events/+/house-id", + "$aws/things/house-id/shadow/name/+/update", + "$aws/things/SBS50BASE123/shadow/name/+/update", + "$aws/events/presence/+/SBS50BASE123", + ] + + +def test_temp_data_request_requires_user_and_device_serials(): + helper = object.__new__(mqtt_helper.MQTTHelper) + station = type( + "Station", + (), + { + "sn": "BASE123", + "shadow_name": "SBS50BASE123", + "devices": { + "one": type("Device", (), {"sn": "TEMP1", "type": "STH0B"})(), + "two": type("Device", (), {"sn": "SMOKE1", "type": "XS01-M"})(), + }, + }, + )() + + with pytest.raises(ValueError, match="user_id is required"): + helper.build_temp_data_request(station) + + payload = helper.build_temp_data_request(station, user_id="user-id") + + desired = payload["state"]["desired"] + assert desired["shadow"] == "appTempData" + assert desired["deviceSN"] == ["TEMP1"] + assert desired["stationSN"] == "BASE123" + assert desired["userId"] == "user-id" + assert desired["timeoutM"] == "5" + assert helper.temp_data_topic(station) == ( + "$aws/things/SBS50BASE123/shadow/name/2nd_apptempdata/update" + ) diff --git a/tests/test_public_api.py b/tests/test_public_api.py new file mode 100644 index 0000000..03e9f42 --- /dev/null +++ b/tests/test_public_api.py @@ -0,0 +1,49 @@ +import importlib + +import pytest + +import xsense +from xsense import AsyncXSense, Device, House, MQTTHelper, Station + + +def test_top_level_exports_async_client_and_models_only(): + assert xsense.__version__ == "0.1.0" + assert xsense.__all__ == [ + "AsyncXSense", + "Device", + "House", + "MQTTHelper", + "Station", + "__version__", + ] + assert AsyncXSense is xsense.AsyncXSense + assert Device is xsense.Device + assert House is xsense.House + assert MQTTHelper is xsense.MQTTHelper + assert Station is xsense.Station + assert not hasattr(xsense, "XSense") + + +def test_sync_client_module_was_removed(): + with pytest.raises(ModuleNotFoundError): + importlib.import_module("xsense.xsense") + + +def test_event_parser_has_explicit_public_exports(): + event_parser = importlib.import_module("xsense.event_parser") + + assert "mqtt_reported_data" in event_parser.__all__ + assert "camera_event_history_records" in event_parser.__all__ + assert "apply_apk_ai_detection_aliases" in event_parser.__all__ + assert "normalize_self_test_report" in event_parser.__all__ + assert "is_presence_topic" in event_parser.__all__ + + +def test_webrtc_signal_has_explicit_public_exports(): + webrtc_signal = importlib.import_module("xsense.webrtc_signal") + + assert "XSenseWebRTCTicket" in webrtc_signal.__all__ + assert "XSenseWebRTCSignalSession" in webrtc_signal.__all__ + assert "make_sdp_offer_payload" in webrtc_signal.__all__ + assert "make_ice_candidate_payload" in webrtc_signal.__all__ + assert "parse_signal_message" in webrtc_signal.__all__ diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..559d2ec --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,26 @@ +import sys + +import pytest + +from xsense.utils import get_credentials + + +def test_get_credentials_uses_cli_arguments(monkeypatch): + monkeypatch.setattr( + sys, + "argv", + ["prog", "--username", "user@example.com", "--password", "secret"], + ) + + assert get_credentials() == ("user@example.com", "secret") + + +def test_get_credentials_raises_when_env_file_has_no_credentials( + monkeypatch, tmp_path +): + monkeypatch.chdir(tmp_path) + monkeypatch.setattr(sys, "argv", ["prog"]) + (tmp_path / ".env").write_text("OTHER=value\n", encoding="utf-8") + + with pytest.raises(ValueError, match="Username and password not provided"): + get_credentials() diff --git a/tests/test_webrtc_signal.py b/tests/test_webrtc_signal.py new file mode 100644 index 0000000..3be7dd4 --- /dev/null +++ b/tests/test_webrtc_signal.py @@ -0,0 +1,170 @@ +import base64 +import json +import time + +from xsense.webrtc_signal import ( + SIGNAL_MODE, + SIGNAL_VIEWER_TYPE, + XSenseWebRTCTicket, + make_ice_candidate_payload, + make_sdp_offer_payload, + parse_signal_message, +) + + +def _decode_payload(envelope: dict): + return json.loads(base64.b64decode(envelope["messagePayload"]).decode()) + + +def _ticket(**overrides): + data = { + "signalServer": "https://signal.example/ws", + "groupId": "group-id", + "role": "viewer", + "id": "client-id", + "traceId": "trace-id", + "sign": "signed-value", + "time": "1710000000000", + "expirationTime": str(int(time.time() * 1000) + 60_000), + "signalPingInterval": "30", + "appStopLiveTimeout": "45", + "signalServerIpAddress": "203.0.113.10", + "iceServer": [{"urls": ["stun:example"]}], + } + data.update(overrides) + return XSenseWebRTCTicket.from_api("CAM123", data) + + +def test_webrtc_ticket_parses_api_data_and_builds_signal_url(): + ticket = _ticket() + + assert ticket.serial_number == "CAM123" + assert ticket.signal_server == "https://signal.example/ws" + assert ticket.group_id == "group-id" + assert ticket.role == "viewer" + assert ticket.client_id == "client-id" + assert ticket.trace_id == "trace-id" + assert ticket.sign == "signed-value" + assert ticket.time == 1710000000000 + assert ticket.signal_ping_interval == 30 + assert ticket.app_stop_live_timeout == 45 + assert ticket.ice_servers == [{"urls": ["stun:example"]}] + assert ticket.is_valid is True + assert ticket.signal_url() == ( + "wss://signal.example/group-id/viewer/client-id?" + "traceId=trace-id&time=1710000000000&sign=signed-value&name=test-123" + ) + + +def test_webrtc_ticket_connect_options_use_signal_ip_override(): + ticket = _ticket(signalServer="wss://signal.example:443/ws") + + assert ticket.signal_connect_options() == { + "url": ( + "wss://203.0.113.10:443/group-id/viewer/client-id?" + "traceId=trace-id&time=1710000000000&sign=signed-value&name=test-123" + ), + "headers": {"Host": "signal.example:443"}, + "server_hostname": "signal.example", + } + + +def test_make_sdp_offer_payload_uses_apk_envelope(): + ticket = _ticket(signalServer="wss://signal.example") + payload = make_sdp_offer_payload( + offer_sdp=( + "v=0\r\n" + "m=video 9 UDP/TLS/RTP/SAVPF 96\r\n" + "a=mid:0\r\n" + "a=recvonly\r\n" + "a=candidate:1 1 udp 1 192.0.2.10 123 typ host\r\n" + ), + ticket=ticket, + recipient_client_id="CAM123", + session_id="session-id", + resolution="1920x1080", + ) + + envelope = json.loads(payload) + decoded = _decode_payload(envelope) + + assert envelope["messageType"] == "SDP_OFFER" + assert envelope["mode"] == SIGNAL_MODE + assert envelope["viewerType"] == SIGNAL_VIEWER_TYPE + assert envelope["senderClientId"] == "client-id" + assert envelope["recipientClientId"] == "CAM123" + assert envelope["sessionId"] == "session-id" + assert envelope["resolution"] == "1920x1080" + assert decoded["type"] == "offer" + assert "a=recvonly" in decoded["sdp"] + assert "a=candidate:" not in decoded["sdp"] + + +def test_make_ice_candidate_payload_uses_apk_envelope(): + ticket = _ticket() + + payload = make_ice_candidate_payload( + candidate="candidate:1 1 udp 1 192.0.2.10 123 typ host", + sdp_mid="0", + sdp_m_line_index=0, + ticket=ticket, + recipient_client_id="CAM123", + session_id="session-id", + ) + + envelope = json.loads(payload) + decoded = _decode_payload(envelope) + + assert envelope["messageType"] == "ICE_CANDIDATE" + assert envelope["senderClientId"] == "client-id" + assert envelope["recipientClientId"] == "CAM123" + assert decoded == { + "sdpMid": "0", + "sdpMLineIndex": 0, + "candidate": "candidate:1 1 udp 1 192.0.2.10 123 typ host", + } + + +def test_parse_signal_message_decodes_answer_and_candidate_payloads(): + answer_payload = base64.b64encode( + json.dumps({"type": "answer", "sdp": "v=0\r\n"}).encode() + ).decode() + event, payload = parse_signal_message( + json.dumps( + { + "messageType": "SDP_ANSWER", + "senderClientId": "CAM123", + "recipientClientId": "client-id", + "messagePayload": answer_payload, + } + ) + ) + + assert event == "SDP_ANSWER" + assert payload["messagePayload"] == answer_payload + + candidate_payload = json.dumps( + {"candidate": "candidate:1 1 udp 1 192.0.2.10 123 typ host"} + ) + event, payload = parse_signal_message( + json.dumps( + { + "messageType": "ICE_CANDIDATE", + "messagePayload": candidate_payload, + } + ) + ) + + assert event == "ICE_CANDIDATE" + assert payload == {"candidate": "candidate:1 1 udp 1 192.0.2.10 123 typ host"} + + +def test_parse_signal_message_decodes_encoded_peer_payload(): + peer_payload = base64.b64encode(json.dumps({"clientId": "CAM123"}).encode()).decode() + + event, payload = parse_signal_message( + json.dumps({"messageType": "PEER_IN", "messagePayload": peer_payload}) + ) + + assert event == "PEER_IN" + assert payload == {"clientId": "CAM123"} diff --git a/xsense/__init__.py b/xsense/__init__.py index d4d3f59..0b96b90 100644 --- a/xsense/__init__.py +++ b/xsense/__init__.py @@ -1,8 +1,18 @@ -from .xsense import XSense -from .async_xsense import AsyncXSense +"""Async X-Sense cloud, MQTT, and camera client library.""" +from .device import Device +from .async_xsense import AsyncXSense from .house import House +from .mqtt_helper import MQTTHelper from .station import Station -from .device import Device -from .mqtt_helper import MQTTHelper +__version__ = "0.1.0" + +__all__ = [ + "AsyncXSense", + "Device", + "House", + "MQTTHelper", + "Station", + "__version__", +] diff --git a/xsense/async_xsense.py b/xsense/async_xsense.py index 70c45d2..c04c7f8 100644 --- a/xsense/async_xsense.py +++ b/xsense/async_xsense.py @@ -1,24 +1,362 @@ import asyncio -from datetime import datetime import json -from typing import Dict, Optional +import logging +from datetime import datetime, timezone +from typing import Any, Dict import aiohttp -from xsense.aws_signer import AWSSigner -from xsense.base import XSenseBase -from xsense.entity import Entity -from xsense.entity_map import entities -from xsense.exceptions import SessionExpired, APIFailure, NotFoundError, XSenseError -from xsense.house import House -from xsense.station import Station +from .aws_signer import AWSSigner +from .base import XSenseBase, shadow_update_body +from .entity import Entity +from .entity_map import EntityType +from .exceptions import SessionExpired, APIFailure, XSenseError +from .house import House +from .station import Station + +LOGGER = logging.getLogger(__name__) + +CAMERA_TYPES = {"SSC0A", "SSC0B"} +CAMERA_LIVE_URL_MAX_AGE_SECONDS = 240 +_CAMERA_VIDEO_RESOLUTIONS = { + "auto", + "640x360", + "640x480", + "960x720", + "1280x720", + "1280x960", + "1920x1080", + "2048x1440", + "2048x1536", + "2304x1296", + "2560x1440", + "3840x2160", + "7680x4320", +} +_CAMERA_RESOLUTION_ALIASES = { + "AUTO": "auto", + "SD": "1280x720", + "HD": "1920x1080", + "2K": "2560x1440", + "360P": "640x360", + "P360": "640x360", + "480P": "640x480", + "P480": "640x480", + "720P": "1280x720", + "P720": "1280x720", + "1080P": "1920x1080", + "P1080": "1920x1080", + "1296P": "2304x1296", + "P1296": "2304x1296", + "1440P": "2560x1440", + "P1440": "2560x1440", + "1536P": "2048x1536", + "P1536": "2048x1536", +} + +CAMERA_AI_NOTIFICATION_TYPES = ( + "person", + "pet", + "vehicle_enter", + "vehicle_out", + "vehicle_held_up", + "package_exist", + "package_drop_off", + "package_pick_up", + "other", +) +CAMERA_AI_ASSISTANT_TYPES = ("person", "pet", "vehicle", "package") +_CAMERA_AI_NOTIFICATION_GROUPS = { + "person": ("person",), + "pet": ("pet",), + "vehicle": ("vehicle_enter", "vehicle_out", "vehicle_held_up"), + "package": ("package_exist", "package_drop_off", "package_pick_up"), + "other": ("other",), +} +_CAMERA_AI_NOTIFICATION_PAYLOAD_KEYS = { + "package": "package", + "person": "person", + "pet": "pet", + "vehicle": "vehicle", + "other": "other", +} + +# The Android app reads these standalone Wi-Fi device categories from the +# house-level mainpage/2nd_mainpage shadows, not from station-level mainpage +# shadows. Querying a station-level mainpage for them returns 404 on accounts +# such as #160 and should not fail setup. +_HOUSE_STATE_DEVICE_TYPES = { + "SC06-WX", + "SC07-WX", + "STH0C", + "SWS0B", + "XC04-WX", + "XC0C-iA", + "XC0C-iR", + "XC0M-iR", + "XP0A-iR", + "XP0H-iR", + "XP0J-iA", + "XR0A-iR", + "XS01-WX", + "XS03-WX", + "XS0B-iR", + "XS0E-iR", + "XS0R-iA", +} + +# The APK uses 2nd_info directly for the newer standalone Wi-Fi CO, +# combined, temperature/humidity, water, and radon families. Wi-Fi smoke +# families still use the legacy info shadow in their settings screens. +_SECOND_INFO_DEVICE_TYPES = { + "SC06-WX", + "SC07-WX", + "STH0C", + "SWS0B", + "XC04-WX", + "XC0C-iA", + "XC0C-iR", + "XC0M-iR", + "XP0A-iR", + "XP0H-iR", + "XP0J-iA", + "XR0A-iR", +} + + +def _shadow_update_body(data: Dict) -> str: + """Return compact AWS shadow JSON body kept for integration parity.""" + return shadow_update_body(data) + + +def _debug_keys(value: Any) -> list[str]: + """Return sorted mapping keys for debug logs without dumping values.""" + if not isinstance(value, dict): + return [] + return sorted(str(key) for key in value) + + +def _debug_data_shape(value: Any) -> dict[str, Any]: + """Return compact response shape details for debug logs.""" + if isinstance(value, dict): + shape: dict[str, Any] = {"keys": _debug_keys(value)} + if isinstance(value.get("list"), list): + shape["list_count"] = len(value["list"]) + return shape + if isinstance(value, list): + return {"list_count": len(value)} + return {"type": type(value).__name__} + + +def _station_state_shadow_names(station: Station) -> tuple[str, ...]: + if station.type in _HOUSE_STATE_DEVICE_TYPES: + return () + if station.type == "SBS10": + return ("mainpage",) + if station.type == "SBS50": + return ("2nd_mainpage",) + return () + + +def _station_info_shadow_names(station: Station) -> tuple[str, ...]: + if station.type == "SBS10": + return (f"info_{station.sn}",) + if station.type == "SBS50" or station.type in _SECOND_INFO_DEVICE_TYPES: + return (f"2nd_info_{station.sn}",) + return (f"info_{station.sn}",) + + +def is_camera_entity(entity: Entity) -> bool: + """Return if an entity came from the APK camera sources.""" + return ( + getattr(entity, "entity_type", None) == EntityType.CAMERA + or entity.type in CAMERA_TYPES + ) + + +def _normalized_camera_serial(value) -> str | None: + """Return a camera serial in the comparison form used for ADDX matching.""" + if value is None: + return None + serial = "".join(char for char in str(value).upper() if char.isalnum()) + return serial or None + + +def _camera_resolution(value) -> str | None: + """Return an APK-style camera video resolution value.""" + if value is None: + return None + resolution = str(value).strip().replace("VIDEO_SIZE_", "") + if not resolution: + return None + normalized = resolution.replace("×", "x") + if normalized in _CAMERA_VIDEO_RESOLUTIONS: + return normalized + if alias := _CAMERA_RESOLUTION_ALIASES.get(normalized.upper()): + return alias + return None + + +def _camera_webrtc_ticket_valid(ticket: dict) -> bool: + expiration = ticket.get("expirationTime") + if expiration in (None, ""): + return False + try: + return int(expiration) > int(datetime.now().timestamp() * 1000) + except (TypeError, ValueError): + return False + + +def _camera_supported_resolutions(camera: Entity) -> list[str]: + """Return APK-supported camera live resolutions in device order.""" + supported = ( + camera.data.get("supportedRecordingResolutions") + or camera.data.get("deviceSupportResolution") + or [] + ) + if isinstance(supported, str): + supported = [supported] + elif not isinstance(supported, (list, tuple)): + return [] + resolutions: list[str] = [] + for value in supported: + resolution = _camera_resolution(value) + if resolution and resolution not in resolutions: + resolutions.append(resolution) + return resolutions + + +def camera_live_resolution(camera: Entity) -> str: + """Return the APK start-live resolution for a camera live-view session.""" + supported_resolutions = _camera_supported_resolutions(camera) + saved_resolution = _camera_resolution(camera.data.get("liveResolution")) + if saved_resolution and ( + not supported_resolutions or saved_resolution in supported_resolutions + ): + return saved_resolution + if supported_resolutions: + return supported_resolutions[0] + + return "auto" + + +def camera_online(camera: Entity) -> bool: + """Return whether ADDX currently reports the camera online.""" + online = getattr(camera, "online", None) + if online is not None: + return online is True + return camera.data.get("online") == 1 + + +def camera_stream_protocol(camera: Entity) -> str | None: + """Return the ADDX stream protocol from the camera device model.""" + protocol = camera.data.get("streamProtocol") + if protocol is None: + return None + return str(protocol).lower() + + +def stream_source_protocol(source: str | None) -> str | None: + """Return a stream source URL protocol without exposing the full source URL.""" + if not isinstance(source, str) or "://" not in source: + return None + return source.split("://", 1)[0].lower() + + +def schedule_time(value: str) -> str: + """Return an APK schedule time in HHMM form.""" + text = str(value).strip() + if ":" in text: + hour, minute = text.split(":", 1) + text = f"{hour.zfill(2)}{minute.zfill(2)}" + if len(text) != 4 or not text.isdigit(): + raise ValueError("X-Sense schedule time must be HH:MM or HHMM") + hour = int(text[:2]) + minute = int(text[2:]) + if hour > 23 or minute > 59: + raise ValueError("X-Sense schedule time is out of range") + return text + + +def schedule_week_days(values: list[str]) -> list[str]: + """Return APK weekday values, where 1 is Sunday and 7 is Saturday.""" + result = [str(value).strip() for value in values] + if not result: + raise ValueError("X-Sense schedule must include at least one weekday") + invalid = [ + value for value in result if value not in {"1", "2", "3", "4", "5", "6", "7"} + ] + if invalid: + raise ValueError("X-Sense schedule weekdays must be 1 through 7") + return result + + +def light_schedule_list(value) -> list: + """Return a normalized schedule list from the APK query response.""" + if isinstance(value, dict): + data = value.get("schedList") or value.get("schedule") or value.get("list") + return data if isinstance(data, list) else [] + return value if isinstance(value, list) else [] + + +def light_group_list(value) -> list: + """Return a normalized group list from the APK query response.""" + if isinstance(value, dict): + data = value.get("groupList") or value.get("groups") or value.get("list") + if data is None and isinstance(value.get("reData"), dict): + data = value["reData"].get("groupList") + return data if isinstance(data, list) else [] + return value if isinstance(value, list) else [] + + +def non_empty_strings(values: list[str], field_name: str) -> list[str]: + """Return stripped non-empty strings for API list fields.""" + result = [str(value).strip() for value in values if str(value).strip()] + if not result: + raise ValueError(f"X-Sense {field_name} must include at least one ID") + return result + + +def typed_option(option: str) -> int | str: + """Return option as int when the API supplied numeric options.""" + try: + return int(option) + except ValueError: + return option + + +def comfort_pair(value, default: list[float]) -> list[float]: + """Return a comfort range pair for APK comfort mode writes.""" + if isinstance(value, (list, tuple)) and len(value) >= 2: + try: + return [float(value[0]), float(value[1])] + except (TypeError, ValueError): + pass + return list(default) + + +def is_native_stream_camera(camera: Entity) -> bool: + """Return whether the camera advertises a native stream protocol.""" + protocol = camera_stream_protocol(camera) + if protocol is None: + return False + return "rtsp" in protocol or "rtmp" in protocol + + +def is_webrtc_camera(camera: Entity) -> bool: + """Return whether the camera should use ADDX WebRTC signaling.""" + protocol = camera_stream_protocol(camera) + if protocol is None: + return True + return "rtsp" not in protocol and "rtmp" not in protocol class AsyncXSense(XSenseBase): - def __init__(self, session=None): + def __init__(self, session=None, language: str | None = None): super().__init__() self.session = session self._owns_session = session is None + self.language = _ipc_language(language) async def _get_session(self): if self.session is None or self.session.closed: @@ -38,22 +376,20 @@ async def __aexit__(self, exc_type, exc, traceback): await self.close() async def api_call(self, code, unauth=False, **kwargs): - data = { - **kwargs - } + data = {**kwargs} if unauth: headers = None - mac = 'abcdefg' + mac = "abcdefg" else: if self._access_token_expiring(): await self.refresh() - headers = {'Authorization': self.access_token} + headers = {"Authorization": self.access_token} mac = self._calculate_mac(data) session = await self._get_session() async with session.post( - f'{self.API}/app', + f"{self.API}/app", json={ **data, "clientType": self.CLIENTYPE, @@ -62,89 +398,474 @@ async def api_call(self, code, unauth=False, **kwargs): "bizCode": code, "appCode": self.APPCODE, }, - headers=headers + headers=headers, ) as response: self._lastres = response data = await response.json() if response.status >= 400: - message = data.get('message') or 'unknown error' - raise APIFailure(f'API failure: {response.status}/{message}') + LOGGER.debug( + "X-Sense API failure context: code=%s status=%s keys=%s", + code, + response.status, + _debug_keys(data), + ) + message = data.get("message") or "unknown error" + raise APIFailure(f"API failure: {response.status}/{message}") + + if "reCode" not in data: + LOGGER.debug( + "X-Sense API unexpected response context: code=%s status=%s keys=%s", + code, + response.status, + _debug_keys(data), + ) + raise APIFailure("API failure: Cannot understand response") + + if data["reCode"] != 200: + errCode = data.get("errCode", 0) + LOGGER.debug( + "X-Sense API error context: code=%s reCode=%s errCode=%s keys=%s", + code, + data["reCode"], + errCode, + _debug_keys(data), + ) + if errCode in ("10000008", "10000020"): + raise SessionExpired(data.get("reMsg")) + raise APIFailure( + f"Request for code {code} failed with error {errCode}/{data['reCode']} {data.get('reMsg')}" + ) + return data["reData"] - if 'reCode' not in data: - raise APIFailure('API failure: Cannot understand response') + async def ai_service_call(self, code: str, **kwargs): + """Call the APK AI-notification service endpoint.""" + if self._access_token_expiring(): + await self.refresh() - if data['reCode'] != 200: - errCode = data.get('errCode', 0) - if errCode in ('10000008', '10000020'): - raise SessionExpired(data.get('reMsg')) + session = await self._get_session() + async with session.post( + f"{self.API}/app", + json=self._signed_body(kwargs, code), + headers={"Authorization": self.access_token}, + ) as response: + self._lastres = response + data = await response.json() + + if response.status >= 400: + LOGGER.debug( + "X-Sense AI service failure context: code=%s status=%s keys=%s", + code, + response.status, + _debug_keys(data), + ) + message = data.get("message") or data.get("reMsg") or "unknown error" + raise APIFailure(f"AI service failure: {response.status}/{message}") + + if str(data.get("reCode")) != "200": + err_code = data.get("errCode", 0) + LOGGER.debug( + "X-Sense AI service error context: code=%s reCode=%s errCode=%s keys=%s", + code, + data.get("reCode"), + err_code, + _debug_keys(data), + ) + if err_code in ("10000008", "10000020"): + raise SessionExpired(data.get("reMsg")) raise APIFailure( - f"Request for code {code} failed with error {errCode}/{data['reCode']} {data.get('reMsg')}" + f"AI service request for code {code} failed with error {err_code}/{data.get('reCode')} {data.get('reMsg')}" + ) + return data.get("reData") + + async def get_ai_service_list(self) -> list[dict]: + """Return APK AI-notification services for the account.""" + user_id = self.user_id_code or self.userid + if not user_id: + return [] + data = await self.ai_service_call("701001", userId=user_id) + LOGGER.debug( + "X-Sense AI service list response: %s", + _debug_data_shape(data), + ) + return data if isinstance(data, list) else [] + + async def get_ai_service_history( + self, server_id: str, next_token: str | None = None + ) -> dict: + """Return APK AI-notification history for a service.""" + payload: dict[str, Any] = {"serverId": server_id} + if next_token: + payload["nextToken"] = next_token + data = await self.ai_service_call("701008", **payload) + return data if isinstance(data, dict) else {} + + async def get_camera_event_history( + self, + serial_numbers: list[str], + start_timestamp: int, + end_timestamp: int, + *, + start: int = 0, + limit: int = 20, + ) -> dict: + """Return ADDX camera library records using the APK playback path.""" + serials = [str(serial) for serial in serial_numbers if serial] + if not serials: + return {} + data = await self.addx_call( + "/library/newselectlibrary", + startTimestamp=start_timestamp, + endTimestamp=end_timestamp, + to=limit, + serialNumber=serials, + tags=[], + marked=0, + **{"from": start}, + ) + LOGGER.debug( + "X-Sense camera record history response: %s", + _debug_data_shape(data), + ) + return data if isinstance(data, dict) else {} + + async def get_daily_history( + self, + house: House | str, + day_time: str, + time_zone: str, + next_token: str | None = None, + ) -> dict: + """Return account daily history using the documented APK endpoint.""" + house_id = house.house_id if hasattr(house, "house_id") else str(house) + payload: dict[str, Any] = { + "houseId": house_id, + "dayTime": day_time, + "timeZone": time_zone, + } + if next_token: + payload["nextToken"] = next_token + data = await self.api_call("104001", **payload) + return data if isinstance(data, dict) else {} + + async def get_monthly_history( + self, + house: House | str, + month: str, + time_zone: str, + ) -> dict: + """Return account monthly history counts using the APK endpoint.""" + house_id = house.house_id if hasattr(house, "house_id") else str(house) + data = await self.api_call( + "104006", houseId=house_id, hisMonth=month, timeZone=time_zone + ) + return data if isinstance(data, dict) else {} + + async def get_station_history( + self, + station: Station, + day_time: str, + time_zone: str, + *, + device: Entity | str | None = None, + next_token: str | None = None, + ) -> dict: + """Return station or device daily history through the APK endpoint.""" + payload: dict[str, Any] = { + "houseId": station.house.house_id, + "stationId": station.entity_id, + "dayTime": day_time, + "timeZone": time_zone, + } + if device is not None: + payload["deviceId"] = ( + device.entity_id if hasattr(device, "entity_id") else str(device) + ) + if next_token: + payload["nextToken"] = next_token + data = await self.api_call("104007", **payload) + return data if isinstance(data, dict) else {} + + async def get_station_monthly_history( + self, + station: Station, + month: str, + time_zone: str, + *, + device: Entity | str | None = None, + ) -> dict: + """Return station or device monthly history counts through the APK endpoint.""" + payload: dict[str, Any] = { + "houseId": station.house.house_id, + "stationId": station.entity_id, + "hisMonth": month, + "timeZone": time_zone, + } + if device is not None: + payload["deviceId"] = ( + device.entity_id if hasattr(device, "entity_id") else str(device) + ) + data = await self.api_call("104008", **payload) + return data if isinstance(data, dict) else {} + + async def get_co_history_days( + self, + station: Station, + time_zone: str, + *, + device: Entity | str | None = None, + ) -> dict: + """Return CO history days for a station or child device.""" + payload: dict[str, Any] = { + "stationId": station.entity_id, + "timeZone": time_zone, + } + code = "104014" + if device is not None: + code = "104009" + payload["deviceId"] = ( + device.entity_id if hasattr(device, "entity_id") else str(device) + ) + data = await self.api_call(code, **payload) + return data if isinstance(data, dict) else {} + + async def get_co_history_details( + self, + station: Station, + day_time: str, + time_zone: str, + *, + device: Entity | str | None = None, + ) -> dict: + """Return CO PPM readings for a station or child device.""" + payload: dict[str, Any] = { + "houseId": station.house.house_id, + "stationId": station.entity_id, + "dayTime": day_time, + "timeZone": time_zone, + } + code = "104015" + if device is not None: + code = "104010" + payload["deviceId"] = ( + device.entity_id if hasattr(device, "entity_id") else str(device) + ) + data = await self.api_call(code, **payload) + return data if isinstance(data, dict) else {} + + async def get_temperature_history( + self, + station: Station, + last_time: str, + *, + next_token: str | None = None, + ) -> dict: + """Return temperature/humidity chart history through the APK endpoint.""" + payload: dict[str, Any] = { + "houseId": station.house.house_id, + "stationId": station.entity_id, + "lastTime": last_time, + } + if next_token: + payload["nextToken"] = next_token + data = await self.api_call("104020", **payload) + return data if isinstance(data, dict) else {} + + async def get_dispatch_history( + self, server_id: str, next_token: str | None = None + ) -> dict: + """Return security dispatch history through the APK endpoint.""" + payload: dict[str, Any] = {"serverId": server_id} + if next_token: + payload["nextToken"] = next_token + data = await self.api_call("505001", **payload) + return data if isinstance(data, dict) else {} + + async def ipc_call(self, code: str, **kwargs): + """Call the X-Sense IPC endpoint used by the Android app.""" + if self._access_token_expiring(): + await self.refresh() + + session = await self._get_session() + async with session.post( + f"{self.IPC_API}/ipc", + json=self._signed_body(kwargs, code, ipc=True), + headers={"Authorization": self.access_token}, + ) as response: + self._lastres = response + data = await response.json() + + if response.status >= 400: + LOGGER.debug( + "X-Sense IPC failure context: code=%s status=%s keys=%s", + code, + response.status, + _debug_keys(data), + ) + message = data.get("message") or data.get("reMsg") or "unknown error" + raise APIFailure(f"IPC API failure: {response.status}/{message}") + + if str(data.get("reCode")) != "200": + err_code = data.get("errCode", 0) + LOGGER.debug( + "X-Sense IPC error context: code=%s reCode=%s errCode=%s keys=%s", + code, + data.get("reCode"), + err_code, + _debug_keys(data), ) - return data['reData'] + if err_code in ("10000008", "10000020"): + raise SessionExpired(data.get("reMsg")) + raise APIFailure( + f"Request for IPC code {code} failed with error {err_code}/{data.get('reCode')} {data.get('reMsg')}" + ) + return data.get("reData") + + async def addx_call(self, endpoint: str, *, _retry: bool = True, **kwargs): + """Call the ADDX camera API after the IPC endpoint has issued a token.""" + if self._addx_session is None: + self._addx_session = await self.register_ipc() + + addx_session = self._addx_session + node = addx_session.get("nodeType") + base_url = self.ADDX_API_BY_NODE.get(node) + if base_url is None: + raise APIFailure(f"Unknown ADDX nodeType: {node}") + data = self._addx_body(addx_session, kwargs) - async def get_house(self, house: House, page: str): + session = await self._get_session() + async with session.post( + f"{base_url}{endpoint}", + json=data, + headers={ + "Authorization": addx_session["token"], + "Content-Type": "application/json", + }, + ) as response: + self._lastres = response + result = await response.json() + + if response.status >= 400: + LOGGER.debug( + "X-Sense ADDX failure context: endpoint=%s status=%s node=%s keys=%s data_shape=%s", + endpoint, + response.status, + node, + _debug_keys(result), + _debug_data_shape(result.get("data")), + ) + if response.status in (401, 403) and _retry: + self._addx_session = await self.register_ipc() + return await self.addx_call(endpoint, _retry=False, **kwargs) + message = result.get("msg") or result.get("message") or "unknown error" + raise APIFailure(f"ADDX API failure: {response.status}/{message}") + + if result.get("result") not in (0, None): + LOGGER.debug( + "X-Sense ADDX error context: endpoint=%s result=%s node=%s keys=%s data_shape=%s", + endpoint, + result.get("result"), + node, + _debug_keys(result), + _debug_data_shape(result.get("data")), + ) + raise APIFailure( + f"ADDX request for {endpoint} failed with error {result.get('result')}/{result.get('msg')}" + ) + return result.get("data") + + def _addx_body(self, addx_session: dict, data: Dict | None = None) -> Dict: + """Return the ADDX request body shape used by the Android SDK.""" + result = dict(data or {}) + country = addx_session.get("countryNo") + if not country: + raise APIFailure("Missing ADDX countryNo from IPC registration") + language = addx_session.get("language") + if not language: + raise APIFailure("Missing ADDX language from IPC registration") + result["countryNo"] = country + result["language"] = language + result["app"] = { + "appName": self.ADDX_APP_NAME, + "appType": "Android", + "bundle": self.ADDX_APP_BUNDLE, + "channelId": self.ADDX_APP_CHANNEL_ID, + "countlyId": self.ADDX_APP_COUNTLY_ID, + "tenantId": self.ADDX_APP_TENANT_ID, + "version": self.ADDX_APP_VERSION, + "versionName": self.ADDX_APP_VERSION_NAME, + } + return result + + async def get_house(self, house: House, page: str, *, _retry: bool = True): if self._aws_token_expiring(): await self.load_aws() url, headers = self._house_request(house, page) session = await self._get_session() - async with session.get( - url, - headers=headers - ) as response: + async with session.get(url, headers=headers) as response: self._lastres = response + if response.status in (401, 403) and _retry: + await self.load_aws() + return await self.get_house(house, page, _retry=False) return await response.json() - async def get_thing(self, station: Station, page: str): + async def get_thing(self, station: Station, page: str, *, _retry: bool = True): if self._aws_token_expiring(): await self.load_aws() url, headers = self._thing_request(station, page) session = await self._get_session() - async with session.get( - url, - headers=headers - ) as response: + async with session.get(url, headers=headers) as response: self._lastres = response + if response.status in (401, 403) and _retry: + await self.load_aws() + return await self.get_thing(station, page, _retry=False) return await response.json() - async def do_thing(self, station: Station, page: str, data: Dict): + async def do_thing( + self, station: Station, page: str, data: Dict, *, _retry: bool = True + ): if self._aws_token_expiring(): await self.load_aws() - url, headers = self._thing_request(station, page, data) + body = shadow_update_body(data) + url, headers = self._thing_request(station, page, body) session = await self._get_session() - async with session.post( - url, - json=data, - headers=headers - ) as response: + async with session.post(url, data=body, headers=headers) as response: self._lastres = response + if response.status in (401, 403) and _retry: + await self.load_aws() + return await self.do_thing(station, page, data, _retry=False) + if response.status >= 400: + text = await response.text() + raise APIFailure( + f"Unable to update thing shadow: {response.status}/{text}" + ) return await response.json() async def login(self, username, password): - await asyncio.get_event_loop().run_in_executor(None, self.sync_login, username, password) + await asyncio.get_running_loop().run_in_executor( + None, self._cognito_login, username, password + ) await self.load_aws() async def refresh(self): url, data, headers = self._refresh_request() session = await self._get_session() - async with session.post( - url, json=data, headers=headers - ) as response: + async with session.post(url, json=data, headers=headers) as response: self._lastres = response text = await response.text() data = json.loads(text) if response.status == 400: - raise SessionExpired(data.get('message', 'token refresh failed')) + raise SessionExpired(data.get("message", "token refresh failed")) - self._parse_refresh_result(data.get('AuthenticationResult', {})) + self._parse_refresh_result(data.get("AuthenticationResult", {})) async def init(self): await self.get_client_info() @@ -152,22 +873,26 @@ async def init(self): async def load_aws(self): await self.get_aws_tokens() if self.signer: - self.signer.update(self.aws_access_key, self.aws_secret_access_key, self.aws_session_token) + self.signer.update( + self.aws_access_key, self.aws_secret_access_key, self.aws_session_token + ) else: - self.signer = AWSSigner(self.aws_access_key, self.aws_secret_access_key, self.aws_session_token) + self.signer = AWSSigner( + self.aws_access_key, self.aws_secret_access_key, self.aws_session_token + ) async def load_all(self): result = {} for i in await self.get_houses(): h = House( self.signer, - i['houseId'], - i['houseName'], - i['houseRegion'], - i['mqttRegion'], - i['mqttServer'] + i["houseId"], + i["houseName"], + i["houseRegion"], + i["mqttRegion"], + i["mqttServer"], ) - result[i['houseId']] = h + result[i["houseId"]] = h if rooms := await self.get_rooms(h.house_id): h.set_rooms(rooms) @@ -177,233 +902,1803 @@ async def load_all(self): self.houses = result + async def register_ipc(self): + """Register with X-Sense IPC and receive the ADDX camera token.""" + if not self.houses: + raise APIFailure("Cannot register IPC without an X-Sense house") + house = next(iter(self.houses.values())) + node_type = _ipc_node_type(house.mqtt_region) + return await self.ipc_call( + "C10101", + userName=self.username, + nodeType=node_type, + language=self.language, + ) + + async def update_camera_data(self): + """Merge camera metadata and config from the Android app ADDX API.""" + data = await self.addx_call("/device/listuserdevices") + devices = [ + device + for device in (data or {}).get("list") or [] + if _normalized_camera_serial(device.get("serialNumber")) + ] + if not devices: + return + + cameras = [] + for device in devices: + camera = self._camera_from_addx_device(device) + if camera is not None: + cameras.append((camera, device)) + if not cameras: + return + + for camera, addx_device in cameras: + camera.set_data(_camera_data(addx_device)) + try: + config = await self.addx_call( + "/device/getuserconfig", + serialNumber=camera.sn, + voiceReminder=False, + ) + except APIFailure: + config = None + if config: + camera.set_data(_camera_config_data(config)) + + try: + setting_options = await self.addx_call( + "/user/getFormOptions", + serialNumber=camera.sn, + ) + except APIFailure: + setting_options = None + if setting_options: + camera.set_data(_camera_settings_options_data(setting_options)) + + if ( + any( + camera.data.get(key) is not False + for key in ( + "supportLiveAudio", + "supportLiveSpeakerVolume", + "supportRecordingAudio", + ) + ) + or camera.data.get("supportMechanicalDingDong") is True + ): + try: + audio = await self.addx_call( + "/device/config/querydeviceaudio", + serialNumber=camera.sn, + ) + except APIFailure: + audio = None + if audio: + camera.set_data(_camera_audio_data(audio)) + + if camera.data.get("supportDoorBellAlarm"): + try: + doorbell = await self.addx_call( + "/device/config/querydoorbellconfig", + serialNumber=camera.sn, + ) + except APIFailure: + doorbell = None + if doorbell: + camera.set_data(_camera_doorbell_data(doorbell)) + + if camera.data.get("supportPersonDetect") is not False: + try: + notification_settings = await self.addx_call( + "/device/queryMessageNotification/v1", + serialNumber=camera.sn, + userId=self.userid, + ) + except APIFailure: + notification_settings = None + if notification_settings: + camera.set_data( + _camera_ai_notification_data(notification_settings) + ) + + try: + ai_event_settings = await self.addx_call( + "/aiAssist/queryEventObjectSwitch", + isAll=False, + serialNumbers=[camera.sn], + ) + except APIFailure: + ai_event_settings = None + if ai_event_settings: + camera.set_data( + _camera_ai_assistant_data(ai_event_settings, camera.sn) + ) + + async def get_camera_thumbnail(self, camera: Entity) -> bytes | None: + """Return the latest camera thumbnail bytes from the APK thumbnail URL.""" + thumbnail_url = camera.data.get("thumbImgUrl") + if not thumbnail_url: + return None + + session = await self._get_session() + async with session.get(thumbnail_url) as response: + self._lastres = response + if response.status >= 400: + return None + return await response.read() + + def _camera_from_addx_device(self, data: Dict) -> Station | None: + """Return the X-Sense camera entity backed by an ADDX DeviceBean.""" + serial = data.get("serialNumber") + normalized_serial = _normalized_camera_serial(serial) + if normalized_serial is None: + return None + + for house in self.houses.values(): + for station in house.stations.values(): + if ( + is_camera_entity(station) + and _normalized_camera_serial(station.sn) == normalized_serial + ): + camera_type = _camera_type(data) + if camera_type: + station.type = camera_type + if data.get("deviceName"): + station.name = data["deviceName"] + return station + + device_house_id = data.get("houseId") + if device_house_id in (None, ""): + target_houses = list(self.houses.values()) + else: + target_houses = [ + house + for house in self.houses.values() + if str(device_house_id) == str(house.house_id) + ] + if not target_houses and len(self.houses) == 1: + target_houses = list(self.houses.values()) + + if len(target_houses) != 1: + return None + + house = target_houses[0] + station_id = str(serial) + camera_type = _camera_type(data) + station = Station( + house, + stationId=station_id, + stationSn=serial, + stationName=data.get("deviceName") + or data.get("displayModelNo") + or station_id, + category=camera_type, + deviceType=camera_type, + onLine=data.get("online"), + devices=[], + ) + station.entity_type = EntityType.CAMERA + station.set_devices({"devices": []}) + house.stations[station.entity_id] = station + return station + + async def update_camera_config(self, camera: Entity, **updates): + """Write camera user config through the Android app endpoint.""" + payload = _camera_user_config_payload(camera, updates) + LOGGER.debug( + "X-Sense camera config update: %s", + _camera_write_debug_context(camera, "/device/updateuserconfig", updates), + ) + await self.addx_call("/device/updateuserconfig", **payload) + camera.set_data(updates) + + async def update_camera_audio(self, camera: Entity, **updates): + """Write camera audio config through the Android app endpoint.""" + device_audio = { + key: camera.data.get(key) + for key in ( + "doorBellRingKey", + "liveAudioToggleOn", + "liveSpeakerVolume", + "recordingAudioToggleOn", + ) + if camera.data.get(key) is not None + } + device_audio.update( + {key: value for key, value in updates.items() if value is not None} + ) + LOGGER.debug( + "X-Sense camera config update: %s", + _camera_write_debug_context( + camera, "/device/config/updatedeviceaudio", updates + ), + ) + await self.addx_call( + "/device/config/updatedeviceaudio", + serialNumber=camera.sn, + deviceAudio=device_audio, + ) + camera.set_data(updates) + + async def update_camera_recording_resolution( + self, camera: Entity, resolution: str + ) -> None: + """Write camera recording resolution through the Android app endpoint.""" + LOGGER.debug( + "X-Sense camera config update: %s", + _camera_write_debug_context( + camera, "/device/updaterecresolution", {"recResolution": resolution} + ), + ) + await self.addx_call( + "/device/updaterecresolution", + serialNumber=camera.sn, + recResolution=resolution, + ) + camera.set_data({"recResolution": resolution}) + + async def update_camera_default_codec(self, camera: Entity, codec: str) -> None: + """Write the default camera codec through the Android app endpoint.""" + LOGGER.debug( + "X-Sense camera config update: %s", + _camera_write_debug_context( + camera, "/device/config/updatedefaultcodec", {"defaultCodec": codec} + ), + ) + await self.addx_call( + "/device/config/updatedefaultcodec", + serialNumber=camera.sn, + defaultCodec=codec, + ) + camera.set_data({"defaultCodec": codec}) + + async def update_camera_cooldown( + self, camera: Entity, *, user_enable: bool, value: int + ) -> None: + """Write camera cooldown through the Android app cooldown endpoint.""" + LOGGER.debug( + "X-Sense camera config update: %s", + _camera_write_debug_context( + camera, + "/device/updateCooldown", + {"cooldown.userEnable": user_enable, "cooldown.value": value}, + ), + ) + await self.addx_call( + "/device/updateCooldown", + serialNumber=camera.sn, + cooldown={"userEnable": user_enable, "value": value}, + ) + camera.set_data({"cooldownEnabled": user_enable, "cooldownValue": value}) + + async def get_camera_webrtc_ticket( + self, camera: Entity, *, force_refresh: bool = False + ) -> dict | None: + """Return an APK WebRTC ticket, reusing it until it is near expiry.""" + cached = camera.data.get("cameraWebrtcTicket") + if ( + not force_refresh + and isinstance(cached, dict) + and _camera_webrtc_ticket_valid(cached) + ): + return cached + + data = await self.addx_call( + "/device/getWebrtcTicket", + serialNumber=camera.sn, + verifyDormancyStatus=True, + ) + if isinstance(data, dict): + camera.set_data({"cameraWebrtcTicket": data}) + return data + return None + + async def start_camera_live(self, camera: Entity) -> str | None: + """Return the direct live URL from the ADDX start-live endpoint.""" + live_started_at = camera.data.get("cameraLiveStartedAt") + if ( + (camera_live_url := camera.data.get("cameraLiveUrl")) + and isinstance(live_started_at, datetime) + and (datetime.now() - live_started_at).total_seconds() + < CAMERA_LIVE_URL_MAX_AGE_SECONDS + ): + return camera_live_url + + data = await self.addx_call( + "/device/newstartlive", + serialNumber=camera.sn, + liveResolution=camera_live_resolution(camera), + ) + if isinstance(data, dict): + live_url = _camera_live_url(data) + camera.set_data( + { + "cameraAudioUrl": data.get("audioUrl"), + "cameraLiveId": data.get("liveId"), + "cameraLiveStartedAt": datetime.now(), + "cameraLiveUrl": live_url, + "cameraLiveProtocol": _url_scheme(live_url), + } + ) + return live_url + return None + + async def keep_camera_live_alive(self, camera: Entity) -> None: + """Send the APK camera live-view keepalive request.""" + await self.addx_call( + "/device/keepalive", serialNumber=camera.sn, seconds=30 + ) + + async def stop_camera_live(self, camera: Entity) -> None: + """Stop camera live view through the Android app endpoint.""" + try: + await self.addx_call("/device/stoplive", serialNumber=camera.sn) + finally: + camera.set_data( + { + "cameraAudioUrl": None, + "cameraLiveId": None, + "cameraLiveStartedAt": None, + "cameraLiveUrl": None, + "cameraLiveProtocol": None, + "cameraWebrtcTicket": None, + } + ) + + async def wake_camera(self, camera: Entity) -> None: + """Wake a sleeping camera through the Android app endpoint.""" + await self.addx_call("/device/wakeupDevice", serialNumber=camera.sn) + + async def update_camera_doorbell_config(self, camera: Entity, **updates) -> None: + """Write doorbell config through the Android app endpoint.""" + doorbell_config = { + "alarmWhenRemoveToggleOn": camera.data.get("alarmWhenRemoveToggleOn") + } + doorbell_config.update(updates) + LOGGER.debug( + "X-Sense camera config update: %s", + _camera_write_debug_context( + camera, "/device/config/updatedoorbellconfig", updates + ), + ) + await self.addx_call( + "/device/config/updatedoorbellconfig", + serialNumber=camera.sn, + doorbellConfig=doorbell_config, + ) + camera.set_data(updates) + + async def update_camera_ai_notification( + self, camera: Entity, event_object: str, enabled: bool + ) -> None: + """Write camera AI notification category settings through the app endpoint.""" + current = _camera_ai_notification_enabled(camera.data) + if enabled: + current.add(event_object) + else: + current.discard(event_object) + payload = _camera_ai_notification_payload(current) + LOGGER.debug( + "X-Sense camera config update: %s", + _camera_write_debug_context( + camera, + "/device/updateMessageNotification/v1", + {f"aiNotification.{event_object}": enabled}, + ), + ) + await self.addx_call( + "/device/updateMessageNotification/v1", + serialNumber=camera.sn, + eventObjectType=payload, + ) + camera.set_data(_camera_ai_notification_state_data(current)) + + async def update_camera_ai_assistant( + self, camera: Entity, event_object: str, enabled: bool + ) -> None: + """Write camera AI assistant object switch through the app endpoint.""" + LOGGER.debug( + "X-Sense camera config update: %s", + _camera_write_debug_context( + camera, + "/aiAssist/updateEventObjectSwitch", + {f"aiAssistant.{event_object}": enabled}, + ), + ) + await self.addx_call( + "/aiAssist/updateEventObjectSwitch", + serialNumber=camera.sn, + list=[{"checked": enabled, "eventObject": event_object}], + ) + camera.set_data({_camera_ai_assistant_key(event_object): enabled}) + async def get_client_info(self): data = await self.api_call("101001", unauth=True) - self.clientid = data['clientId'] - self.clientsecret = self._decode_secret(data['clientSecret']) - self.region = data['cgtRegion'] - self.userpool = data['userPoolId'] + self.clientid = data["clientId"] + self.clientsecret = self._decode_secret(data["clientSecret"]) + self.region = data["cgtRegion"] + self.userpool = data["userPoolId"] async def get_aws_tokens(self): data = await self.api_call("101003", userName=self.username) - self.aws_access_key = data['accessKeyId'] - self.aws_secret_access_key = data['secretAccessKey'] - self.aws_session_token = data['sessionToken'] - self.aws_access_expiry = datetime.strptime(data['expiration'], "%Y-%m-%d %H:%M:%S%z") + self.aws_access_key = data["accessKeyId"] + self.aws_secret_access_key = data["secretAccessKey"] + self.aws_session_token = data["sessionToken"] + self.aws_access_expiry = datetime.strptime( + data["expiration"], "%Y-%m-%d %H:%M:%S%z" + ) async def get_houses(self): - params = { - 'utctimestamp': "0" - } + params = {"utctimestamp": "0"} return await self.api_call("102007", **params) async def get_rooms(self, houseId: str): - params = { - 'houseId': houseId, - 'utctimestamp': "0" - } + params = {"houseId": houseId, "utctimestamp": "0"} return await self.api_call("102008", **params) async def get_stations(self, houseId: str): - params = { - 'houseId': houseId, - 'utctimestamp': "0" - } + params = {"houseId": houseId, "utctimestamp": "0"} return await self.api_call("103007", **params) - async def get_history(self, houseId: str, dayTime: str, timeZone: str, nextToken: Optional[str] = None): - params = { - 'houseId': houseId, - 'dayTime': dayTime, - 'timeZone': timeZone, - } - if nextToken: - params['nextToken'] = nextToken - return await self.api_call("104001", **params) - - async def get_history_month(self, houseId: str, hisMonth: str, timeZone: str): - params = { - 'houseId': houseId, - 'hisMonth': hisMonth, - 'timeZone': timeZone, - } - return await self.api_call("104006", **params) - - async def get_station_history( - self, - houseId: str, - stationId: str, - dayTime: str, - timeZone: str, - deviceId: Optional[str] = None, - nextToken: Optional[str] = None, - ): - params = { - 'houseId': houseId, - 'dayTime': dayTime, - 'timeZone': timeZone, - 'stationId': stationId, - } - if deviceId: - params['deviceId'] = deviceId - if nextToken: - params['nextToken'] = nextToken - return await self.api_call("104007", **params) - - async def get_station_history_month( - self, - houseId: str, - stationId: str, - deviceId: str, - hisMonth: str, - timeZone: str, - ): - params = { - 'houseId': houseId, - 'hisMonth': hisMonth, - 'timeZone': timeZone, - 'stationId': stationId, - 'deviceId': deviceId, - } - return await self.api_call("104008", **params) - - async def get_security_history(self, serverId: str, nextToken: Optional[str] = None): - params = { - 'serverId': serverId, - } - if nextToken: - params['nextToken'] = nextToken - return await self.api_call("505001", **params) - - async def get_sth_history( - self, - houseId: str, - stationId: str, - lastTime: str = "", - nextToken: Optional[str] = None, - ): - params = { - 'houseId': houseId, - 'stationId': stationId, - 'lastTime': lastTime, - } - if nextToken: - params['nextToken'] = nextToken - return await self.api_call("104020", **params) - - async def get_co_ppm_history(self, stationId: str, timeZone: str, deviceId: Optional[str] = None): - params = { - 'stationId': stationId, - 'timeZone': timeZone, - } - if deviceId: - params['deviceId'] = deviceId - return await self.api_call("104009", **params) - return await self.api_call("104014", **params) - - async def get_co_ppm_history_details( - self, - houseId: str, - stationId: str, - dayTime: str, - timeZone: str, - deviceId: Optional[str] = None, - ): - params = { - 'houseId': houseId, - 'stationId': stationId, - 'dayTime': dayTime, - 'timeZone': timeZone, - } - if deviceId: - params['deviceId'] = deviceId - return await self.api_call("104010", **params) - return await self.api_call("104015", **params) - async def get_house_state(self, house: House): - for page in ('mainpage', '2nd_mainpage'): + for page in ("mainpage", "2nd_mainpage"): res = await self.get_house(house, page) if self._lastres.status == 404: continue - if 'reported' in res.get('state', {}): - self._parse_get_house_state(house, res['state']['reported']) + if "reported" in res.get("state", {}): + self._parse_get_house_state(house, res["state"]["reported"]) # else: # text = await self._lastres.text() # raise APIFailure(f'Unable to retrieve house data: {self._lastres.status}/{text}') async def get_alarm_state(self, station: Station): - res = await self.get_thing(station, '2nd_safemode') + res = await self.get_thing(station, "2nd_safemode") if self._lastres.status == 404: return - if 'reported' in res.get('state', {}): - station.set_alarm_data(res['state']['reported']) + if "reported" in res.get("state", {}): + station.set_alarm_data(res["state"]["reported"]) async def get_station_state(self, station: Station): - res = None + for page in _station_info_shadow_names(station): + res = await self.get_thing(station, page) - if station.type not in ('SBS50', 'SC07-WX', 'XC04-WX'): - res = await self.get_thing(station, f'info_{station.sn}') - - if res is None or self._lastres.status == 404: - res = await self.get_thing(station, f'2nd_info_{station.sn}') + if self._lastres.status == 404: + continue - if self._lastres.status == 404: - return + if "reported" in res.get("state", {}): + station.set_data(res["state"]["reported"]) + return - if 'reported' in res.get('state', {}): - station.set_data(res['state']['reported']) - else: text = await self._lastres.text() - raise APIFailure(f'Unable to retrieve station data: {self._lastres.status}/{text}') + raise APIFailure( + f"Unable to retrieve station data: {self._lastres.status}/{text}" + ) async def get_state(self, station: Station): - if not station.devices: - return + for page in _station_state_shadow_names(station): + res = await self.get_thing(station, page) - res = None - if station.type not in ('SBS10',): - res = await self.get_thing(station, '2nd_mainpage') + if self._lastres.status == 404: + return - if res is None or self._lastres.status == 404: - res = await self.get_thing(station, f'mainpage') + if "reported" in res.get("state", {}): + self.parse_get_state(station, res["state"]["reported"]) + return - if 'reported' in res.get('state', {}): - self.parse_get_state(station, res['state']['reported']) - else: text = await self._lastres.text() - raise APIFailure(f'Unable to retrieve station data: {self._lastres.status}/{text}') + raise APIFailure( + f"Unable to retrieve station data: {self._lastres.status}/{text}" + ) - async def set_state(self, entity: Entity, shadow: str, topic: str, definition: Dict): - station = entity.station - t = datetime.now() - timestamp = t.strftime('%Y%m%d%H%M%S') + async def set_state( + self, entity: Entity, shadow: str, topic: str, definition: Dict + ): + station = getattr(entity, "station", entity) + target = definition.get("target") + if callable(target): + target = target(entity) + if target is None: + target = station desired = { "deviceSN": entity.sn, "shadow": shadow, "stationSN": station.sn, - "time": timestamp, - "userId": self.userid + "userId": self.userid, } - desired.update(definition.get('extra', {})) + if timestamp := _action_timestamp(definition, entity): + desired["time"] = timestamp + extra = definition.get("extra", {}) + if callable(extra): + extra = extra(entity) + desired.update(extra) + action_data = definition.get("data", {}) + if callable(action_data): + action_data = action_data(entity) + desired.update(action_data) data = {"state": {"desired": desired}} + LOGGER.debug( + "X-Sense action shadow update: %s", + _action_debug_context( + entity, definition.get("action"), target, topic, desired + ), + ) + + return await self.do_thing(target, topic, data) + + def _station_for_entity(self, entity: Entity) -> Station: + station = getattr(entity, "station", entity) + if not getattr(station, "sn", None): + raise XSenseError("Entity is not associated with a station") + return station + + def _validate_volume(self, volume: int) -> None: + if not isinstance(volume, int): + raise XSenseError("Volume must be an integer") + if volume < 0 or volume > 100: + raise XSenseError("Volume must be between 0 and 100") + + def _bool_value(self, value) -> str: + if isinstance(value, bool): + return "1" if value else "0" + if value in (0, 1, "0", "1"): + return str(value) + raise XSenseError("Value must be a boolean or 0/1") + + def _utc_timestamp(self) -> str: + return datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + + def build_command_state( + self, + entity: Entity, + shadow: str, + values: Dict, + include_device: bool | None = None, + include_time: bool = True, + include_user: bool = True, + ): + """Return the app-style desired shadow payload for command helpers.""" + station = self._station_for_entity(entity) + if include_device is None: + include_device = entity is not station + + desired = { + "shadow": shadow, + "stationSN": station.sn, + } + if include_device: + desired["deviceSN"] = entity.sn + if include_time: + desired["time"] = self._utc_timestamp() + if include_user: + desired["userId"] = self.userid + desired.update({key: value for key, value in values.items() if value is not None}) + return station, {"state": {"desired": desired}} + + def build_config_state(self, entity: Entity, shadow: str, values: Dict): + """Return an app-style config shadow payload without time/user fields.""" + return self.build_command_state( + entity, shadow, values, include_time=False, include_user=False + ) + + def build_desired_state(self, entity: Entity, shadow: str, definition: Dict): + """Return an app-style action payload for a mapped action definition.""" + extra = definition.get("extra", {}) + if callable(extra): + extra = extra(entity) + data = definition.get("data", {}) + if callable(data): + data = data(entity) + values = {**extra, **data} + return self.build_command_state(entity, shadow, values) + + async def set_device_config(self, entity: Entity, **values): + """Write raw device config values through the APK config shadow.""" + shadow = "infoBase" if isinstance(entity, Station) else "infoDev" + station, data = self.build_config_state(entity, shadow, values) + return await self.do_thing(station, f"2nd_cfg_{entity.sn}", data) + + async def set_alarm_volume( + self, + entity: Entity, + volume: int, + alarm_tone: str | None = None, + mute: str | None = None, + ): + """Write alarm volume/tone values through the APK config shadow.""" + self._validate_volume(volume) + values = {"alarmVol": str(volume)} + if alarm_tone is not None: + values["alarmTone"] = alarm_tone + if mute is not None: + values["mute"] = mute + shadow = "infoBase" if isinstance(entity, Station) else "infoDev" + station, data = self.build_config_state(entity, shadow, values) + return await self.do_thing(station, f"2nd_cfg_{entity.sn}", data) + + async def set_voice_volume(self, station: Station, volume: int): + """Write station voice volume through the APK config shadow.""" + self._validate_volume(volume) + station, data = self.build_config_state( + station, "infoBase", {"voiceVol": str(volume)} + ) + return await self.do_thing(station, f"2nd_cfg_{station.sn}", data) + + async def set_station_mode( + self, station: Station, safe_mode: str, force_arm: str | None = None + ): + """Write station arm/disarm safe mode through the APK app-mode shadow.""" + values = { + "userParam": "source=1", + "source": "1", + "safeMode": safe_mode, + } + if force_arm is not None: + values["forceArm"] = force_arm + station, data = self.build_command_state( + station, + "appMode", + values, + include_device=False, + include_time=False, + ) + return await self.do_thing(station, "2nd_appmode", data) + + async def trigger_sos(self, station: Station, sos_type: str = "1"): + """Trigger station SOS through the APK shadow command.""" + station, data = self.build_command_state( + station, + "sosDown", + {"userParam": "source=1", "sosType": sos_type}, + include_device=False, + ) + return await self.do_thing(station, "2nd_sosdown", data) + + async def cancel_sos(self, station: Station): + """Cancel station SOS through the APK shadow command.""" + station, data = self.build_command_state( + station, "sosDown", {"sosStatus": "0"}, include_device=False + ) + return await self.do_thing(station, "sosdown", data) + + async def cancel_alarm(self, station: Station): + """Cancel an active station alarm through the APK shadow command.""" + station, data = self.build_command_state( + station, + "alarmCancel", + {"cancelTime": self._utc_timestamp()}, + include_device=False, + include_time=False, + ) + return await self.do_thing(station, "alarmcancel", data) + + async def set_fire_drill( + self, + entity: Entity, + drill: bool | str = True, + drill_time: str | None = None, + alarm_type: str | None = None, + alarm_vol: str | None = None, + alarm_tone: str | None = None, + location: str | None = None, + stop_reason: str | None = None, + ): + """Write fire-drill command values through the APK shadow.""" + values = { + "drill": self._bool_value(drill), + "drillTime": drill_time, + "alarmType": alarm_type, + "alarmVol": alarm_vol, + "alarmTone": alarm_tone, + "location": location, + "stopReason": stop_reason, + } + if not isinstance(entity, Station): + values["deviceType"] = entity.type + station, data = self.build_command_state( + entity, + "appFireDrill", + values, + include_device=not isinstance(entity, Station), + ) + return await self.do_thing(station, "2nd_firedrill", data) + + async def set_sos_sound(self, station: Station, sos_sound: str): + """Write station SOS sound through the APK shadow command.""" + station, data = self.build_command_state( + station, + "sosParam", + {"userParam": "source=1", "sosSound": sos_sound}, + include_device=False, + ) + return await self.do_thing(station, "2nd_sosparam", data) + + async def activate_device(self, entity: Entity): + """Activate a device through the APK activation shadow.""" + station, data = self.build_command_state( + entity, "app2ndActivate", {"activate": "1"}, include_device=True + ) + return await self.do_thing(station, "2nd_appactivate", data) + + async def set_install_guide_test( + self, + entity: Entity, + active: bool | str = True, + dev_type: str | None = None, + test_time: str = "180", + detc_sens: str | None = None, + ): + """Write install-guide test values through the APK shadow.""" + values = { + "devType": dev_type or entity.type, + "test": self._bool_value(active), + "testTime": test_time, + "detcSens": detc_sens, + } + station, data = self.build_command_state( + entity, "appInstallGuide", values, include_device=True + ) + return await self.do_thing(station, "2nd_appinstallguide", data) - res = await self.do_thing(station, topic, data) + async def signal_test( + self, + entity: Entity, + dev_type: str | None = None, + test: bool | str = True, + test_time: str = "5", + ): + """Write RF signal-test values through the APK shadow.""" + station, data = self.build_command_state( + entity, + "signalTest", + { + "devType": dev_type or entity.type, + "test": self._bool_value(test), + "testTime": test_time, + }, + include_device=True, + ) + return await self.do_thing(station, f"2nd_signaltest_{entity.sn}", data) - async def action(self, entity: Entity, action: str): - entity_def = entities.get(entity.type) - if not entity_def: - raise XSenseError(f'Entity type {entity.type} is unkown, action {action} not possible') + async def set_motion_test( + self, entity: Entity, active: bool | str = True, dev_type: str = "SMS01" + ): + """Write motion-test values through the APK shadow.""" + station, data = self.build_command_state( + entity, + "testIR", + {"devType": dev_type, "testIR": self._bool_value(active)}, + include_device=True, + include_time=False, + include_user=False, + ) + return await self.do_thing(station, "testir", data) + + async def set_light_power(self, entity: Entity, on: bool | str): + """Compatibility wrapper for the APK light power command.""" + return await self.update_light_power(entity, self._bool_value(on) == "1") + + async def set_light_group_power( + self, + station: Station, + group_id: str, + device_sns: list[str], + on: bool | str, + timeout: str = "180", + ): + """Write light group power through the APK group shadow.""" + station, data = self.build_command_state( + station, + "groupLampPower", + { + "userParam": "source=1", + "timeOut": timeout, + "groupId": group_id, + "devs": device_sns, + "isOn": self._bool_value(on), + }, + include_device=False, + ) + return await self.do_thing(station, "2nd_grouppower", data) + + async def mute_water( + self, + entity: Entity, + set_type: str = "0", + silence_time: str = "", + trigger_source: str | None = None, + ): + """Write water-alarm mute values through the APK shadow.""" + values = { + "setType": set_type, + "silenceTime": silence_time, + "triggerSource": trigger_source, + } + station, data = self.build_command_state( + entity, "appWater", values, include_device=True + ) + return await self.do_thing(station, "2nd_appwater", data) + + async def mute_temperature_humidity( + self, entity: Entity, mute_type: str = "1", sensor_type: str | None = None + ): + """Write temperature/humidity mute values through the APK shadow.""" + station, data = self.build_command_state( + entity, + "extendMute", + {"muteType": mute_type, "type": sensor_type or entity.type}, + include_device=True, + ) + return await self.do_thing(station, "2nd_appmute", data) + + async def mute_driveway( + self, entity: Entity, mute: bool | str = True, topic: str = "2nd_driveway" + ): + """Write driveway alarm mute values through the APK shadow.""" + station, data = self.build_command_state( + entity, + "appDriveway", + {"mute": self._bool_value(mute)}, + include_device=True, + ) + return await self.do_thing(station, topic, data) + + async def update_light_power(self, entity: Entity, enabled: bool): + """Write an SBS50 light power change through the app light shadows.""" + station = getattr(entity, "station", entity) + desired = { + "isOn": "1" if enabled else "0", + "time": datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S"), + "userId": self.userid, + "userParam": "source=1", + } + if entity.type == "group-L": + desired.update( + { + "devs": entity.data.get("devs") or [], + "groupId": entity.data.get("groupId"), + "shadow": "groupLampPower", + "stationSN": station.sn, + "timeOut": "180", + } + ) + return await self.do_thing( + station, "2nd_grouppower", {"state": {"desired": desired}} + ) - action_def = next((a for a in entity_def.get('actions', []) if a.get('action') == action), None) + desired.update( + { + "dev": entity.sn, + "shadow": "lampPower", + "stationSN": station.sn, + } + ) + return await self.do_thing( + station, "2nd_lamppower", {"state": {"desired": desired}} + ) + + async def update_shadow_volume(self, entity: Entity, data_key: str, value: int): + """Write a volume value through the same settings shadow as the app.""" + return await self.update_shadow_setting(entity, data_key, value) + + async def update_shadow_setting(self, entity: Entity, data_key: str, value): + """Write a non-camera setting through the same settings shadow as the app.""" + station = getattr(entity, "station", entity) + desired = { + "shadow": "infoBase" if entity is station else "infoDev", + data_key: str(value), + } + + if data_key in {"alarmVol", "voiceVol", "alarmTone"}: + if _volume_includes_station_sn(entity, data_key): + desired["stationSN"] = station.sn + + if entity is station: + if entity.type == "SBS10": + if data_key != "voiceVol" and "voiceVol" in entity.data: + desired["voiceVol"] = str(entity.data["voiceVol"]) + if data_key != "alarmVol" and "alarmVol" in entity.data: + desired["alarmVol"] = str(entity.data["alarmVol"]) + if data_key != "alarmTone" and "alarmTone" in entity.data: + desired["alarmTone"] = str(entity.data["alarmTone"]) + else: + desired["deviceSN"] = entity.sn + + if data_key == "alarmVol": + if alarm_tone := entity.data.get("alarmTone"): + desired["alarmTone"] = str(alarm_tone) + if data_key == "alarmTone": + if alarm_vol := entity.data.get("alarmVol"): + desired["alarmVol"] = str(alarm_vol) + else: + if entity is station: + desired["stationSN"] = station.sn + else: + desired["deviceSN"] = entity.sn + if _shadow_setting_includes_station_sn(entity, data_key): + desired["stationSN"] = station.sn + if data_key == "tempUnit": + desired["changeUnit"] = "1" + companion_key = _shadow_setting_companion_key(data_key) + if companion_key and (companion := entity.data.get(companion_key)): + desired[companion_key] = str(companion) + + return await self.do_thing( + station, _shadow_config_topic(entity), {"state": {"desired": desired}} + ) + + async def update_light_setting( + self, + entity: Entity, + data_key: str, + value, + *, + on_event: str, + ): + """Write an SBS50 light setting through the APK light shadow path.""" + station = getattr(entity, "station", entity) + desired = { + "shadow": "infoDev", + "deviceSN": entity.sn, + data_key: str(value), + "onEvent": on_event, + } + + return await self.do_thing( + station, f"2nd_cfg_{entity.sn}", {"state": {"desired": desired}} + ) + + async def update_light_scene(self, entity: Entity, scene: str): + """Write the SBS50 light scene payload used by the APK.""" + station = getattr(entity, "station", entity) + desired = { + "shadow": "infoDev", + "deviceSN": entity.sn, + "lightScene": str(scene), + "onEvent": "1", + } + if str(scene) == "1": + desired["pirEnable"] = "1" + desired["awaitEnable"] = "0" + elif str(scene) == "2": + desired["pirEnable"] = "0" + desired["awaitEnable"] = "1" + else: + desired["pirEnable"] = "1" + desired["awaitEnable"] = "1" + + return await self.do_thing( + station, f"2nd_cfg_{entity.sn}", {"state": {"desired": desired}} + ) + + async def query_light_schedules(self, entity: Entity): + """Query SBS50 light schedules through the same REST API as the APK.""" + station = getattr(entity, "station", entity) + data = await self.api_call( + "405105", + stationId=station.entity_id, + deviceId=entity.entity_id, + ) + return light_schedule_list(data) + + async def create_light_schedule( + self, + entity: Entity, + *, + name: str, + start_time: str, + end_time: str, + week_days: list[str], + enabled: bool, + time_zone: str, + ): + """Create an SBS50 light schedule through the APK schedule API.""" + station = getattr(entity, "station", entity) + return await self.api_call( + "405101", + stationId=station.entity_id, + schedName=name, + deviceIds=[entity.entity_id], + timeZone=time_zone, + startTime=schedule_time(start_time), + endTime=schedule_time(end_time), + isEnable="1" if enabled else "0", + weekDays=schedule_week_days(week_days), + newTimeZoneMode="1", + ) + + async def update_light_schedule( + self, + entity: Entity, + *, + schedule_id: str, + start_time: str, + end_time: str, + week_days: list[str], + enabled: bool, + time_zone: str, + ): + """Update an SBS50 light schedule through the APK schedule API.""" + station = getattr(entity, "station", entity) + return await self.api_call( + "405103", + stationId=station.entity_id, + schedId=schedule_id, + deviceId=entity.entity_id, + timeZone=time_zone, + startTime=schedule_time(start_time), + endTime=schedule_time(end_time), + isEnable="1" if enabled else "0", + weekDays=schedule_week_days(week_days), + newTimeZoneMode="1", + ) + + async def rename_light_schedule( + self, + entity: Entity, + *, + schedule_id: str, + name: str, + ): + """Rename an SBS50 light schedule through the APK schedule API.""" + station = getattr(entity, "station", entity) + return await self.api_call( + "405102", + stationId=station.entity_id, + schedId=schedule_id, + schedName=name, + ) + + async def delete_light_schedule(self, entity: Entity, *, schedule_id: str): + """Delete an SBS50 light schedule through the APK schedule API.""" + station = getattr(entity, "station", entity) + return await self.api_call( + "405104", + stationId=station.entity_id, + schedId=schedule_id, + deviceId=entity.entity_id, + ) + + async def query_light_groups(self, entity: Entity): + """Query SBS50 light groups through the same REST API as the APK.""" + station = getattr(entity, "station", entity) + data = await self.api_call("405001", stationId=station.entity_id) + return light_group_list(data) + + async def create_light_group( + self, + entity: Entity, + *, + name: str, + ): + """Create an SBS50 light group through the APK group API.""" + station = getattr(entity, "station", entity) + return await self.api_call( + "405002", + stationId=station.entity_id, + groupName=name, + ) + + async def rename_light_group( + self, + entity: Entity, + *, + group_id: str, + name: str, + ): + """Rename an SBS50 light group through the APK group API.""" + station = getattr(entity, "station", entity) + return await self.api_call( + "405003", + stationId=station.entity_id, + groupId=group_id, + groupName=name, + ) + + async def update_light_group_timer( + self, + entity: Entity, + *, + group_id: str, + data_key: str, + value: str, + ): + """Update an SBS50 light group timer through the APK group API.""" + station = getattr(entity, "station", entity) + payload = { + "stationId": station.entity_id, + "groupId": group_id, + data_key: value, + "onEvent": "1" if data_key == "pirTime" else "2", + } + return await self.api_call("405004", **payload) + + async def bind_light_group( + self, + entity: Entity, + *, + name: str, + device_ids: list[str], + ): + """Add SBS50 lights to a group through the APK group API.""" + station = getattr(entity, "station", entity) + return await self.api_call( + "405005", + stationId=station.entity_id, + groupName=name, + deviceIds=non_empty_strings(device_ids, "group device list"), + ) + + async def delete_light_group(self, entity: Entity, *, group_id: str): + """Delete an SBS50 light group through the APK group API.""" + station = getattr(entity, "station", entity) + return await self.api_call( + "405006", + stationId=station.entity_id, + groupId=group_id, + ) + + async def remove_light_group_devices( + self, + entity: Entity, + *, + device_ids: list[str], + ): + """Remove SBS50 lights from their group through the APK group API.""" + station = getattr(entity, "station", entity) + return await self.api_call( + "405007", + stationId=station.entity_id, + deviceIds=non_empty_strings(device_ids, "group device list"), + ) + + async def update_co_pre_alarm( + self, + entity: Entity, + *, + enabled: bool | None = None, + period: int | str | None = None, + ): + """Write CO low pre-alarm settings through the APK warn-period shadow.""" + station = getattr(entity, "station", entity) + desired = { + "shadow": "appWarnPerion", + "deviceSN": entity.sn, + "stationSN": station.sn, + "time": datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S"), + "userId": self.userid, + } + if enabled is not None: + desired["warnIsOpen"] = "1" if enabled else "0" + if period is not None: + desired["warnPeriod"] = str(period) + + return await self.do_thing( + station, "2nd_warnperiod", {"state": {"desired": desired}} + ) + + async def update_shadow_array_setting( + self, + entity: Entity, + data_key: str, + values: list[float], + *, + comfort_type: str | None = None, + ): + """Write paired range settings through the same array payload as the app.""" + updates = {data_key: values} + return await self.update_shadow_settings( + entity, updates, comfort_type=comfort_type + ) + + async def update_shadow_settings( + self, + entity: Entity, + updates: Dict, + *, + comfort_type: str | None = None, + ): + """Write multiple non-camera settings in one APK-style shadow payload.""" + station = getattr(entity, "station", entity) + desired = { + "shadow": "infoDev", + "deviceSN": entity.sn, + "stationSN": station.sn, + } + desired.update(updates) + if comfort_type is not None: + desired["comfortType"] = comfort_type + + return await self.do_thing( + station, _shadow_config_topic(entity), {"state": {"desired": desired}} + ) + + async def action(self, entity: Entity, action: str): + action_def = self.action_definition(entity, action) if not action_def: - raise XSenseError(f'Action {action} is not supported for entity type {entity.type}') + raise XSenseError( + f"Action {action} is not supported for entity type {entity.type}" + ) - topic = action_def.get('topic') + topic = action_def.get("topic") if callable(topic): topic = topic(entity) - await self.set_state(entity, action_def['shadow'], topic, action_def) + shadow = action_def["shadow"] + if callable(shadow): + shadow = shadow(entity) + return await self.set_state(entity, shadow, topic, action_def) + + +def _url_scheme(url: str | None) -> str | None: + """Return the URL scheme without logging or exposing the full URL.""" + if not isinstance(url, str) or "://" not in url: + return None + return url.split("://", 1)[0].lower() + + +def _camera_live_url(data: Dict) -> str | None: + """Return the live URL from the APK LiveResponse data model.""" + live_url = data.get("liveUrl") or data.get("url") + if not isinstance(live_url, str) or not live_url: + return None + return live_url + + +def _shadow_config_topic(entity: Entity) -> str: + """Return the settings shadow topic used by the X-Sense app.""" + station = getattr(entity, "station", None) + if entity.type == "SBS50": + return f"2nd_cfg_{entity.sn}" + if station and station.type == "SBS50": + return f"2nd_cfg_{entity.sn}" + return f"info_{entity.sn}" + + +def _volume_includes_station_sn(entity: Entity, data_key: str) -> bool: + """Return if the APK volume payload includes stationSN for this entity.""" + if data_key == "voiceVol": + return True + if data_key == "alarmTone": + if entity is getattr(entity, "station", entity): + return True + data_key = "alarmVol" + if getattr(entity, "entity_type", None) in { + EntityType.CO, + EntityType.LIGHT, + EntityType.TEMPERATURE, + }: + return False + return True + + +def _shadow_setting_companion_key(data_key: str) -> str | None: + """Return the companion setting the app preserves with paired updates.""" + return { + "alarmVol": "alarmTone", + "alarmTone": "alarmVol", + "chirpVol": "chirpTone", + "chirpTone": "chirpVol", + "remindVol": "remindTone", + "remindTone": "remindVol", + }.get(data_key) + + +def _shadow_setting_includes_station_sn(entity: Entity, data_key: str) -> bool: + """Return if an APK settings payload includes stationSN for this field.""" + return ( + data_key in {"tempUnit", "tAdjust", "hAdjust"} + and getattr(entity, "entity_type", None) == EntityType.TEMPERATURE + ) + + +def _action_timestamp(definition: Dict, entity: Entity) -> str | None: + time_format = definition.get("time_format", "datetime") + if callable(time_format): + time_format = time_format(entity) + if time_format is None: + return None + if time_format == "epoch_ms": + return str(int(datetime.now(timezone.utc).timestamp() * 1000)) + return datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S") + + +def _short_id(value): + """Return a short diagnostic id without logging full serial-like values.""" + if value in (None, ""): + return None + text = str(value) + return text if len(text) <= 6 else f"...{text[-6:]}" + + +def _action_debug_context(entity: Entity, action: str, target, topic, desired: Dict): + """Return safe action metadata without full serials or payload values.""" + station = getattr(entity, "station", entity) + return { + "action": action, + "device": _short_id(getattr(entity, "sn", None)), + "device_type": getattr(entity, "type", None), + "station": _short_id(getattr(station, "sn", None)), + "station_type": getattr(station, "type", None), + "target": _short_id(getattr(target, "shadow_name", None)), + "topic": topic, + "shadow": desired.get("shadow"), + "has_time": "time" in desired, + "has_user_param": "userParam" in desired, + } + + +def _camera_write_debug_context(camera: Entity, endpoint: str, updates: Dict): + """Return safe camera write metadata without full serials or payload values.""" + return { + "endpoint": endpoint, + "device": _short_id(getattr(camera, "sn", None)), + "device_type": getattr(camera, "type", None), + "fields": sorted(str(key) for key in updates), + } + + +def _ipc_node_type(mqtt_region: str | None) -> str: + """Return the IPC node type from the APK current-house MQTT region.""" + if not mqtt_region or len(mqtt_region) <= 2: + return "US" + node_type = mqtt_region[:2].upper() + return node_type if node_type in {"CN", "EU", "US"} else "US" + + +def _ipc_language(language: str | None) -> str: + """Return the simple app language code the APK sends to IPC registration.""" + if not language: + return "en" + normalized = str(language).strip().replace("_", "-") + if not normalized: + return "en" + return normalized.split("-", 1)[0].lower() + + +def _camera_type(data: Dict) -> str | None: + device_model = data.get("deviceModel") or {} + for value in ( + data.get("modelNo"), + data.get("displayModelNo"), + device_model.get("modelName"), + ): + model = str(value or "").strip().upper() + if model: + return model + return None + + +def _camera_data(data: Dict) -> Dict: + device_model = data.get("deviceModel") or {} + device_support = data.get("deviceSupport") or {} + sd_card = data.get("sdCard") or {} + person_detect_support = _first_present( + device_model.get("devicePersonDetect"), + device_support.get("devicePersonDetect"), + device_support.get("supportPersonDetect"), + data.get("devicePersonDetect"), + data.get("supportPersonDetect"), + data.get("personDetectSupport"), + ) + + return { + "activatedTime": data.get("activatedTime"), + "antiflickerSupport": data.get("antiflickerSupport"), + "awake": data.get("awake"), + "batteryLevel": data.get("batteryLevel"), + "cameraModel": data.get("displayModelNo") or device_model.get("modelName"), + "cameraStatusCode": data.get("statusCode"), + "codec": data.get("codec"), + "defaultCodec": data.get("defaultCodec"), + "deviceDormancyMessage": data.get("deviceDormancyMessage"), + "deviceDormancyWakeTime": data.get("deviceDormancyWakeTime"), + "deviceStatus": data.get("deviceStatus"), + "firmwareStatus": data.get("firmwareStatus"), + "firmwareVersion": data.get("firmwareId"), + "ip": data.get("ip"), + "isAdmin": ( + data.get("userId") == data.get("adminId") + if data.get("userId") is not None and data.get("adminId") is not None + else data.get("isAdmin") + ), + "isCharging": data.get("isCharging"), + "isMoved": data.get("isMoved"), + "liveAudioToggleOn": data.get("liveAudioToggleOn"), + "modelNo": data.get("modelNo"), + "networkName": data.get("networkName"), + "offlineTime": data.get("offlineTime"), + "online": data.get("online"), + "recResolution": data.get("recResolution"), + "sdCardFormatStatus": sd_card.get("formatStatus"), + "sdCardTotal": sd_card.get("total"), + "sdCardUsed": sd_card.get("used"), + "signalStrength": data.get("signalStrength"), + "showCodecChange": data.get("showCodecChange"), + "streamProtocol": device_model.get("streamProtocol"), + "supportAntiFlicker": _addx_bool(data.get("antiflickerSupport")), + "supportAlarm": _addx_bool(device_support.get("deviceSupportAlarm")), + "supportAlarmVolume": _addx_bool(device_support.get("supportAlarmVolume")), + "supportBattery": _addx_bool(device_model.get("canStandby")), + "supportChargeAutoPowerOn": _addx_bool( + device_support.get("supportChargeAutoPowerOn") + ), + "supportCryDetect": _addx_bool(device_support.get("supportCryDetect")), + "supportDeviceCall": _addx_bool(device_support.get("supportDeviceCall")), + "supportDoorBellAlarm": _addx_bool( + device_support.get("supportAlarmWhenRemoveToggle") + ), + "supportLiveAudio": _addx_bool(device_support.get("supportLiveAudioToggle")), + "supportLight": _addx_bool(device_model.get("whiteLight")), + "supportMechanicalDingDong": _addx_bool( + device_support.get("supportMechanicalDingDong") + ), + "supportMirrorFlip": _addx_bool(device_support.get("deviceSupportMirrorFlip")), + "supportMotionTrack": _addx_bool(device_model.get("supportMotionTrack")), + "supportPersonDetect": _addx_bool(person_detect_support), + "supportPirCooldown": _addx_bool(device_support.get("supportPirCooldown")), + "supportRecLamp": _addx_bool(device_support.get("supportRecLamp")), + "supportRecordingAudio": _addx_bool( + device_support.get("supportRecordingAudioToggle") + ), + "supportRocker": _addx_bool(device_model.get("canRotate")), + "supportSdCard": bool(sd_card) and sd_card.get("formatStatus") != 23, + "supportSleep": device_support.get("deviceDormancySupport") == 1, + "supportLiveSpeakerVolume": _addx_bool( + device_support.get("supportLiveSpeakerVolume") + ), + "supportedRecordingResolutions": device_support.get("deviceSupportResolution"), + "supportVoiceVolume": _addx_bool(device_support.get("supportVoiceVolume")), + "supportWebrtc": _addx_bool(device_support.get("supportWebrtc")), + "thumbImgTime": data.get("thumbImgTime"), + "thumbImgUrl": data.get("thumbImgUrl"), + "timeZone": data.get("timeZone"), + "timeZoneArea": data.get("timeZoneArea"), + "wifiChannel": data.get("wifiChannel"), + "wiredMacAddress": data.get("wiredMacAddress"), + } + + +_CAMERA_USER_CONFIG_KEYS = ( + "alarmSeconds", + "alarmVolume", + "antiflicker", + "antiflickerSwitch", + "chargeAutoPowerOnCapacity", + "chargeAutoPowerOnSwitch", + "cryDetect", + "cryDetectLevel", + "deviceCallToggleOn", + "deviceLanguage", + "devicePersonDetect", + "mechanicalDingDongDuration", + "mechanicalDingDongSwitch", + "mirrorFlip", + "motionSensitivity", + "motionTrack", + "motionTrackMode", + "needAlarm", + "needMotion", + "needNightVision", + "needVideo", + "nightThresholdLevel", + "nightVisionMode", + "recLamp", + "timeZone", + "timeZoneArea", + "videoSeconds", + "voiceVolume", + "voiceVolumeSwitch", + "whiteLightScintillation", +) + +_CAMERA_BOOLEAN_USER_CONFIG_KEYS = {"deviceCallToggleOn"} + + +def _camera_user_config_payload(camera: Entity, updates: Dict) -> Dict: + """Return the APK UserConfigBean-style camera config payload.""" + payload = {"serialNumber": camera.sn} + payload.update( + { + key: _camera_config_payload_value(key, value) + for key, value in updates.items() + if key in _CAMERA_USER_CONFIG_KEYS and value is not None + } + ) + _add_camera_config_companions(camera, payload) + return payload + + +def _add_camera_config_companions(camera: Entity, payload: Dict) -> None: + """Add companion config fields the APK sends with selected toggles.""" + if "needMotion" in payload and camera.data.get("motionSensitivity") is not None: + payload["motionSensitivity"] = camera.data["motionSensitivity"] + if "needVideo" in payload and camera.data.get("videoSeconds") == 0: + payload["videoSeconds"] = -1 + if "needAlarm" in payload: + if camera.data.get("supportRocker") is True: + payload["alarmSeconds"] = 10 + elif camera.data.get("alarmSeconds") in (None, 0): + payload["alarmSeconds"] = 5 + else: + payload["alarmSeconds"] = camera.data["alarmSeconds"] + if ( + "needNightVision" in payload + and camera.data.get("nightThresholdLevel") is not None + ): + payload["nightThresholdLevel"] = camera.data["nightThresholdLevel"] + + +def _camera_config_payload_value(key: str, value): + """Return the value type used by the APK UserConfigBean field.""" + if key in _CAMERA_BOOLEAN_USER_CONFIG_KEYS: + return value if isinstance(value, bool) else _addx_bool(value) + if isinstance(value, bool): + return 1 if value else 0 + return value + + +def _camera_config_write_value(key: str, enabled: bool): + """Return the value type used by the APK UserConfigBean field.""" + return _camera_config_payload_value(key, enabled) + + +def _addx_bool(value) -> bool | None: + """Return the APK-style Boolean/int support flag value.""" + if value is None: + return None + if isinstance(value, bool): + return value + if isinstance(value, int): + return value == 1 + return None + + +def _first_present(*values): + """Return the first value that is explicitly present.""" + for value in values: + if value is not None: + return value + return None + + +def _enabled_option_values(options: Any) -> list[Any]: + """Return enabled option values from the APK SettingOptionsResponse shape.""" + if not isinstance(options, list): + return [] + values: list[Any] = [] + for option in options: + if not isinstance(option, dict): + continue + if option.get("enabled") is False: + continue + value = option.get("value") + if value is not None: + values.append(value) + return values + + +def _camera_settings_options_data(data: Dict) -> Dict: + """Return APK camera form options from /user/getFormOptions.""" + form_options = data.get("deviceFormOptions") or {} if isinstance(data, dict) else {} + video_seconds = _enabled_option_values(form_options.get("videoSeconds")) + cooldown = _enabled_option_values(form_options.get("cooldown_in_s")) + result: Dict[str, Any] = {} + if video_seconds: + result["videoSecondsValues"] = video_seconds + if cooldown: + result["cooldownOptions"] = cooldown + return result + + +def _camera_config_data(data: Dict) -> Dict: + cooldown = data.get("cooldown") or {} + return { + "alarmSeconds": data.get("alarmSeconds"), + "alarmVol": data.get("alarmVolume"), + "antiflicker": data.get("antiflicker"), + "antiflickerSwitch": _addx_bool(data.get("antiflickerSwitch")), + "chargeAutoPowerOnCapacity": data.get("chargeAutoPowerOnCapacity"), + "chargeAutoPowerOnCapacityOptions": data.get( + "chargeAutoPowerOnCapacityOptions" + ), + "chargeAutoPowerOnSwitch": _addx_bool(data.get("chargeAutoPowerOnSwitch")), + "cooldownSupported": _addx_bool(cooldown.get("deviceSupport")), + "cooldownEnabled": _addx_bool(cooldown.get("userEnable")), + "cooldownOptions": cooldown.get("notCloseValues") or data.get("coolDownValues"), + "cooldownValue": cooldown.get("value"), + "cryDetect": _addx_bool(data.get("cryDetect")), + "cryDetectLevel": data.get("cryDetectLevel"), + "deviceCallToggleOn": _addx_bool(data.get("deviceCallToggleOn")), + "deviceLanguage": data.get("deviceLanguage"), + "devicePersonDetect": _addx_bool(data.get("devicePersonDetect")), + "deviceSupportLanguage": data.get("deviceSupportLanguage"), + "mechanicalDingDongDuration": data.get("mechanicalDingDongDuration"), + "mechanicalDingDongSwitch": _addx_bool(data.get("mechanicalDingDongSwitch")), + "mirrorFlip": _addx_bool(data.get("mirrorFlip")), + "motionSensitivity": _camera_motion_sensitivity_value( + data.get("motionSensitivity") + ), + "motionSensitivityOptionList": data.get("motionSensitivityOptionList"), + "motionTrack": _addx_bool(data.get("motionTrack")), + "motionTrackMode": data.get("motionTrackMode"), + "needAlarm": _addx_bool(data.get("needAlarm")), + "needMotion": _addx_bool(data.get("needMotion")), + "needNightVision": _addx_bool(data.get("needNightVision")), + "needVideo": _addx_bool(data.get("needVideo")), + "nightThresholdLevel": data.get("nightThresholdLevel"), + "nightVisionMode": data.get("nightVisionMode"), + "recLamp": _addx_bool(data.get("recLamp")), + "timeZone": data.get("timeZone"), + "timeZoneArea": data.get("timeZoneArea"), + "videoSeconds": _camera_video_seconds_value(data.get("videoSeconds")), + "videoSecondsValues": data.get("videoSecondsValues"), + "voiceVol": data.get("voiceVolume"), + "voiceVolumeSwitch": _addx_bool(data.get("voiceVolumeSwitch")), + "whiteLightScintillation": _addx_bool(data.get("whiteLightScintillation")), + } + + +def _camera_motion_sensitivity_value(value): + """Return the APK camera motion sensitivity value.""" + return value + + +def _camera_video_seconds_value(value): + """Return the APK default for unset camera recording duration.""" + return -1 if value in (None, 0) else value + + +def _camera_audio_data(data: Dict) -> Dict: + audio = data.get("deviceAudio") or data + ring_keys = audio.get("supportDoorBellRingKey") or [] + return { + "doorBellRingKey": audio.get("doorBellRingKey"), + "doorBellRingKeyOptions": [ + ring_key.get("id") + for ring_key in ring_keys + if isinstance(ring_key, dict) and ring_key.get("id") is not None + ], + "liveAudioToggleOn": _addx_bool(audio.get("liveAudioToggleOn")), + "liveSpeakerVolume": audio.get("liveSpeakerVolume"), + "recordingAudioToggleOn": _addx_bool(audio.get("recordingAudioToggleOn")), + } + + +def _camera_doorbell_data(data: Dict) -> Dict: + doorbell_config = data.get("doorbellConfig") or data + return { + "alarmWhenRemoveToggleOn": _addx_bool( + doorbell_config.get("alarmWhenRemoveToggleOn") + ), + } + + +def _camera_ai_notification_data(data: Dict) -> Dict: + """Return AI notification category settings from the APK response.""" + raw_items = data.get("list") + if isinstance(data.get("data"), dict): + raw_items = data["data"].get("list", raw_items) + if not isinstance(raw_items, list): + return {} + + enabled: set[str] = set() + supported: set[str] = set() + for item in raw_items: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + group_types = _CAMERA_AI_NOTIFICATION_GROUPS.get(name) + if group_types is None: + continue + + sub_events = item.get("subEvent") + if isinstance(sub_events, list) and sub_events: + item_types = [] + for sub_event in sub_events: + if not isinstance(sub_event, dict): + continue + sub_name = str(sub_event.get("name") or "").strip() + if sub_name in group_types: + item_types.append(sub_name) + supported.add(sub_name) + if sub_event.get("choice") is True: + enabled.add(sub_name) + else: + item_types = list(group_types) + supported.update(item_types) + if item.get("choice") is True: + enabled.update(item_types) + + result = _camera_ai_notification_state_data(enabled) + result["aiNotificationSupportedTypes"] = sorted(supported) + return result + + +def _camera_ai_notification_enabled(data: Dict) -> set[str]: + """Return currently enabled AI notification categories from entity data.""" + return { + event_type + for event_type in CAMERA_AI_NOTIFICATION_TYPES + if data.get(_camera_ai_notification_key(event_type)) is True + } + + +def _camera_ai_notification_payload(enabled: set[str]) -> Dict: + """Return the APK updateMessageNotification eventObjectType payload.""" + payload: dict[str, list[str]] = {"vehicle": [], "package": []} + for group, group_types in _CAMERA_AI_NOTIFICATION_GROUPS.items(): + selected = [event_type for event_type in group_types if event_type in enabled] + payload_key = _CAMERA_AI_NOTIFICATION_PAYLOAD_KEYS[group] + if group in {"vehicle", "package"}: + payload[payload_key] = selected + elif selected: + payload[payload_key] = [] + return payload + + +def _camera_ai_notification_state_data(enabled: set[str]) -> Dict: + """Return flat entity data for AI notification category settings.""" + return { + _camera_ai_notification_key(event_type): event_type in enabled + for event_type in CAMERA_AI_NOTIFICATION_TYPES + } + + +def _camera_ai_notification_key(event_type: str) -> str: + """Return the entity data key for an AI notification category.""" + return f"aiNotification{_camel_suffix(event_type)}" + + +def _camera_ai_assistant_data(data: Dict, serial_number: str | None = None) -> Dict: + """Return AI assistant object switches from the APK response.""" + raw_items = data.get("data", data) + if isinstance(raw_items, dict): + raw_items = raw_items.get("data") or raw_items.get("list") or [raw_items] + if not isinstance(raw_items, list): + return {} + + selected_device = None + for item in raw_items: + if not isinstance(item, dict): + continue + if serial_number is None or str(item.get("serialNumber")) == str(serial_number): + selected_device = item + break + if selected_device is None: + return {} + + object_list = selected_device.get("list") + if not isinstance(object_list, list): + return {} + + result: Dict[str, bool] = {} + supported: list[str] = [] + for item in object_list: + if not isinstance(item, dict): + continue + event_object = str(item.get("eventObject") or "").strip() + if event_object not in CAMERA_AI_ASSISTANT_TYPES: + continue + supported.append(event_object) + result[_camera_ai_assistant_key(event_object)] = item.get("checked") is True + if supported: + result["aiAssistantSupportedTypes"] = supported + return result + + +def _camera_ai_assistant_key(event_object: str) -> str: + """Return the entity data key for an AI assistant object switch.""" + return f"aiAssistant{_camel_suffix(event_object)}" + + +def _camel_suffix(value: str) -> str: + """Return PascalCase for snake-style APK object names.""" + return "".join(part.capitalize() for part in value.split("_") if part) diff --git a/xsense/aws_signer.py b/xsense/aws_signer.py index a49227f..1bc30c6 100644 --- a/xsense/aws_signer.py +++ b/xsense/aws_signer.py @@ -87,7 +87,7 @@ def sign_headers( # calculate content hash if content: - if isinstance(content, Dict): + if isinstance(content, dict): content = json.dumps(content, sort_keys=True) content_hash = hashlib.sha256(content.encode("utf-8")).hexdigest() diff --git a/xsense/base.py b/xsense/base.py index 433b47e..c252237 100644 --- a/xsense/base.py +++ b/xsense/base.py @@ -1,4 +1,5 @@ import base64 +import binascii import hashlib import hmac import json @@ -6,23 +7,88 @@ from typing import Dict import boto3 -from botocore.exceptions import ClientError +from botocore.config import Config +from botocore.exceptions import BotoCoreError, ClientError from pycognito import AWSSRP from .entity import Entity from .entity_map import entities -from .exceptions import AuthFailed +from .exceptions import APIFailure, AuthFailed from .station import Station from .house import House +def _mac_json(value) -> str: + """Return compact Gson-style JSON for X-Sense MAC input.""" + return json.dumps(value, ensure_ascii=False, separators=(',', ':')) + + +def _mac_scalar(value) -> str: + """Return the Java StringBuilder text used by the app MAC input.""" + if value is None: + return 'null' + if isinstance(value, bool): + return 'true' if value else 'false' + return str(value) + + +def _jwt_claim(token: str | None, claim: str): + if not token: + return None + try: + payload = token.split('.')[1] + padding = '=' * (-len(payload) % 4) + decoded = base64.urlsafe_b64decode(payload + padding) + claims = json.loads(decoded) + except ( + IndexError, + TypeError, + ValueError, + UnicodeDecodeError, + binascii.Error, + json.JSONDecodeError, + ): + return None + value = claims.get(claim) + return str(value) if value is not None else None + + +def shadow_update_body(data: Dict) -> str: + return json.dumps(data, ensure_ascii=False, separators=(',', ':')) + + +_COGNITO_CLIENT_CONFIG = Config( + connect_timeout=15, + read_timeout=15, + retries={'total_max_attempts': 4, 'mode': 'standard'}, +) + + class XSenseBase: API = 'https://api.x-sense-iot.com' - VERSION = "v1.22.0_20240914.1" - APPCODE = "1220" - CLIENTYPE = "1" + VERSION = "v1.36.0_20260130" + APPCODE = "1360" + CLIENTYPE = "2" + IPC_API = 'https://ipc.x-sense-iot.com' + ADDX_API_BY_NODE = { + 'CN': 'https://api.addx.live', + 'EU': 'https://api-eu.vicohome.io', + 'US': 'https://api-us.vicohome.io', + } + IPC_VERSION = VERSION + IPC_APPCODE = APPCODE + IPC_CLIENTTYPE = CLIENTYPE + + ADDX_APP_NAME = 'VicoHome' + ADDX_APP_BUNDLE = 'com.ai.vicoo' + ADDX_APP_CHANNEL_ID = 1000 + ADDX_APP_COUNTLY_ID = 'b940908f19b8e858' + ADDX_APP_TENANT_ID = 'guard' + ADDX_APP_VERSION = 200700500 + ADDX_APP_VERSION_NAME = '2.7.5' userid = None + user_id_code = None username = None clientid = None clientsecret = None @@ -45,14 +111,17 @@ class XSenseBase: def __init__(self): self.houses: Dict[str, House] = {} + self._addx_session = None def _parse_client_error(self, e: ClientError): return e.response.get('Error', {}).get('Message') or str(e) - def sync_login(self, username, password): + def _cognito_login(self, username, password): self.username = username session = boto3.Session() - cognito = session.client('cognito-idp', region_name=self.region) + cognito = session.client( + 'cognito-idp', region_name=self.region, config=_COGNITO_CLIENT_CONFIG + ) aws_srp = AWSSRP( username=username, @@ -74,6 +143,8 @@ def sync_login(self, username, password): ) except ClientError as e: raise AuthFailed(self._parse_client_error(e)) from e + except BotoCoreError as e: + raise APIFailure(f'Cognito connection failed: {e}') from e self.userid = response['ChallengeParameters']['USERNAME'] @@ -92,20 +163,31 @@ def sync_login(self, username, password): auth_result = response['AuthenticationResult'] self.access_token = auth_result['AccessToken'] self.id_token = auth_result['IdToken'] - self.refresh_token =auth_result['RefreshToken'] + self.refresh_token = auth_result['RefreshToken'] + self._set_user_id_code_from_tokens() self.access_token_expiry = datetime.now(timezone.utc) + timedelta(seconds=auth_result['ExpiresIn']) except ClientError as e: raise AuthFailed(self._parse_client_error(e)) from e + except BotoCoreError as e: + raise APIFailure(f'Cognito connection failed: {e}') from e def restore_session(self, username, access_token, refresh_token, id_token): self.username = username self.access_token = access_token self.refresh_token = refresh_token self.id_token = id_token + self._set_user_id_code_from_tokens() self.access_token_expiry = datetime.now(timezone.utc) self.aws_access_expiry = datetime.now(timezone.utc) + def _set_user_id_code_from_tokens(self): + self.user_id_code = None + for token in (self.id_token, self.access_token): + if user_id_code := _jwt_claim(token, 'user_id_code'): + self.user_id_code = user_id_code + return + def _access_token_expiring(self): return datetime.now(timezone.utc) > self.access_token_expiry - timedelta(seconds=60) @@ -122,19 +204,30 @@ def _calculate_mac(self, data): for key in data: value = data[key] if isinstance(value, list): - if value and isinstance(value[0], str): - values.extend(value) + if not value: + continue + if isinstance(value[0], str): + values.extend(_mac_scalar(item) for item in value) else: - values.append(json.dumps(value)) + values.append(_mac_json(value)) elif isinstance(value, dict): - values.append(json.dumps(value, separators=(',', ':'))) + values.append(_mac_json(value)) else: - values.append(str(value)) + values.append(_mac_scalar(value)) concatenated_string = ''.join(values) mac_data = concatenated_string.encode('utf-8') + self.clientsecret return hashlib.md5(mac_data).hexdigest() + def _signed_body(self, data: Dict | None, code: str, *, ipc: bool = False) -> Dict: + data = dict(data or {}) + data['mac'] = self._calculate_mac(data) + data['bizCode'] = code + data['appCode'] = self.IPC_APPCODE if ipc else self.APPCODE + data['appVersion'] = self.IPC_VERSION if ipc else self.VERSION + data['clientType'] = self.IPC_CLIENTTYPE if ipc else self.CLIENTYPE + return data + def generate_hash(self, data): return base64.b64encode( hmac.new( @@ -188,14 +281,10 @@ def _thing_request(self, station: Station, page: str, data=None): 'X-Amz-Security-Token': self.aws_session_token } - typename = station.type - if typename in ['SBS10']: - typename = '' - if typename in ('XC04-WX', 'SC07-WX'): - typename += '-' + thing_name = _thing_name(station) host = f'{station.house.mqtt_region}.x-sense-iot.com' - uri = f'/things/{typename}{station.sn}/shadow?name={page}' + uri = f'/things/{thing_name}/shadow?name={page}' url = f'https://{host}{uri}' @@ -212,25 +301,191 @@ def _parse_refresh_result(self, data: Dict): self.access_token = data['AccessToken'] if 'IdToken' in data: self.id_token = data['IdToken'] + self._set_user_id_code_from_tokens() if 'ExpiresIn' in data: self.access_token_expiry = datetime.now(timezone.utc) + timedelta(seconds=data['ExpiresIn']) def parse_get_state(self, station: Station, data: Dict): - if 'wifiRSSI' in data: - station.data['wifiRSSI'] = data['wifiRSSI'] - - station.has_alarm = data.get('activate') == '1' + if isinstance(data, list): + station_data = {} + children = data + else: + station_data = data.copy() + children = station_data.pop('devs', {}) or {} + + if _apply_group_light_state(station, station_data, children): + return + + has_alarm_status = 'alarmStatus' in station_data or 'a' in station_data + if station_data: + station.set_data(station_data) + if 'safeMode' in station_data: + station.safe_mode = station_data['safeMode'] + + station.has_alarm = _is_active_state(station_data.get('activate')) or ( + has_alarm_status and _is_active_state(station.data.get('alarmStatus')) + ) - for sn, i in data.get('devs', {}).items(): - if dev := station.get_device_by_sn(sn): - dev.set_data(i) + for child_key, child_state in _child_state_items(children): + if dev := _state_child_device(station, child_key, child_state): + dev.set_data(child_state) def _parse_get_house_state(self, house: House, data: Dict): for sn, i in data.items(): if station := house.get_station_by_sn(sn): - station.set_data(i) + self.parse_get_state(station, i) + + def station_by_sn(self, serial_number: str | None): + """Return the station with this station serial number.""" + if not serial_number: + return None + for house in self.houses.values(): + if station := house.get_station_by_sn(serial_number): + return station + return None + + def station_by_shadow_name(self, shadow_name: str | None): + """Return the station matching an AWS IoT shadow thing name.""" + if not shadow_name: + return None + for house in self.houses.values(): + for station in house.stations.values(): + if station.shadow_name == shadow_name: + return station + return None + + def station_by_device_sn(self, serial_number: str | None): + """Return the station containing this station or child device serial.""" + if not serial_number: + return None + for house in self.houses.values(): + for station in house.stations.values(): + if station.sn == serial_number or station.get_device_by_sn(serial_number): + return station + return None + + def apply_safe_mode(self, station: Station, safe_mode) -> None: + """Store safeMode consistently for HTTP polling and MQTT updates.""" + station.safe_mode = safe_mode + station.set_data({"safeMode": safe_mode}) + + def action_definition(self, entity: Entity, action: str) -> Dict | None: + """Return the supported action definition for an entity, if resolvable.""" + entity_def = entities.get(entity.type) + if not entity_def: + return None + action_def = next( + (a for a in entity_def.get('actions', []) if a.get('action') == action), + None, + ) + if action_def is None or not _action_route_resolves(entity, action_def): + return None + return action_def def has_action(self, entity: Entity, action: str): - if entity_def := entities.get(entity.type): - return any(a for a in entity_def.get('actions', []) if a.get('action') == action) + return self.action_definition(entity, action) is not None + + +def _action_route_resolves(entity: Entity, action_def: Dict) -> bool: + """Return whether an app shadow action can resolve for this entity context.""" + if not getattr(entity, 'sn', None): + return False + try: + topic = _resolve_action_value(action_def.get('topic'), entity) + shadow = _resolve_action_value(action_def.get('shadow'), entity) + target = _resolve_action_value(action_def.get('target'), entity) + extra = _resolve_action_value(action_def.get('extra', {}), entity) + data = _resolve_action_value(action_def.get('data', {}), entity) + except (AttributeError, TypeError, KeyError, ValueError): + return False + + if not topic or not shadow: + return False + if not isinstance(extra, dict) or not isinstance(data, dict): + return False + + target = target if target is not None else getattr(entity, 'station', entity) + return bool(getattr(target, 'shadow_name', None) or getattr(target, 'sn', None)) + + +def _resolve_action_value(value, entity: Entity): + """Return an action value, resolving callables against the entity.""" + if callable(value): + return value(entity) + return value + + +def _state_child_device(station: Station, child_key, child_state): + """Return the child device targeted by an app shadow payload.""" + for value in _child_state_identifiers(child_key, child_state): + if dev := station.get_device_by_sn(value): + return dev + return None + + +def _child_state_items(children): + """Yield child device shadow records in the list/dict forms used by the app.""" + if isinstance(children, dict): + yield from children.items() + elif isinstance(children, list): + for child_state in children: + if isinstance(child_state, dict): + yield None, child_state + + +def _child_state_identifiers(child_key, child_state) -> tuple[str, ...]: + """Return device serial identifiers used by X-Sense child shadows.""" + values = [child_key] + if isinstance(child_state, dict): + values.extend( + child_state.get(key) + for key in ( + 'deviceSN', + 'deviceSn', + '_deviceSN', + '_deviceSn', + ) + ) + seen = set() + result = [] + for value in values: + if value is None: + continue + text = str(value) + if text and text not in seen: + seen.add(text) + result.append(text) + return tuple(result) + + +def _apply_group_light_state(station: Station, station_data: Dict, children) -> bool: + group_id = station_data.get('groupId') + if group_id is None: + return False + + group = station.get_group_device(group_id) + if group is None: return False + + group_data = station_data.copy() + if 'isOn' in group_data: + group_data['on'] = group_data['isOn'] + if isinstance(children, list): + group_data['devs'] = children + group.set_data(group_data) + return True + + +def _is_active_state(value) -> bool: + if isinstance(value, bool): + return value + if isinstance(value, (int, float)): + return value == 1 + if isinstance(value, str): + return value.strip().lower() in {'1', 'true', 'on', 'active', 'alarm'} + return False + + +def _thing_name(station: Station) -> str: + """Return the AWS IoT thing name used by the X-Sense app.""" + return station.shadow_name diff --git a/xsense/device.py b/xsense/device.py index 20c11d2..d23d66a 100644 --- a/xsense/device.py +++ b/xsense/device.py @@ -1,15 +1,11 @@ -from xsense.entity import Entity +from .entity import Entity class Device(Entity): - def __init__( - self, - station, - **kwargs - ): + def __init__(self, station, **kwargs): self.station = station - self.entity_id = kwargs.get('deviceId') - self.type = kwargs.get('deviceType') - self.name = kwargs.get('deviceName') - self.sn = kwargs.get('deviceSn') + self.entity_id = kwargs.get("deviceId") + self.type = kwargs.get("deviceType") + self.name = kwargs.get("deviceName") + self.sn = kwargs.get("deviceSn") super().__init__(**kwargs) diff --git a/xsense/entity.py b/xsense/entity.py index c8abf18..83f09a5 100644 --- a/xsense/entity.py +++ b/xsense/entity.py @@ -1,5 +1,43 @@ -from xsense.entity_map import entities -from xsense.mapping import map_values +from datetime import datetime, timedelta, timezone + +from .entity_map import entities +from .mapping import bool_state, map_values + + +_ONLINE_TIME_EXCLUDED_TYPES = {"XP0J-iA", "XS0R-iA", "STH0C"} +_EXTENDED_OFFLINE_HOUR_TYPES = {"SWS0B", "XR0A-iR"} + + +def _offline_over_hours(entity_type: str | None) -> int: + return 49 if entity_type in _EXTENDED_OFFLINE_HOUR_TYPES else 34 + + +def _parse_xsense_time(value: str | None) -> datetime | None: + if not value: + return None + + try: + return datetime.strptime(str(value), "%Y%m%d%H%M%S").replace( + tzinfo=timezone.utc + ) + except ValueError: + return None + + +def _online_from_report_time(data: dict, entity_type: str | None) -> bool | None: + if entity_type in _ONLINE_TIME_EXCLUDED_TYPES: + return None + + online_time = data.get("onlineTime") + if not online_time: + return None + + reported = _parse_xsense_time(online_time) + utc_time = _parse_xsense_time(data.get("utcTime")) or datetime.now(timezone.utc) + if reported is None: + return None + + return utc_time <= reported + timedelta(hours=_offline_over_hours(entity_type)) class Entity: @@ -8,27 +46,59 @@ class Entity: _data = None entity_type = None - def __init__( - self, - **kwargs - ): - self.room_id = kwargs.get('roomId') + def __init__(self, **kwargs): + self.online = None + self.room_id = kwargs.get("roomId") self._data = {} + self._online_from_explicit_flag = False entity = entities.get(self.type, {}) - self.entity_type = entity.get('type') + self.entity_type = entity.get("type") + + for key in ("online", "onLine"): + if key in kwargs: + self._set_online(kwargs[key]) + break + + def _set_online(self, value) -> None: + online = bool_state(value) + if online is not None: + self.online = online + self._online_from_explicit_flag = True def set_data(self, values: dict): data = values.copy() - if 'online' in values: - self.online = values.pop('online') != '0' - if values.get('onlineTime'): - self.online = True - data |= data.pop('status', {}) + has_online_flag = False + for key in ("online", "onLine"): + if key in data: + self._set_online(data.pop(key)) + has_online_flag = True + break + if not has_online_flag: + online = _online_from_report_time(data, self.type) + if online is not None: + # The app treats online/onLine as the authoritative connection + # state. Report timestamps can confirm a device is awake, but + # should not turn an explicitly online device offline. + if ( + online + or self.online is not True + or not self._online_from_explicit_flag + ): + self.online = online + self._online_from_explicit_flag = False + status_data = data.get("status") + if isinstance(status_data, dict): + data.pop("status", None) + data.update(status_data) + for nested_key in ("lightShadowBean", "skp0aShadowBean"): + nested_data = data.pop(nested_key, {}) + if isinstance(nested_data, dict): + data.update(nested_data) # software versions are reported differently per device - if 'swMain' in data: - data['network_sw'] = data.get('sw') - data['sw'] = data.pop('swMain', None) + if "swMain" in data: + data["network_sw"] = data.get("sw") + data["sw"] = data.pop("swMain", None) self._data.update(map_values(self.type, data)) @property @@ -37,4 +107,53 @@ def data(self): @property def shadow_name(self): - return f'{self.type}{self.sn}' + """Return the AWS IoT thing name used by the X-Sense app.""" + if self.type == "SBS10": + return self.sn + if self.type in _SBS50_THING_TYPES: + return f"SBS50{self._station_sn()}" + if self.type in _DASHED_THING_TYPES: + return f"{self.type}-{self.sn}" + if self.type == "XS01-WX": + separator = "-" if self._is_xs01wx_v9_serial() else "" + return f"{self.type}{separator}{self.sn}" + return f"{self.type}{self.sn}" + + def _is_xs01wx_v9_serial(self) -> bool: + serial = str(self.sn or "").upper() + return "EN" in serial or "UL" in serial + + def _station_sn(self) -> str: + station = getattr(self, "station", None) + return getattr(station, "sn", None) or self.sn + + +_DASHED_THING_TYPES = { + # Mirrors com.claybox.iot.ams.thing.c0.getThingName() plus per-device + # XsDeviceAlarm.getWiFiThingName() handlers in the Android app. + "SC06-WX", + "SC07-WX", + "STH0C", + "SWS0B", + "XC04-WX", + "XC0C-iA", + "XC0C-iR", + "XC0M-iR", + "XP0A-iR", + "XP0H-iR", + "XP0J-iA", + "XR0A-iR", + "XS0B-iR", + "XS0R-iA", +} + +_SBS50_THING_TYPES = { + "CB0Z-3S", + "LP/N-SA-0B", + "LP/N-SCA-0A", + "SC01-MN", + "SD19-MN", + "SK0Z-3S", + "STH0B", + "XC0C-MR", +} diff --git a/xsense/entity_map.py b/xsense/entity_map.py index 089fdec..0cd5116 100644 --- a/xsense/entity_map.py +++ b/xsense/entity_map.py @@ -1,208 +1,637 @@ from enum import Enum -from typing import Callable, Dict +from typing import Callable, Dict, Optional, Union class EntityType(Enum): - ALARM = 'alarm' + ALARM = "alarm" BASE = "base" - BASESTATION = 'station' + BASESTATION = "station" + CAMERA = "camera" CO = "co" - COMBI = 'combi' - DOOR = 'door' - HEAT = 'heat' - KEYPAD = 'keypad' - MAILBOX = 'mailbox' - MOTION = 'motion' + COMBI = "combi" + DOOR = "door" + HEAT = "heat" + KEYPAD = "keypad" + LIGHT = "light" + LISTENER = "listener" + MAILBOX = "mailbox" + MOTION = "motion" + RADON = "radon" + REMOTE = "remote" + SMARTDROP = "smartdrop" SMOKE = "smoke" TEMPERATURE = "temperature" WATER = "water" -def MuteAction(shadow: str = 'appMute', topic: str|None|Callable = '2nd_appmute', extra: Dict|None=None): +def MuteAction( + shadow: Union[str, Callable] = "appMute", + topic: Union[str, Callable, None] = "2nd_appmute", + extra: Optional[Dict] = None, + mute_type: Optional[str] = None, + target=None, +): data = { - 'action': 'mute', - 'topic': topic, - 'shadow': shadow, + "action": "mute", + "topic": topic, + "shadow": shadow, } if extra: - data['extra'] = extra + data["extra"] = extra + if mute_type is not None: + data.setdefault("extra", {})["muteType"] = mute_type + if target: + data["target"] = target return data -def TestAction(shadow='appSelfTest'): +def TestAction(shadow="appSelfTest", extra: Optional[Dict] = None, target=None): + data = { + "action": "test", + "topic": lambda x: f"2nd_selftest_{x.sn}", + "shadow": shadow, + "time_format": "epoch_ms", + } + if extra: + data["extra"] = extra + if target: + data["target"] = target + return data + + +def SBS50SecondGenTestAction(): + return TestAction("app2ndSelfTest", extra={"userParam": "source=1"}) + + +def XP0JTestAction(): + return TestAction( + "app2ndSelfTest", + extra={"userParam": "source=1"}, + target=lambda entity: _ThingTarget(entity, f"SBS50{entity.sn}"), + ) + + +def _smoke_rf_test_shadow(entity) -> str: + return "app2ndSelfTest" if _is_smoke_v9(entity) else "appSelfTest" + + +def _smoke_rf_test_target(entity): + station = getattr(entity, "station", entity) + if _is_smoke_v9(entity) or getattr(station, "type", None) == "SBS50": + return station + return _ThingTarget(station, station.sn) + + +def _smoke_rf_test_topic(entity): + station = getattr(entity, "station", entity) + if _is_smoke_v9(entity) or getattr(station, "type", None) == "SBS50": + return f"2nd_selftest_{entity.sn}" + return f"appselftest_{entity.sn}" + + +def _smoke_rf_test_time_format(entity) -> str | None: + station = getattr(entity, "station", entity) + if _is_smoke_v9(entity) or getattr(station, "type", None) == "SBS50": + return "epoch_ms" + return None + + +def _smoke_rf_test_extra(entity) -> Dict: + station = getattr(entity, "station", entity) + if _is_smoke_v9(entity) or getattr(station, "type", None) == "SBS50": + return {"userParam": "source=1"} + return {} + + +def SmokeRFTestAction(): return { - 'action': 'test', - 'topic': lambda x: f'2nd_selftest_{x.sn}', - 'shadow': shadow + "action": "test", + "topic": _smoke_rf_test_topic, + "shadow": _smoke_rf_test_shadow, + "extra": _smoke_rf_test_extra, + "target": _smoke_rf_test_target, + "time_format": _smoke_rf_test_time_format, } +class _ThingTarget: + def __init__(self, source, shadow_name: str) -> None: + self.house = getattr(source, "house", None) + self.shadow_name = shadow_name + + +def _xs01_wx_thing_name(station_sn: str) -> str: + separator = "-" if "EN" in station_sn.upper() or "UL" in station_sn.upper() else "" + return f"XS01-WX{separator}{station_sn}" + + +def _wifi_thing_name(device_type: str, station_sn: str) -> str: + if device_type == "XS01-WX": + return _xs01_wx_thing_name(station_sn) + if device_type in {"XS0E-iR", "XS03-WX"}: + return f"{device_type}{station_sn}" + return f"{device_type}-{station_sn}" + + +def _wifi_thing_target(entity): + station = getattr(entity, "station", entity) + return _ThingTarget(station, _wifi_thing_name(entity.type, station.sn)) + + +_WIFI_FIRE_DRILL_TYPES = {"XP0J-iA", "XS0R-iA"} + + +def _fire_drill_target(entity): + if entity.type in _WIFI_FIRE_DRILL_TYPES: + return _wifi_thing_target(entity) + return getattr(entity, "station", entity) + + +def WifiSelfTestAction(): + return TestAction( + "appSelfTest", extra={"userParam": "source=1"}, target=_wifi_thing_target + ) + + +def _fire_drill_alarm_type(entity) -> str: + if entity.type in ("XC01-M", "XC0C-MR"): + return "2" + return "1" + + +def _fire_drill_device_sn(entity) -> str: + station = getattr(entity, "station", entity) + return station.sn + + def FireDrillAction(): return { - 'action': 'firedrill', - 'topic': '2nd_firedrill', - 'shadow': 'appFireDrill' + "action": "firedrill", + "topic": "2nd_firedrill", + "shadow": "appFireDrill", + "target": _fire_drill_target, + "data": lambda entity: { + "alarmTone": "1", + "alarmType": _fire_drill_alarm_type(entity), + "alarmVol": "75", + "deviceSN": _fire_drill_device_sn(entity), + "drill": "1", + "drillTime": "30", + "location": "17", + }, } -def SATestAction(shadow = 'appSelfTest'): +def SATestAction(shadow="appSelfTest"): """Standalone device test.""" return { - 'action': 'test', - 'topic': lambda x: f'appselftest_{x.sn}', - 'shadow': shadow + "action": "test", + "topic": lambda x: f"appselftest_{x.sn}", + "shadow": shadow, + "time_format": None, } +def _is_smoke_v9(entity) -> bool: + try: + return int(entity.data.get("smokeEdition", 0)) >= 9 + except (TypeError, ValueError): + return False + + +def _xs01_wx_target(entity): + station = getattr(entity, "station", entity) + return _ThingTarget(station, _xs01_wx_thing_name(station.sn)) + + +def XS01WXMuteAction(): + return MuteAction( + topic=lambda entity: "2nd_appmute" if _is_smoke_v9(entity) else "appmute", + target=_xs01_wx_target, + mute_type="0", + ) + + +def SC07MRMuteAction(): + return MuteAction( + shadow=lambda entity: "app2ndMute" if _is_smoke_v9(entity) else "appSc07mrMute", + mute_type="1", + extra={"userParam": "source=1"}, + ) + + +def WifiAlarmMuteAction(): + return MuteAction(mute_type="1", target=_wifi_thing_target) + + +def WifiExtendedMuteAction(): + return MuteAction("extendMute", "2nd_appmute", mute_type="1", target=_wifi_thing_target) + + +def WifiWaterMuteAction(): + return MuteAction("appWater", "2nd_appwater", mute_type="1", target=_wifi_thing_target) + + +def SBS50SmokeMuteAction(): + return MuteAction("appMute", "2nd_appmute", mute_type="1") + + +def SBS50CoMuteAction(): + return MuteAction("app2ndMute", "2nd_appmute", mute_type="1") + + +def _station_serial_target(entity): + station = getattr(entity, "station", entity) + return _ThingTarget(station, station.sn) + + +def SmokeRFAppMuteAction(): + return MuteAction("appMute", "appmute", target=_station_serial_target, mute_type="1") + + entities = { - 'SAL51': {}, # listener - 'SAL100': {}, # listener - 'SBS10': { - 'type': EntityType.BASESTATION, - }, - 'SBS50': { - 'type': EntityType.BASESTATION, - 'identifier': lambda entity: f'SBS50{entity.sn}', - }, - # SSC0A - Camera - # SSC0B - 'SC06-WX': { - 'identifier': lambda entity: f'SC06-WX-{entity.sn}', - 'type': EntityType.COMBI, - 'actions': [ - TestAction(), - ], - }, - 'SC07-MR': { - 'identifier': lambda entity: f'SC07-MR-{entity.sn}', - 'type': EntityType.COMBI, - 'actions': [ - ] - }, - 'SC07-WX': { - 'identifier': lambda entity: f'SC07-WX-{entity.sn}', - 'type': EntityType.COMBI, - 'actions': [ - MuteAction('1') - ] - }, - # 'SDA51': {}, - Driveway alarm - 'SDS0A': { - 'type': EntityType.DOOR, - }, - # 'SES01': {}, - Door sensor - # 'SKF01': {}, - Remote Control - 'SKP0A': { - 'type': EntityType.KEYPAD, - }, - 'SMA51': { - 'type': EntityType.MAILBOX, - 'actions': [ + "SAL51": { + "type": EntityType.LISTENER, + "actions": [ + TestAction("listenerSelfTest"), + MuteAction("appListener", mute_type="1"), + ], + }, + "SAL100": { + "type": EntityType.LISTENER, + "actions": [ + TestAction("listenerSelfTest"), + MuteAction("appListener", mute_type="1"), + ], + }, + "SBS10": { + "type": EntityType.BASESTATION, + }, + "SBS50": { + "type": EntityType.BASESTATION, + "identifier": lambda entity: f"SBS50{entity.sn}", + }, + "SSC0A": { + "type": EntityType.CAMERA, + }, + "SSC0B": { + "type": EntityType.CAMERA, + }, + "SC01-MN": { + "type": EntityType.COMBI, + "actions": [ + SBS50SecondGenTestAction(), + MuteAction("app2ndMute", mute_type="1", extra={"userParam": "source=1"}), + FireDrillAction(), + ], + }, + "SC01-MR": { + "type": EntityType.COMBI, + "actions": [ + SBS50SecondGenTestAction(), + MuteAction("appSc07mrMute", mute_type="1", extra={"userParam": "source=1"}), + FireDrillAction(), + ], + }, + "SC06-WX": { + "identifier": lambda entity: f"SC06-WX-{entity.sn}", + "type": EntityType.COMBI, + "actions": [WifiAlarmMuteAction()], + }, + "SC07-MR": { + "identifier": lambda entity: f"SC07-MR-{entity.sn}", + "type": EntityType.COMBI, + "actions": [ + SBS50SecondGenTestAction(), + SC07MRMuteAction(), + FireDrillAction(), + ], + }, + "SC07-WX": { + "identifier": lambda entity: f"SC07-WX-{entity.sn}", + "type": EntityType.COMBI, + "actions": [WifiAlarmMuteAction()], + }, + "SD11-MR": { + "type": EntityType.SMOKE, + "actions": [ + SBS50SecondGenTestAction(), + SBS50SmokeMuteAction(), + FireDrillAction(), + ], + }, + "SD19-MN": { + "type": EntityType.SMOKE, + "actions": [ + SBS50SecondGenTestAction(), + SBS50SmokeMuteAction(), + FireDrillAction(), + ], + }, + "SDA51": { + "type": EntityType.ALARM, + "actions": [ { - 'action': 'mute', - 'topic': lambda x: '2nd_appmailmute', - 'shadow': 'appMailMute', - 'data': {'silenceTime': '', 'setType': ''} + "action": "mute", + "topic": "2nd_driveway", + "shadow": "appDriveway", + "data": {"mute": "1"}, }, ], }, - 'SMS0A': { - 'type': EntityType.MOTION, + "SDS0A": { + "type": EntityType.DOOR, + "actions": [ + SBS50SecondGenTestAction(), + ], + }, + "SES01": { + "type": EntityType.DOOR, + }, + "SKF01": { + "type": EntityType.REMOTE, + }, + "SK0Z-3S": { + "type": EntityType.SMOKE, + "actions": [ + SBS50SecondGenTestAction(), + SBS50SmokeMuteAction(), + FireDrillAction(), + ], + }, + "SKP01": { + "type": EntityType.KEYPAD, }, - # 'SSD01': {}, - # 'SPL51': {}, - # 'SSL51': {}, - 'STH0A': { - 'type': EntityType.TEMPERATURE, - 'actions': [ - TestAction('thSelfTest'), - MuteAction('1', 'extendMute') + "SKP0A": { + "type": EntityType.KEYPAD, + "actions": [ + SBS50SecondGenTestAction(), ], }, - 'STH0B': { - 'type': EntityType.TEMPERATURE, - 'actions': [ - TestAction('thSelfTest'), - MuteAction('1', 'extendMute') + "SMA51": { + "type": EntityType.MAILBOX, + "actions": [ + MuteAction("appMailMute", "2nd_appmailmute", mute_type="1"), ], }, - 'STH51': { - 'type': EntityType.TEMPERATURE, - 'actions': [ - TestAction('thSelfTest'), - MuteAction('1', 'extendMute') + "SMS0A": { + "type": EntityType.MOTION, + "actions": [ + SBS50SecondGenTestAction(), ], }, - # 'SWL51': {}, - 'SWS51': { - 'type': EntityType.WATER, - 'actions': [ - TestAction('waterSelfTest'), - MuteAction(shadow='appWater', topic='2nd_appwater', extra={'silencetime': '', 'setType': '0'}) + "SMS01": { + "type": EntityType.MOTION, + }, + "SPL51": { + "type": EntityType.LIGHT, + }, + "group-L": { + "type": EntityType.LIGHT, + }, + "SSD01": { + "type": EntityType.SMARTDROP, + }, + "SSL51": { + "type": EntityType.LIGHT, + }, + "STH0A": { + "type": EntityType.TEMPERATURE, + "actions": [ + TestAction("thSelfTest"), + MuteAction( + "extendMute", "2nd_appmute", extra={"type": "STH0A"}, mute_type="1" + ), + ], + }, + "STH0B": { + "type": EntityType.TEMPERATURE, + "actions": [ + TestAction("thSelfTest"), + MuteAction( + "extendMute", "2nd_appmute", extra={"type": "STH0B"}, mute_type="1" + ), + ], + }, + "STH0C": { + "type": EntityType.TEMPERATURE, + "actions": [WifiExtendedMuteAction()], + }, + "STH51": { + "type": EntityType.TEMPERATURE, + "actions": [ + TestAction("thSelfTest"), + MuteAction( + "extendMute", "2nd_appmute", extra={"type": "STH51"}, mute_type="1" + ), ], }, - 'XC0C-iR': { - 'type': EntityType.CO, + "SWL51": { + "type": EntityType.LIGHT, }, - 'XC01-M': { + "SWS0A": { + "type": EntityType.WATER, + "actions": [ + TestAction("waterSelfTest", extra={"userParam": "source=1"}), + ], + }, + "SWS0B": { + "type": EntityType.WATER, + "actions": [WifiWaterMuteAction()], + }, + "SWS51": { + "type": EntityType.WATER, + "actions": [ + TestAction("waterSelfTest"), + MuteAction( + shadow="appWater", + topic="2nd_appwater", + extra={"silenceTime": "", "setType": "0"}, + ), + ], + }, + "CB0Z-3S": { + "type": EntityType.COMBI, + "actions": [ + SBS50SecondGenTestAction(), + MuteAction("app2ndMute", mute_type="1", extra={"userParam": "source=1"}), + FireDrillAction(), + ], + }, + "LP/N-SA-0B": { + "type": EntityType.SMOKE, + "actions": [ + SBS50SecondGenTestAction(), + SBS50SmokeMuteAction(), + FireDrillAction(), + ], + }, + "LP/N-SCA-0A": { + "type": EntityType.COMBI, + "actions": [ + SBS50SecondGenTestAction(), + MuteAction("app2ndMute", mute_type="1", extra={"userParam": "source=1"}), + FireDrillAction(), + ], + }, + "XC0C-iA": { + "type": EntityType.CO, + "actions": [WifiAlarmMuteAction()], + }, + "XC0C-iR": { + "type": EntityType.CO, + "actions": [WifiAlarmMuteAction()], + }, + "XC0M-iR": { + "type": EntityType.CO, + "actions": [WifiAlarmMuteAction()], + }, + "XC01-M": { # CO RF - 'type': EntityType.CO, - 'actions': [ - TestAction(shadow='appCoSelfTest'), - MuteAction('1', '"appCoMute') - ] - }, - 'XC04-WX': { - 'identifier': lambda entity: f'XC04-WX-{entity.sn}', - 'type': EntityType.CO, - 'actions': [ - MuteAction('1') - ] - }, - 'XH02-M': { - 'type': EntityType.HEAT, - 'actions': [ - TestAction(shadow='appXh02mSelfTest'), - ] - }, - 'XP0A-MR': { - 'type': EntityType.COMBI, - 'actions': [ - TestAction(shadow='app2ndSelfTest'), - FireDrillAction() - ] - }, - 'XP02S-MR': { - 'type': EntityType.SMOKE, - 'actions': [ - TestAction(shadow='app2ndSelfTest'), - ] - }, - 'XS01-M': { - 'type': EntityType.SMOKE, - 'actions': [ - TestAction(), - MuteAction(), + "type": EntityType.CO, + "actions": [ + TestAction(shadow="appCoSelfTest"), + MuteAction("appCoMute", mute_type="1"), + FireDrillAction(), ], }, - 'XS01-WX': { - 'type': EntityType.SMOKE, - 'actions': [ - TestAction(), + "XC04-WX": { + "identifier": lambda entity: f"XC04-WX-{entity.sn}", + "type": EntityType.CO, + "actions": [WifiAlarmMuteAction()], + }, + "XH02-M": { + "type": EntityType.HEAT, + "actions": [ + TestAction(shadow="appXh02mSelfTest", extra={"userParam": "source=1"}), + MuteAction("appXh02mMute", mute_type="1", extra={"userParam": "source=1"}), + FireDrillAction(), + ], + }, + "XP0A-MR": { + "type": EntityType.COMBI, + "actions": [ + SBS50SecondGenTestAction(), + MuteAction("appXp0amrMute", mute_type="1", extra={"userParam": "source=1"}), + FireDrillAction(), + ], + }, + "XR0A-iR": { + "type": EntityType.RADON, + "actions": [WifiExtendedMuteAction()], + }, + "XP02S-MR": { + "type": EntityType.SMOKE, + "actions": [ + SBS50SecondGenTestAction(), + SBS50SmokeMuteAction(), + FireDrillAction(), ], }, - 'XS0B-MR': { - 'type': EntityType.SMOKE, - 'actions': [ - TestAction(), + "XS01-M": { + "type": EntityType.SMOKE, + "actions": [ + SmokeRFTestAction(), MuteAction(), FireDrillAction(), ], }, - 'XS03-iWX': { + "XS01-WX": { + "type": EntityType.SMOKE, + "actions": [ + XS01WXMuteAction(), + ], + }, + "XS0B-MR": { + "type": EntityType.SMOKE, + "actions": [ + SBS50SecondGenTestAction(), + MuteAction("app2ndMute", mute_type="1", extra={"userParam": "source=1"}), + FireDrillAction(), + ], + }, + "XS03-iWX": { # Smoke RF - 'type': EntityType.SMOKE, + "type": EntityType.SMOKE, + "actions": [ + SmokeRFTestAction(), + ], + }, + "XS03-WX": { + "type": EntityType.SMOKE, + "actions": [WifiAlarmMuteAction()], + }, + "XS0D-MR": { + "type": EntityType.SMOKE, + "actions": [ + SBS50SecondGenTestAction(), + SBS50SmokeMuteAction(), + FireDrillAction(), + ], + }, + "XC0C-MR": { + "type": EntityType.CO, + "actions": [ + SBS50SecondGenTestAction(), + SBS50CoMuteAction(), + FireDrillAction(), + ], + }, + "XP0A-iR": { + "type": EntityType.COMBI, + "actions": [WifiAlarmMuteAction()], + }, + "XP0H-MR": { + "type": EntityType.COMBI, + "actions": [ + SBS50SecondGenTestAction(), + MuteAction("appSc07mrMute", mute_type="1", extra={"userParam": "source=1"}), + FireDrillAction(), + ], + }, + "XP0H-iR": { + "type": EntityType.COMBI, + "actions": [WifiAlarmMuteAction()], + }, + "XP0J-iA": { + "type": EntityType.COMBI, + "actions": [ + XP0JTestAction(), + WifiAlarmMuteAction(), + FireDrillAction(), + ], + }, + "XP0P-MR": { + "type": EntityType.COMBI, + "actions": [ + SBS50SecondGenTestAction(), + MuteAction("appSc07mrMute", mute_type="1", extra={"userParam": "source=1"}), + FireDrillAction(), + ], + }, + "XS0B-iR": { + "type": EntityType.SMOKE, + "actions": [WifiAlarmMuteAction()], + }, + "XS0E-iR": { + "type": EntityType.SMOKE, + "actions": [WifiAlarmMuteAction()], + }, + "XS0F-PMA": { + "type": EntityType.SMOKE, + "actions": [ + SBS50SecondGenTestAction(), + MuteAction("app2ndMute", mute_type="1", extra={"userParam": "source=1"}), + FireDrillAction(), + ], + }, + "XS0R-iA": { + "type": EntityType.SMOKE, + "actions": [ + WifiSelfTestAction(), + WifiAlarmMuteAction(), + FireDrillAction(), + ], }, - 'XS03-WX': {} } diff --git a/xsense/event_parser.py b/xsense/event_parser.py new file mode 100644 index 0000000..af3c9a7 --- /dev/null +++ b/xsense/event_parser.py @@ -0,0 +1,513 @@ +"""Pure parsers for X-Sense MQTT and camera-history event payloads.""" + +from __future__ import annotations + +from contextlib import suppress +from datetime import datetime, timezone +import json +from typing import Any + + +APK_AI_DETECTION_OBJECTS = { + "person", + "pet", + "vehicle", + "vehicle_enter", + "vehicle_out", + "vehicle_held_up", + "package", + "package_drop_off", + "package_pick_up", + "package_exist", + "other", +} + +APK_AI_DETECTION_GROUPS = { + "person": {"person"}, + "pet": {"pet"}, + "vehicle": {"vehicle", "vehicle_enter", "vehicle_out", "vehicle_held_up"}, + "package": {"package", "package_drop_off", "package_pick_up", "package_exist"}, + "other": {"other"}, +} + +APK_AI_DETECTION_DATA_KEYS = { + "person": "person", + "pet": "pet", + "vehicle_enter": "vehicleEnter", + "vehicle_out": "vehicleOut", + "vehicle_held_up": "vehicleHeldUp", + "package_drop_off": "packageDropOff", + "package_pick_up": "packagePickUp", + "package_exist": "packageExist", + "other": "other", +} + +MQTT_IDENTIFIER_KEYS = { + "camerasn", + "cxserialnumber", + "deviceid", + "devicesn", + "devserialnumber", + "realcxserialnumber", + "serial", + "serialnumber", + "sn", + "stationsn", + "stationserialnumber", +} + +SELF_TEST_RESULT_KEYS = ( + "lastSelfTest", + "selfTest", + "selfTestResult", + "selfTestStatus", + "testResult", + "testStatus", + "result", +) + +SELF_TEST_TIME_KEYS = ( + "lastSelfTestTime", + "selfTestTime", + "testTime", + "eventTime", + "timestamp", + "time", +) + +__all__ = [ + "APK_AI_DETECTION_DATA_KEYS", + "APK_AI_DETECTION_GROUPS", + "APK_AI_DETECTION_OBJECTS", + "MQTT_IDENTIFIER_KEYS", + "apk_ai_detection_name_times", + "apk_ai_detection_names", + "apk_ai_detection_object_times", + "apply_apk_ai_detection_aliases", + "apply_apk_dispatch_aliases", + "apply_apk_event_aliases", + "camera_ai_history_event_key", + "camera_event_history_event_key", + "camera_event_history_records", + "camera_event_history_station_data", + "camera_event_history_time", + "latest_apk_detection_time", + "is_self_test_topic", + "is_presence_topic", + "mqtt_identifier_candidates", + "mqtt_identifier_key_name", + "mqtt_reported_data", + "mqtt_topic_kind", + "normalize_self_test_report", + "normalize_self_test_result", + "SELF_TEST_RESULT_KEYS", + "SELF_TEST_TIME_KEYS", +] + + +def mqtt_reported_data(data: dict[str, Any]) -> dict[str, Any] | list[Any]: + """Return device data from either shadow reports or X-Sense event payloads.""" + reported = data.get("state", {}).get("reported") + if isinstance(reported, dict): + return reported.copy() + if isinstance(reported, list): + return list(reported) + + event_data = data.get("eventData") + if isinstance(event_data, str): + try: + event_data = json.loads(event_data) + except json.JSONDecodeError: + event_data = None + if isinstance(event_data, dict): + result = event_data.copy() + if event_time := data.get("eventTime"): + result.setdefault("time", event_time) + result.setdefault("eventTime", event_time) + if event_type := data.get("eventType") or result.get("eventType"): + result.setdefault("eventType", event_type) + apply_apk_dispatch_aliases(result) + apply_apk_event_aliases(result) + return result + + if any( + key in data + for key in ( + "dispatchDevs", + "eventItems", + "eventObjectType", + "eventType", + "lastType", + "serialNumber", + ) + ): + result = data.copy() + if event_time := data.get("eventTime"): + result.setdefault("time", event_time) + result.setdefault("eventTime", event_time) + apply_apk_dispatch_aliases(result) + apply_apk_event_aliases(result) + return result + + return {} + + +def mqtt_identifier_candidates(*values: Any) -> list[str]: + """Return possible station/device identifiers from nested MQTT payloads.""" + candidates: list[str] = [] + seen: set[str] = set() + + def add(value: Any) -> None: + if value in (None, ""): + return + text = str(value).strip() + if not text or text in seen: + return + seen.add(text) + candidates.append(text) + + def walk(value: Any) -> None: + if isinstance(value, str): + text = value.strip() + if text.startswith(("{", "[")): + with suppress(json.JSONDecodeError): + walk(json.loads(text)) + return + if isinstance(value, dict): + for key, nested_value in value.items(): + key_name = mqtt_identifier_key_name(key) + if key_name in MQTT_IDENTIFIER_KEYS and not isinstance( + nested_value, (dict, list, tuple, set) + ): + add(nested_value) + walk(nested_value) + return + if isinstance(value, (list, tuple, set)): + for item in value: + walk(item) + + for value in values: + walk(value) + return candidates + + +def mqtt_identifier_key_name(value: Any) -> str: + """Return a normalized MQTT identifier key name.""" + return "".join(char for char in str(value).strip().lower() if char.isalnum()) + + +def mqtt_topic_kind(topic: str) -> str: + """Return a non-sensitive MQTT topic category.""" + if is_presence_topic(topic): + return "presence" + if topic.startswith("@xsense/events/aiplan/"): + return "ai_plan" + if topic.startswith("@xsense/events/"): + return "house_event" + if "/shadow/name/" in topic: + return "shadow" + return "other" + + +def is_presence_topic(topic: str) -> bool: + """Return if an MQTT topic is an AWS IoT presence update.""" + return "/events/presence/" in topic + + +def is_self_test_topic(topic: str) -> bool: + """Return if an MQTT update is an X-Sense self-test report topic.""" + return any( + marker in topic + for marker in ( + "_testup/update", + "selftestup/update", + "selftestup_v2/update", + ) + ) + + +def normalize_self_test_report(data: dict[str, Any]) -> None: + """Normalize APK self-test report fields into reusable state keys.""" + for key in SELF_TEST_RESULT_KEYS: + value = data.get(key) + if value not in (None, ""): + data["lastSelfTest"] = normalize_self_test_result(value) + break + + for key in SELF_TEST_TIME_KEYS: + value = data.get(key) + if value not in (None, ""): + data["lastSelfTestTime"] = value + break + + +def normalize_self_test_result(value: Any) -> Any: + """Return the app-style success code when the report uses readable text.""" + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"success", "successful", "ok", "pass", "passed"}: + return "0" + if normalized in {"fail", "failed", "failure", "error"}: + return "1" + return value + + +def camera_ai_history_event_key(server_id: str, alarm_item: dict[str, Any]) -> str: + """Return a stable key for one APK AI-history alarm item.""" + if event_id := alarm_item.get("eventId"): + return f"{server_id}:{event_id}" + payload = json.dumps( + alarm_item, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + return f"{server_id}:{payload}" + + +def camera_event_history_records(history: dict[str, Any]) -> list[dict[str, Any]]: + """Return ADDX camera event records from the APK event-history response.""" + data = history.get("data") if isinstance(history.get("data"), dict) else history + records = data.get("list") if isinstance(data, dict) else None + if not isinstance(records, list): + return [] + return [record for record in records if isinstance(record, dict)] + + +def camera_event_history_event_key(record: dict[str, Any]) -> str: + """Return a stable key for one APK ADDX camera event record.""" + serial = record.get("serialNumber") or record.get("deviceSn") or record.get("sn") + trace = record.get("traceId") or record.get("traceIds") + timestamp = record.get("timestamp") or record.get("startTime") or record.get("date") + if serial and (trace or timestamp): + return f"camera-event:{serial}:{trace or timestamp}" + payload = json.dumps( + record, + ensure_ascii=False, + sort_keys=True, + separators=(",", ":"), + default=str, + ) + return f"camera-event:{payload}" + + +def camera_event_history_station_data(record: dict[str, Any]) -> dict[str, Any]: + """Return normal camera state keys from an APK ADDX event-history record.""" + serial = record.get("serialNumber") or record.get("deviceSn") or record.get("sn") + if not serial: + return {} + + timestamp = record.get("timestamp") or record.get("startTime") + event_time = camera_event_history_time(timestamp) + data: dict[str, Any] = { + "serialNumber": serial, + "deviceSN": serial, + "eventType": record.get("videoEvent") or record.get("tags") or "motion", + "eventItems": record.get("eventInfoList"), + "eventObjectType": record.get("eventInfoList") or record.get("tags"), + "lastType": record.get("videoEvent") or record.get("tags"), + } + if event_time: + data["time"] = event_time + data["eventTime"] = event_time + + apply_apk_event_aliases(data) + return data + + +def camera_event_history_time(value: Any) -> str | None: + """Return an X-Sense compact timestamp from an ADDX epoch timestamp.""" + if value in (None, ""): + return None + try: + timestamp = int(value) + except (TypeError, ValueError): + return str(value) + if timestamp > 10_000_000_000: + timestamp //= 1000 + return datetime.fromtimestamp(timestamp, timezone.utc).strftime("%Y%m%d%H%M%S") + + +def apply_apk_dispatch_aliases(data: dict[str, Any]) -> None: + """Apply APK dispatch device identifiers to normal MQTT lookup keys.""" + dispatch_devs = data.get("dispatchDevs") + if not isinstance(dispatch_devs, list): + return + + dispatch_dev = next((item for item in dispatch_devs if isinstance(item, dict)), None) + if dispatch_dev is None: + return + + if station_sn := dispatch_dev.get("stationSn"): + data.setdefault("stationSN", station_sn) + if device_sn := dispatch_dev.get("deviceSn"): + data.setdefault("deviceSN", device_sn) + data.setdefault("serialNumber", device_sn) + if event_time := dispatch_dev.get("eventTime"): + data.setdefault("time", event_time) + + +def apply_apk_event_aliases(data: dict[str, Any]) -> None: + """Apply APK event aliases that are not reported as shadow keys.""" + apply_apk_ai_detection_aliases(data) + + +def apply_apk_ai_detection_aliases(data: dict[str, Any]) -> None: + """Apply APK AI detection object names from camera event payloads.""" + fallback_time = data.get("time") or data.get("eventTime") + object_times = apk_ai_detection_object_times(data, fallback_time) + objects = set(object_times) + if not objects: + return + + data["lastAiDetection"] = ",".join(sorted(objects)) + for group, object_names in APK_AI_DETECTION_GROUPS.items(): + detected_objects = objects & object_names + detected = bool(detected_objects) + data[f"{group}Detected"] = detected + if detected: + time_value = latest_apk_detection_time( + object_times.get(name) for name in detected_objects + ) + if time_value: + data[f"last{group.title()}DetectionTime"] = time_value + for object_name, data_key in APK_AI_DETECTION_DATA_KEYS.items(): + detected = object_name in objects + data[f"{data_key}Detected"] = detected + if detected and object_times.get(object_name): + data[f"last{data_key[0].upper()}{data_key[1:]}DetectionTime"] = object_times[ + object_name + ] + + +def apk_ai_detection_object_times( + data: dict[str, Any], fallback_time: Any = None +) -> dict[str, Any]: + """Return APK AI detection object names and their best event timestamp.""" + raw_values: list[Any] = [ + data.get("eventObjectType"), + data.get("eventItems"), + data.get("lastType"), + data.get("lastAiDetection"), + ] + objects: dict[str, Any] = {} + for raw_value in raw_values: + for name, time_value in apk_ai_detection_name_times( + raw_value, fallback_time + ).items(): + objects[name] = latest_apk_detection_time((objects.get(name), time_value)) + return objects + + +def apk_ai_detection_name_times(value: Any, fallback_time: Any = None) -> dict[str, Any]: + """Return APK AI detection object names with timestamps from nested payloads.""" + if value is None: + return {} + if isinstance(value, str): + text = value.strip() + if text.startswith(("{", "[")): + with suppress(json.JSONDecodeError): + return apk_ai_detection_name_times(json.loads(text), fallback_time) + return {name: fallback_time for name in apk_ai_detection_names(text)} + if isinstance(value, dict): + item_time = value.get("eventTime") or value.get("time") or fallback_time + objects: dict[str, Any] = {} + for key in ("eventType", "eventObjectType", "eventItems", "lastType"): + for name, time_value in apk_ai_detection_name_times( + value.get(key), item_time + ).items(): + objects[name] = latest_apk_detection_time( + (objects.get(name), time_value) + ) + for key, nested_value in value.items(): + key_name = str(key).strip().lower() + if key_name in APK_AI_DETECTION_GROUPS: + nested = apk_ai_detection_name_times(nested_value, item_time) + if nested: + for name, time_value in nested.items(): + objects[name] = latest_apk_detection_time( + (objects.get(name), time_value) + ) + elif nested_value not in (None, False): + for name in APK_AI_DETECTION_GROUPS[key_name]: + objects[name] = latest_apk_detection_time( + (objects.get(name), item_time) + ) + continue + if key_name in APK_AI_DETECTION_OBJECTS and nested_value not in ( + None, + False, + ): + objects[key_name] = latest_apk_detection_time( + (objects.get(key_name), item_time) + ) + continue + if key in {"eventType", "eventObjectType", "eventItems", "lastType"}: + continue + for name, time_value in apk_ai_detection_name_times( + nested_value, item_time + ).items(): + objects[name] = latest_apk_detection_time( + (objects.get(name), time_value) + ) + return objects + if isinstance(value, (list, tuple, set)): + objects: dict[str, Any] = {} + for item in value: + for name, time_value in apk_ai_detection_name_times( + item, fallback_time + ).items(): + objects[name] = latest_apk_detection_time( + (objects.get(name), time_value) + ) + return objects + return {} + + +def latest_apk_detection_time(values) -> Any: + """Return the newest compact X-Sense time value from an iterable.""" + candidates = [value for value in values if value not in (None, "")] + if not candidates: + return None + return max(candidates, key=str) + + +def apk_ai_detection_names(value: Any) -> set[str]: + """Return APK AI detection object names from a scalar/list/dict value.""" + if value is None: + return set() + if isinstance(value, str): + text = value.strip() + if text.startswith(("{", "[")): + with suppress(json.JSONDecodeError): + return apk_ai_detection_names(json.loads(text)) + candidates = [ + part.strip().lower() + for part in text.replace(";", ",").replace("|", ",").split(",") + ] + return {name for name in candidates if name in APK_AI_DETECTION_OBJECTS} + if isinstance(value, dict): + names: set[str] = set() + for key, nested_value in value.items(): + key_name = str(key).strip().lower() + if key_name in APK_AI_DETECTION_GROUPS: + nested_names = apk_ai_detection_names(nested_value) + if nested_names: + names.update(nested_names) + elif nested_value not in (None, False): + names.update(APK_AI_DETECTION_GROUPS[key_name]) + continue + if key_name in APK_AI_DETECTION_OBJECTS and nested_value not in ( + None, + False, + ): + names.add(key_name) + names.update(apk_ai_detection_names(nested_value)) + return names + if isinstance(value, (list, tuple, set)): + names: set[str] = set() + for item in value: + names.update(apk_ai_detection_names(item)) + return names + return set() diff --git a/xsense/house.py b/xsense/house.py index b27cfba..cc14007 100644 --- a/xsense/house.py +++ b/xsense/house.py @@ -1,8 +1,9 @@ from typing import List, Dict -from xsense.aws_signer import AWSSigner -from xsense.station import Station -from xsense.mqtt_helper import MQTTHelper +from .aws_signer import AWSSigner +from .station import Station +from .mqtt_helper import MQTTHelper +from .entity_map import EntityType class House: @@ -13,38 +14,84 @@ class House: station_order: List[str] = None def __init__( - self, - signer: AWSSigner, - house_id: str, - name: str, - region: str, - mqtt_region: str, - mqtt_server: str + self, + signer: AWSSigner, + house_id: str, + name: str, + region: str, + mqtt_region: str, + mqtt_server: str, ): self.house_id = house_id self.name = name self.region = region self.mqtt_region = mqtt_region self.mqtt_server = mqtt_server + self.rooms = {} + self.room_order = [] + self.stations = {} + self.station_order = [] self.mqtt = MQTTHelper(signer, self) def set_rooms(self, data): - self.rooms = data.get('houseRooms') - self.room_order = data.get('roomSort') + self.rooms = data.get("houseRooms") or {} + self.room_order = data.get("roomSort") or [] + + def room_name(self, room_id: str | None) -> str | None: + if not room_id: + return None + if isinstance(self.rooms, dict): + room = self.rooms.get(room_id) + if isinstance(room, dict): + return room.get("roomName") or room.get("name") + if isinstance(room, str): + return room + if isinstance(self.rooms, list): + for room in self.rooms: + if not isinstance(room, dict): + continue + if room.get("roomId") == room_id: + return room.get("roomName") or room.get("name") + return None def set_stations(self, data): - self.station_order = data.get('stationSort') + self.station_order = list(data.get("stationSort") or []) stations = {} - for i in data.get('stations', []): - s = Station( - self, - **i - ) + for i in data.get("stations") or []: + station_id = i.get("stationId") + if not station_id: + continue + s = Station(self, **i) s.set_devices(i) - stations[i['stationId']] = s + stations[station_id] = s + + for i in data.get("cameras") or []: + camera_type = i.get("category") + station_id = i.get("ipcId") + if not station_id: + continue + if station_id in stations: + continue + + station_data = { + "stationId": station_id, + "roomId": i.get("roomId"), + "stationSn": i.get("ipcSn"), + "stationName": i.get("ipcName"), + "category": camera_type, + "deviceType": camera_type, + "userId": i.get("userId"), + "userName": i.get("userName"), + "onLine": 1, + "devices": [], + } + s = Station(self, **station_data) + s.entity_type = EntityType.CAMERA + s.set_devices(station_data) + stations[station_id] = s self.stations = stations diff --git a/xsense/mapping.py b/xsense/mapping.py index 13fada3..25fc4c9 100644 --- a/xsense/mapping.py +++ b/xsense/mapping.py @@ -1,56 +1,262 @@ +from collections.abc import Callable import typing property_mapper = { - '*': { - 'wifiRssi': 'wifiRSSI' + "*": {"wifiRssi": "wifiRSSI"}, + "SDS0A": { + "a": "isOpen", + "open": "isOpen", + "door": "isOpen", + "doorStatus": "isOpen", + "status": "isOpen", }, - 'STH0A': { - 'a': 'alarmStatus', - 'b': 'temperature', - 'c': 'humidity', - 'd': 'temperatureUnit', - 'e': 'temperatureRange', - 'f': 'humidityRange', - 'g': 'alarmEnabled', - 'h': 'continuedAlarm', - 't': 'time' + "SES01": { + "a": "isOpen", + "open": "isOpen", + "door": "isOpen", + "doorStatus": "isOpen", + "status": "isOpen", }, - 'STH0B': { - 'a': 'alarmStatus', - 'b': 'temperature', - 'c': 'humidity', - 'd': 'temperatureUnit', - 'e': 'temperatureRange', - 'f': 'humidityRange', - 'g': 'alarmEnabled', - 'h': 'continuedAlarm', - 't': 'time' + "STH0A": { + "a": "alarmStatus", + "b": "temperature", + "c": "humidity", + "d": "tempUnit", + "e": "tRange", + "f": "hRange", + "g": "alarmEnabled", + "h": "continuedAlarm", + "t": "time", }, - 'STH51': { - 'a': 'alarmStatus', - 'b': 'temperature', - 'c': 'humidity', - 'd': 'temperatureUnit', - 'e': 'temperatureRange', - 'f': 'humidityRange', - 'g': 'alarmEnabled', - 'h': 'continuedAlarm', - 't': 'time' + "STH0B": { + "a": "alarmStatus", + "b": "temperature", + "c": "humidity", + "d": "tempUnit", + "e": "tRange", + "f": "hRange", + "g": "alarmEnabled", + "h": "continuedAlarm", + "t": "time", + }, + "STH51": { + "a": "alarmStatus", + "b": "temperature", + "c": "humidity", + "d": "tempUnit", + "e": "tRange", + "f": "hRange", + "g": "alarmEnabled", + "h": "continuedAlarm", + "t": "time", }, } -type_mapping = { - 'batInfo': int, - 'rfLevel': int, - 'alarmStatus': lambda x: x == '1', - 'alarmEnabled': lambda x: x == '1', - 'muteStatus': lambda x: x == '1', - 'continuedAlarm': lambda x: x == '1', - 'coPpm': int, - 'coLevel': int, - 'isLifeEnd': lambda x: x == '1', - 'temperature': float, - 'humidity': float + +def bool_state(value: typing.Any) -> bool | None: + if isinstance(value, bool): + return value + if isinstance(value, int): + if value == 1: + return True + if value == 0: + return False + return None + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"1", "true", "on"}: + return True + if normalized in {"0", "false", "off"}: + return False + return None + + +def open_state(value: typing.Any) -> bool | None: + """Return door/opening state where true means open.""" + result = bool_state(value) + if result is not None: + return result + if isinstance(value, str): + normalized = value.strip().lower() + if normalized in {"open", "opened"}: + return True + if normalized in {"closed", "close", "shut"}: + return False + return None + + +def safe_float(value: typing.Any) -> float | None: + if value in (None, ""): + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def safe_float_list(value: typing.Any) -> list[float] | None: + if not isinstance(value, (list, tuple)): + return None + result = [] + for item in value: + parsed = safe_float(item) + if parsed is None: + return None + result.append(parsed) + return result + + +def safe_int(value: typing.Any) -> int | None: + if value in (None, ""): + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +type_mapping: dict[str, Callable[[typing.Any], typing.Any]] = { + "batInfo": safe_int, + "rfLevel": safe_int, + "wifiRSSI": safe_int, + "alarmStatus": bool_state, + "alarmEnabled": bool_state, + "alarmEnable": bool_state, + "alarmWhenRemoveToggleOn": bool_state, + "activate": bool_state, + "alarmSound": bool_state, + "appTip": bool_state, + "awaitEnable": bool_state, + "antiflickerSupport": bool_state, + "antiflickerSwitch": bool_state, + "baseRemove": bool_state, + "continuedAlarm": bool_state, + "continueAlarm": bool_state, + "cooldownEnabled": bool_state, + "cooldownSupported": bool_state, + "acBreak": bool_state, + "bEndUse": bool_state, + "deviceCallToggleOn": bool_state, + "isActivate": bool_state, + "isAdmin": bool_state, + "initiativeAlarm": bool_state, + "isAlarm": bool_state, + "isArmed": bool_state, + "isOpen": open_state, + "openRemind": bool_state, + "isFireDrill": bool_state, + "isMoved": bool_state, + "keySound": bool_state, + "liveAudioToggleOn": bool_state, + "mailNotice": bool_state, + "mechanicalDingDongSwitch": bool_state, + "mirrorFlip": bool_state, + "mute": bool_state, + "muteStatus": bool_state, + "needAlarm": bool_state, + "needMotion": bool_state, + "needNightVision": bool_state, + "needVideo": bool_state, + "on": bool_state, + "pirEnable": bool_state, + "recordingAudioToggleOn": bool_state, + "recLamp": bool_state, + "remindOn": bool_state, + "remindToneEnable": bool_state, + "scheduleTip": bool_state, + "showCodecChange": bool_state, + "sunshineEnable": bool_state, + "tempAlarmStatus": bool_state, + "tempMuteStatus": bool_state, + "test": bool_state, + "timeZoneEnabled": bool_state, + "timeZoneValid": bool_state, + "usbCharge": bool_state, + "voiceVolumeSwitch": bool_state, + "waterAlarmStatus": bool_state, + "waterMuteStatus": bool_state, + "whiteLightScintillation": bool_state, + "warnIsOpen": bool_state, + "chirpToneEnable": bool_state, + "coPpm": safe_int, + "coPpmPeak": safe_int, + "warnLongCoPpm": safe_int, + "warnShortCoPpm": safe_int, + "coLevel": safe_int, + "isLifeEnd": bool_state, + "temperature": safe_float, + "humidity": safe_float, + "tempRangeMin": safe_float, + "tempRangeMax": safe_float, + "humRangeMin": safe_float, + "humRangeMax": safe_float, + "hRange": safe_float_list, + "hAdjust": safe_float, + "hComfort": safe_float_list, + "tRange": safe_float_list, + "tAdjust": safe_float, + "tComfort": safe_float_list, + "alarmVol": safe_int, + "alarmSeconds": safe_int, + "awaitBrightness": safe_int, + "awake": safe_int, + "batteryLevel": safe_int, + "cameraStatusCode": safe_int, + "chargeAutoPowerOnCapacity": safe_int, + "chargeAutoPowerOnSwitch": safe_int, + "cooldownValue": safe_int, + "cryDetect": safe_int, + "cryDetectLevel": safe_int, + "devicePersonDetect": safe_int, + "deviceDormancyWakeTime": safe_int, + "deviceStatus": safe_int, + "firmwareStatus": safe_int, + "voiceVol": safe_int, + "chirpVol": safe_int, + "isCharging": safe_int, + "languageCount": safe_int, + "languageIndex": safe_int, + "ledBrt": safe_int, + "liveSpeakerVolume": safe_int, + "mechanicalDingDongDuration": safe_int, + "motionSensitivity": safe_int, + "motionTrack": bool_state, + "motionTrackMode": safe_int, + "nightThresholdLevel": safe_int, + "nightVisionMode": safe_int, + "pirInterval": safe_int, + "pirSensitivity": safe_int, + "sdCardFormatStatus": safe_int, + "sdCardTotal": safe_int, + "sdCardUsed": safe_int, + "remindVol": safe_int, + "signalStrength": safe_int, + "supportLiveAudio": bool_state, + "supportLiveSpeakerVolume": bool_state, + "supportAlarm": bool_state, + "supportAlarmVolume": bool_state, + "supportAntiFlicker": bool_state, + "supportBattery": bool_state, + "supportChargeAutoPowerOn": bool_state, + "supportCryDetect": bool_state, + "supportDeviceCall": bool_state, + "supportDoorBellAlarm": bool_state, + "supportLight": bool_state, + "supportMechanicalDingDong": bool_state, + "supportMirrorFlip": bool_state, + "supportMotionTrack": bool_state, + "supportPirCooldown": bool_state, + "supportRecLamp": bool_state, + "supportRecordingAudio": bool_state, + "supportRocker": bool_state, + "supportSdCard": bool_state, + "supportSleep": bool_state, + "supportVoiceVolume": bool_state, + "supportWebrtc": bool_state, + "thumbImgTime": safe_int, + "triggerBrightness": safe_int, + "videoSeconds": safe_int, + "wifiRssiLevel": safe_int, } @@ -59,10 +265,6 @@ def map_type(k: str, value: typing.Any): def map_values(device_type: str, data: typing.Dict): - mapping = property_mapper[device_type] if device_type in property_mapper else {} - mapping.update(property_mapper.get('*', {})) + mapping = property_mapper.get("*", {}) | property_mapper.get(device_type, {}) - return { - mapping.get(k, k): map_type(mapping.get(k, k), v) - for k, v in data.items() - } + return {mapping.get(k, k): map_type(mapping.get(k, k), v) for k, v in data.items()} diff --git a/xsense/mqtt_helper.py b/xsense/mqtt_helper.py index 514ac19..bd85865 100644 --- a/xsense/mqtt_helper.py +++ b/xsense/mqtt_helper.py @@ -1,56 +1,228 @@ -"""A helper class to setup the MQTT client, generate connection urls and parse messages""" +"""Helpers for connecting to and using the X-Sense MQTT broker.""" + +from __future__ import annotations + +import json import uuid import ssl -from datetime import datetime, timedelta -from typing import Dict +from datetime import datetime, timedelta, timezone +from typing import TYPE_CHECKING, Any, Callable, Dict, Iterable, List, Optional from paho.mqtt import client as mqtt_client -from xsense.aws_signer import AWSSigner +from .aws_signer import AWSSigner + +if TYPE_CHECKING: + from .house import House + from .station import Station URL_MAX_AGE = 5 -USERNAME = '?SDK=iOS&Version=2.26.5' +USERNAME = "?SDK=iOS&Version=2.26.5" FIRST_RECONNECT_DELAY = 1 RECONNECT_RATE = 2 MAX_RECONNECT_COUNT = 12 MAX_RECONNECT_DELAY = 60 +DEFAULT_QOS = 0 +DEFAULT_SUBSCRIBE_QOS = 1 +DEFAULT_RETAIN = False +TEMP_DATA_TYPES = ("STH51", "STH0A", "STH0B") -class MQTTHelper: - _sig_age = None - active: bool - _last_update = None - _update_callback = None - _mqtt_path = None +def shadow_update_topic(thing_name: str, shadow: str) -> str: + return f"$aws/things/{thing_name}/shadow/name/{shadow}/update" + + +def shadow_wildcard_topic(thing_name: str) -> str: + return f"$aws/things/{thing_name}/shadow/name/+/update" + + +def presence_topic(thing_name: str) -> str: + return f"$aws/events/presence/+/{thing_name}" + +def house_event_topic(house_id: str) -> str: + return f"@xsense/events/+/{house_id}" + + +def parse_message_payload(payload: Any) -> Dict: + if isinstance(payload, bytes): + payload = payload.decode() + if isinstance(payload, str): + return json.loads(payload) + if payload is None: + return {} + return payload + + +def should_ignore_shadow_topic(topic: str) -> bool: + ignored_suffixes = ("/update/accepted", "/update/documents", "/update/rejected") + return topic.endswith(ignored_suffixes) + + +class MQTTHelper: def _get_path(self): if ( - not self._mqtt_path or - not self._sig_age or - datetime.now() - self._sig_age > timedelta(minutes=URL_MAX_AGE) + not self._mqtt_path + or not self._sig_age + or datetime.now() - self._sig_age > timedelta(minutes=URL_MAX_AGE) ): - signed = self.signer.presign_url(f'wss://{self.house.mqtt_server}/mqtt', self.house.mqtt_region) + signed = self.signer.presign_url( + f"wss://{self.house.mqtt_server}/mqtt", self.house.mqtt_region + ) url_parts = signed.split("/") self._mqtt_path = "/" + "/".join(url_parts[3:]) - _sig_age = datetime.now() + self._sig_age = datetime.now() return self._mqtt_path - def __init__(self, signer: AWSSigner, house: 'House'): + def __init__(self, signer: AWSSigner, house: House): self.signer = signer self.house = house + self.active = False + self._last_update = None + self._update_callback = None + self._mqtt_path = None + self._sig_age = None self.client = mqtt_client.Client( + callback_api_version=mqtt_client.CallbackAPIVersion.VERSION2, client_id=str(uuid.uuid4()), reconnect_on_failure=False, - transport='websockets' + transport="websockets", ) - self.client.username_pw_set(USERNAME, '') - ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS) - ssl_context.verify_mode = ssl.CERT_NONE + self._tls_context_configured = False + self.client.username_pw_set(USERNAME, "") + + def ensure_tls_context(self): + """Configure TLS after certificate loading has moved off the event loop.""" + if self._tls_context_configured: + return + ssl_context = ssl.create_default_context() self.client.tls_set_context(ssl_context) + self._tls_context_configured = True def prepare_connect(self): self.client.ws_set_options(path=self._get_path()) + + def prepare_connection(self): + self.prepare_connect() + self.ensure_tls_context() + + def connect(self, port: int = 443, keepalive: int = 60): + self.prepare_connection() + result = self.client.connect(self.house.mqtt_server, port, keepalive) + self.active = result == 0 + return result + + def loop_start(self): + self.client.loop_start() + + def loop_stop(self): + self.client.loop_stop() + + def disconnect(self): + result = self.client.disconnect() + self.active = False + return result + + def publish( + self, + topic: str, + payload: Any, + qos: int = DEFAULT_QOS, + retain: bool = DEFAULT_RETAIN, + ): + if not isinstance(payload, str): + payload = json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + return self.client.publish(topic, payload, qos=qos, retain=retain) + + def subscribe(self, topic: str, qos: int = DEFAULT_SUBSCRIBE_QOS): + return self.client.subscribe(topic, qos=qos) + + def subscribe_live_updates(self, qos: int = DEFAULT_SUBSCRIBE_QOS) -> List: + results = [] + for topic in self.live_update_topics(): + results.append(self.subscribe(topic, qos=qos)) + return results + + def set_message_callback( + self, callback: Callable[[str, Dict], None], ignore_shadow_ack: bool = True + ): + def on_message(_client, _userdata, msg): + if ignore_shadow_ack and should_ignore_shadow_topic(msg.topic): + return + payload = parse_message_payload(msg.payload) + self._last_update = (msg.topic, payload) + callback(msg.topic, payload) + + self.client.on_message = on_message + + def live_update_topics(self) -> List[str]: + topics = [ + house_event_topic(self.house.house_id), + shadow_wildcard_topic(self.house.house_id), + ] + + for station in getattr(self.house, "stations", {}).values(): + topics.append(shadow_wildcard_topic(station.shadow_name)) + topics.append(presence_topic(station.shadow_name)) + + return topics + + def temp_data_devices( + self, station: Station, device_types: Iterable[str] = TEMP_DATA_TYPES + ) -> List[str]: + device_types = set(device_types) + return [ + device.sn + for device in getattr(station, "devices", {}).values() + if device.type in device_types + ] + + def build_temp_data_request( + self, + station: Station, + device_sns: Optional[Iterable[str]] = None, + timeout_minutes: int = 5, + user_id: Optional[str] = None, + ) -> Dict: + if device_sns is None: + device_sns = self.temp_data_devices(station) + device_sns = list(device_sns) + if not device_sns: + raise ValueError("At least one device serial number is required") + if user_id is None: + raise ValueError("user_id is required to request temperature data") + + return { + "state": { + "desired": { + "shadow": "appTempData", + "deviceSN": device_sns, + "source": "1", + "report": "1", + "reportDst": "1", + "timeoutM": str(timeout_minutes), + "userId": user_id, + "time": datetime.now(timezone.utc).strftime("%Y%m%d%H%M%S"), + "stationSN": station.sn, + } + } + } + + def temp_data_topic(self, station: Station) -> str: + return shadow_update_topic(station.shadow_name, "2nd_apptempdata") + + def request_temp_data( + self, + station: Station, + device_sns: Optional[Iterable[str]] = None, + timeout_minutes: int = 5, + user_id: Optional[str] = None, + ): + payload = self.build_temp_data_request( + station, device_sns, timeout_minutes, user_id + ) + return self.publish(self.temp_data_topic(station), payload) diff --git a/xsense/station.py b/xsense/station.py index 8de3842..ca28d52 100644 --- a/xsense/station.py +++ b/xsense/station.py @@ -1,7 +1,7 @@ from typing import List, Dict -from xsense.device import Device -from xsense.entity import Entity +from .device import Device +from .entity import Entity class Station(Entity): @@ -9,34 +9,59 @@ class Station(Entity): device_order: List[str] device_by_sn: Dict[str, str] - def __init__( - self, - parent, - **kwargs - ): + def __init__(self, parent, **kwargs): self.house = parent - self.safe_mode = kwargs.get('safeMode') - self.entity_id = kwargs.get('stationId') - self.name = kwargs.get('stationName') - self.sn = kwargs.get('stationSn') - self.online = kwargs.get('onLine', True) - self.type = kwargs.get('category') + self.safe_mode = kwargs.get("safeMode") + self.entity_id = kwargs.get("stationId") + self.name = kwargs.get("stationName") + self.sn = kwargs.get("stationSn") + self.online = None + self.type = kwargs.get("category") + self.devices = {} + self.device_order = [] + self.device_by_sn = {} self.has_alarm = False self._alarm_data = {} super().__init__(**kwargs) def set_devices(self, data): - self.device_order = data.get('deviceSort') + self.device_order = data.get("deviceSort") or [] result = {} result_sn = {} - for i in data.get('devices'): - d = Device( - self, - **i - ) - result[i['deviceId']] = d - result_sn[i['deviceSn']] = i['deviceId'] + source_devices = [] + for i in data.get("devices") or []: + device_data = dict(i) + device_data["stationId"] = self.entity_id + if not device_data.get("roomName") and self.house is not None: + device_data["roomName"] = self.house.room_name(device_data.get("roomId")) + if "isActivate" in device_data: + device_data["activate"] = device_data["isActivate"] + source_devices.append(device_data) + d = Device(self, **device_data) + data_updates = {} + if device_data.get("roomName") is not None: + data_updates["roomName"] = device_data["roomName"] + if device_data.get("stationId") is not None: + data_updates["stationId"] = device_data["stationId"] + if "isActivate" in device_data: + data_updates.update( + { + "activate": device_data["isActivate"], + "isActivate": device_data["isActivate"], + } + ) + if data_updates: + d.set_data(data_updates) + result[device_data["deviceId"]] = d + result_sn[device_data["deviceSn"]] = device_data["deviceId"] + + for i in data.get("groupList") or []: + device_data = _light_group_device_data(self, data, i, source_devices) + d = Device(self, **device_data) + d.set_data(device_data) + result[device_data["deviceId"]] = d + result_sn[device_data["deviceSn"]] = device_data["deviceId"] self.devices = result self.device_by_sn = result_sn @@ -45,23 +70,102 @@ def get_device_by_sn(self, sn: str): return self.devices.get(device_id) return None + def get_group_device(self, group_id): + group_id = _java_string(group_id) + for dev in self.devices.values(): + if ( + dev.type == "group-L" + and _java_string(dev.data.get("groupId")) == group_id + ): + return dev + return None def set_alarm_data(self, values: dict): keys = [ - 'mode', - 'who', - 'safeMode', - 'entryDelay', - 'pword', - 'deviceSn', - 'forceArm', - 'forceReason' + "mode", + "who", + "safeMode", + "entryDelay", + "pword", + "deviceSn", + "forceArm", + "forceReason", ] for k in keys: - if v := values.get(k): - self._alarm_data[k] = v + if k in values: + self._alarm_data[k] = values[k] @property def alarm_data(self): return self._alarm_data + + @property + def alarm_mode(self): + """Return the current security mode reported by X-Sense.""" + return ( + self._alarm_data.get("mode") + or self._alarm_data.get("safeMode") + or self.safe_mode + or self.data.get("safeMode") + ) + + @property + def is_armed(self) -> bool | None: + """Return whether the security mode is armed when it is known.""" + mode = self.alarm_mode + if mode in (None, ""): + return None + normalized = str(mode).strip().lower() + if normalized in {"0", "disarm", "disarmed", "off"}: + return False + if normalized in {"1", "2", "home", "away", "armed", "armed_home", "armed_away"}: + return True + return None + + +def _light_group_device_data( + station: Station, station_data: Dict, group: Dict, source_devices: List[Dict] +) -> Dict: + group_id = group.get("groupId") + group_name = group.get("groupName") + create_time = group.get("createTime") or "" + members = [i for i in source_devices if i.get("groupId") == group_id] + return { + "deviceName": group_name, + "deviceId": f"{create_time}{_java_string(group_id)}", + "deviceSn": _light_group_device_sn(group_id), + "groupId": group_id, + "groupName": group_name, + "stationId": station.entity_id, + "stationSn": station.sn, + "houseId": station.house.house_id if station.house is not None else None, + "deviceType": "group-L", + "appTime": group.get("appTime"), + "pirTime": group.get("pirTime"), + "roomId": station_data.get("roomId"), + "roomName": station_data.get("roomName") + or (station.house.room_name(station.room_id) if station.house is not None else None), + "devs": [i.get("deviceSn") for i in members if i.get("deviceSn")], + "on": "1" if _has_light_on(members) else "0", + } + + +def _has_light_on(devices: List[Dict]) -> bool: + return any(_is_not_reported_offline(i) and i.get("on") == "1" for i in devices) + + +def _is_not_reported_offline(device: Dict) -> bool: + online = device.get("online", device.get("onLine")) + return online not in (0, "0", False, "false", "False") + + +def _light_group_device_sn(group_id) -> str: + if group_id is None: + return "LG000000" + padded = _java_string(group_id).rjust(8, "0") + return f"LG{padded[2:]}" + + +def _java_string(value) -> str: + return "null" if value is None else str(value) diff --git a/xsense/utils.py b/xsense/utils.py index ea1c0f8..c722f87 100644 --- a/xsense/utils.py +++ b/xsense/utils.py @@ -1,7 +1,7 @@ import argparse import contextlib -from xsense.base import XSenseBase +from .base import XSenseBase def get_credentials(): @@ -13,6 +13,8 @@ def get_credentials(): if args.username and args.password: return args.username, args.password + username = None + password = None with contextlib.suppress(FileNotFoundError): with open('.env', 'r') as file: for line in file: diff --git a/xsense/webrtc_signal.py b/xsense/webrtc_signal.py new file mode 100644 index 0000000..f19f513 --- /dev/null +++ b/xsense/webrtc_signal.py @@ -0,0 +1,1222 @@ +"""X-Sense ADDX WebRTC signaling helpers.""" + +from __future__ import annotations + +import asyncio +import base64 +import json +import logging +import time +from collections import Counter +from collections.abc import Callable +from contextlib import suppress +from dataclasses import dataclass +from typing import Any +from urllib.parse import urlparse, urlunparse + +import aiohttp + +LOGGER = logging.getLogger(__name__) + +SIGNAL_MODE = "vicoo" +SIGNAL_VIEWER_TYPE = "a4x_sdk" +_SIGNAL_NAME = "test-123" +_DEFAULT_RESOLUTION = "1280x720" +_ANSWER_TIMEOUT = 40 +_SIGNAL_RECONNECT_DELAY = 5 +_SIGNAL_TERMINAL_CLOSE_CODES = {3002, 3004} + +__all__ = [ + "SIGNAL_MODE", + "SIGNAL_VIEWER_TYPE", + "XSenseWebRTCTicket", + "XSenseWebRTCSignalSession", + "make_ice_candidate_payload", + "make_sdp_offer_payload", + "parse_signal_message", +] + + +@dataclass(slots=True) +class XSenseWebRTCTicket: + """ADDX WebRTC ticket data returned by the X-Sense camera API.""" + + serial_number: str + signal_server: str + group_id: str + role: str + client_id: str + trace_id: str + sign: str + time: int + expiration_time: int | None = None + signal_ping_interval: int | None = None + app_stop_live_timeout: int | None = None + signal_server_ip_address: str | None = None + ice_servers: list[dict[str, Any]] | None = None + + @classmethod + def from_api(cls, serial_number: str, data: dict[str, Any]) -> "XSenseWebRTCTicket": + """Build a ticket from the Android app getWebrtcTicket response.""" + return cls( + serial_number=serial_number, + signal_server=str(data["signalServer"]), + group_id=str(data["groupId"]), + role=str(data["role"]), + client_id=str(data["id"]), + trace_id=str(data["traceId"]), + sign=str(data["sign"]), + time=int(data["time"]), + expiration_time=_optional_int(data.get("expirationTime")), + signal_ping_interval=_optional_int(data.get("signalPingInterval")), + app_stop_live_timeout=_optional_int(data.get("appStopLiveTimeout")), + signal_server_ip_address=data.get("signalServerIpAddress"), + ice_servers=list(data.get("iceServer") or []), + ) + + @property + def is_valid(self) -> bool: + """Return whether the ticket has enough lifetime left to start playback.""" + if self.expiration_time is None: + return False + return self.expiration_time > int(time.time() * 1000) + + @property + def session_id(self) -> str: + """Return the SDK-style peer connection session id.""" + return f"Android-{self.client_id}-{int(time.time() * 1000)}" + + def signal_url(self) -> str: + """Return the APK-compatible WebRTC signal URL.""" + parsed = self._parsed_signal_server() + path = f"/{self.group_id}/{self.role}/{self.client_id}" + query = ( + f"traceId={self.trace_id}&time={self.time}" + f"&sign={self.sign}&name={_SIGNAL_NAME}" + ) + return urlunparse((parsed.scheme, parsed.netloc, path, "", query, "")) + + def signal_connect_options(self) -> dict[str, Any]: + """Return APK-style WebSocket connect overrides for signal IP tickets.""" + if not self.signal_server_ip_address: + return {} + parsed = urlparse(self.signal_url()) + host = parsed.hostname + if not host: + return {} + netloc = self.signal_server_ip_address + if parsed.port: + netloc = f"{netloc}:{parsed.port}" + url = urlunparse((parsed.scheme, netloc, parsed.path, "", parsed.query, "")) + return { + "url": url, + "headers": {"Host": parsed.netloc}, + "server_hostname": host, + } + + def _parsed_signal_server(self): + parsed = urlparse(self.signal_server) + if not parsed.scheme: + parsed = urlparse(f"wss://{self.signal_server}") + scheme = "wss" if parsed.scheme in {"http", "https"} else parsed.scheme + return parsed._replace(scheme=scheme) + + +class XSenseWebRTCSignalSession: + """Relay a WebRTC client SDP through the X-Sense signal server.""" + + def __init__( + self, + *, + session: aiohttp.ClientSession, + ticket: XSenseWebRTCTicket, + offer_sdp: str, + resolution: str | None, + camera_online: bool, + remote_candidate_callback: Callable[[dict[str, Any]], None] | None = None, + ) -> None: + self._session = session + self._ticket = ticket + self._offer_sdp = offer_sdp + self._resolution = resolution or _DEFAULT_RESOLUTION + self._camera_online = camera_online + self._session_id = ticket.session_id + self._recipient_client_id = ticket.serial_number + self._ws: aiohttp.ClientWebSocketResponse | None = None + self._answer: asyncio.Future[str] = asyncio.get_running_loop().create_future() + self._closed = False + self._offer_sent = False + self._camera_peer_ready = False + self._signal_event_counts: Counter[str] = Counter() + self._local_candidate_count = 0 + self._sent_candidate_count = 0 + self._offer_attempt_count = 0 + self._signal_reconnect_count = 0 + self._pending_remote_candidates: list[Any] = [] + self._pending_client_candidates: list[dict[str, Any]] = [] + self._remote_candidate_callback = remote_candidate_callback + self._forward_client_candidates = False + self._last_signal_event: str | None = None + self._read_task: asyncio.Task | None = None + self._reconnect_task: asyncio.Task | None = None + + def _debug_context(self, **extra: Any) -> dict[str, Any]: + context = _ticket_debug_context(self._ticket) + context.update( + { + "session": _short_id(self._session_id), + "recipient": _short_id(self._recipient_client_id), + "resolution": self._resolution, + "camera_online": self._camera_online, + "camera_peer_ready": self._camera_peer_ready, + "offer_sent": self._offer_sent, + "sdp_answer_received": _future_has_result(self._answer), + "last_signal_event": self._last_signal_event, + "signal_events": dict(self._signal_event_counts), + "offer_attempt_count": self._offer_attempt_count, + "signal_reconnect_count": self._signal_reconnect_count, + "local_candidate_count": self._local_candidate_count, + "sent_candidate_count": self._sent_candidate_count, + "pending_remote_candidates": len(self._pending_remote_candidates), + "pending_client_candidates": len(self._pending_client_candidates), + } + ) + context.update(extra) + return context + + async def start(self) -> str: + """Return the X-Sense SDP answer for a WebRTC client offer.""" + LOGGER.debug("X-Sense WebRTC signal relay starting: %s", self._debug_context()) + await self._connect_signal() + LOGGER.debug( + "X-Sense WebRTC waiting for PEER_IN before relay offer: %s", + self._debug_context(answer_timeout_s=_ANSWER_TIMEOUT), + ) + try: + return await asyncio.wait_for(self._answer, timeout=_ANSWER_TIMEOUT) + except Exception as err: + LOGGER.debug( + "X-Sense WebRTC signal relay failed: %s", + self._debug_context(error=_exception_debug(err)), + ) + raise + + async def close(self) -> None: + """Close the X-Sense signal connection.""" + self._closed = True + ws = self._ws + self._ws = None + if ws is not None and not ws.closed: + with suppress(Exception): + await ws.close() + if self._read_task is not None: + self._read_task.cancel() + self._read_task = None + if self._reconnect_task is not None: + self._reconnect_task.cancel() + self._reconnect_task = None + + def start_forwarding_remote_candidates(self) -> None: + """Forward queued X-Sense ICE candidates to the client callback.""" + self._forward_client_candidates = True + LOGGER.debug( + "X-Sense WebRTC signal relay forwarding queued remote candidates to client: %s", + self._debug_context( + queued_remote_candidate_count=len(self._pending_client_candidates) + ), + ) + while self._pending_client_candidates: + self._forward_remote_candidate(self._pending_client_candidates.pop(0)) + + async def add_candidate(self, candidate: Any) -> None: + """Forward a trickled WebRTC client candidate to X-Sense.""" + if self._closed: + return + payload = _candidate_init_payload(candidate) + if payload is None: + LOGGER.debug( + "X-Sense WebRTC signal relay ignored invalid client ICE candidate: %s", + self._debug_context(candidate_type=type(candidate).__name__), + ) + return + if self._ws is None or self._ws.closed or not self._offer_sent: + self._pending_remote_candidates.append(payload) + LOGGER.debug( + "X-Sense WebRTC signal relay queued client ICE candidate: %s", + self._debug_context( + queue_reason=_candidate_queue_reason(self), + **_single_candidate_debug(payload), + ), + ) + return + LOGGER.debug( + "X-Sense WebRTC signal relay sending client ICE candidate immediately: %s", + self._debug_context(**_single_candidate_debug(payload)), + ) + await self._send_candidate(payload) + + async def _connect_signal(self) -> None: + options = self._ticket.signal_connect_options() + url = options.pop("url", self._ticket.signal_url()) + self._ws = await self._session.ws_connect(url, **options) + LOGGER.debug( + "X-Sense WebRTC signal relay connected: %s", + self._debug_context(connect_host=_safe_host(url)), + ) + self._read_task = asyncio.create_task(self._read_loop()) + + async def _read_loop(self) -> None: + close_code: int | None = None + try: + ws = self._ws + assert ws is not None + async for message in ws: + if message.type not in ( + aiohttp.WSMsgType.TEXT, + aiohttp.WSMsgType.BINARY, + ): + continue + raw = message.data + event, payload = parse_signal_message(raw) + if event: + self._last_signal_event = event + self._signal_event_counts[event] += 1 + if self._should_log_signal_event(event): + LOGGER.debug( + "X-Sense WebRTC signal relay event received: %s", + self._debug_context( + event=event, payload=_payload_debug(payload) + ), + ) + await self._handle_signal_event(event, payload) + close_code = ws.close_code + except Exception as err: + if not self._answer.done(): + self._answer.set_exception(err) + LOGGER.debug( + "X-Sense WebRTC signal relay read failed: %s", + self._debug_context(error=_exception_debug(err)), + ) + finally: + LOGGER.debug( + "X-Sense WebRTC signal relay websocket closed: %s", + self._debug_context(signal_close_code=close_code), + ) + self._schedule_signal_reconnect(close_code) + + def _schedule_signal_reconnect(self, close_code: int | None) -> None: + if self._closed or self._answer.done(): + return + if close_code in _SIGNAL_TERMINAL_CLOSE_CODES: + return + if self._reconnect_task is not None and not self._reconnect_task.done(): + return + LOGGER.debug( + "X-Sense WebRTC signal relay scheduling reconnect: %s", + self._debug_context( + signal_close_code=close_code, + reconnect_delay_s=_SIGNAL_RECONNECT_DELAY, + ), + ) + self._reconnect_task = asyncio.create_task(self._reconnect_signal()) + + async def _reconnect_signal(self) -> None: + await asyncio.sleep(_SIGNAL_RECONNECT_DELAY) + if self._closed or self._answer.done(): + return + self._reset_offer_attempt("signal_reconnect") + self._camera_peer_ready = False + with suppress(Exception): + await self.close_signal_only() + self._signal_reconnect_count += 1 + LOGGER.debug( + "X-Sense WebRTC signal relay reconnecting signal socket: %s", + self._debug_context(reconnect_reason="signal_closed_before_answer"), + ) + try: + await self._connect_signal() + except Exception as err: + LOGGER.debug( + "X-Sense WebRTC signal relay reconnect failed: %s", + self._debug_context(error=_exception_debug(err)), + ) + if not self._answer.done(): + self._answer.set_exception(err) + + async def close_signal_only(self) -> None: + """Close only the current signal websocket before reconnecting.""" + ws = self._ws + self._ws = None + if ws is not None and not ws.closed: + await ws.close() + + async def _handle_signal_event(self, event: str | None, payload: Any) -> None: + if self._closed: + return + if event == "PEER_IN": + if _is_owned_peer_message(payload, self._ticket.serial_number): + self._recipient_client_id = ( + _matching_peer_id(payload, self._ticket.serial_number) + or self._ticket.serial_number + ) + should_log_peer = ( + not self._camera_peer_ready + or not self._offer_sent + or self._should_log_signal_event(event) + ) + self._camera_peer_ready = True + if should_log_peer: + LOGGER.debug( + "X-Sense WebRTC signal relay matched camera peer: %s", + self._debug_context( + event=event, + **_peer_event_debug(payload, self._ticket.serial_number), + ), + ) + await self._send_offer() + else: + LOGGER.debug( + "X-Sense WebRTC signal relay ignored peer in for other client: %s", + self._debug_context( + event=event, + **_peer_event_debug(payload, self._ticket.serial_number), + ), + ) + return + if event == "PEER_OUT": + if _is_owned_peer_message(payload, self._ticket.serial_number): + self._camera_peer_ready = False + if not _future_has_result(self._answer): + self._reset_offer_attempt("peer_out_before_answer") + LOGGER.debug( + "X-Sense WebRTC signal relay reset offer after peer out before answer: %s", + self._debug_context( + event=event, + **_peer_event_debug(payload, self._ticket.serial_number), + ), + ) + else: + LOGGER.debug( + "X-Sense WebRTC signal relay ignored peer out for other client: %s", + self._debug_context( + event=event, + **_peer_event_debug(payload, self._ticket.serial_number), + ), + ) + return + if event == "SDP_ANSWER": + answer = _owned_answer_sdp(payload, self._ticket) + if answer: + answer, normalize_context = _normalize_answer_sdp( + answer, self._offer_sdp + ) + LOGGER.debug( + "X-Sense WebRTC signal relay received SDP answer: %s", + self._debug_context( + answer_sdp=_sdp_debug(answer), + answer_normalization=normalize_context, + ), + ) + if not self._answer.done(): + self._answer.set_result(answer) + else: + LOGGER.debug( + "X-Sense WebRTC signal relay ignored SDP answer: %s", + self._debug_context( + payload=_payload_debug(payload), + reason=_answer_reject_reason(payload, self._ticket), + ), + ) + return + if event == "ICE_CANDIDATE": + candidate = _remote_candidate_init_payload(payload) + if candidate is None: + LOGGER.debug( + "X-Sense WebRTC signal relay ignored invalid remote ICE candidate: %s", + self._debug_context(payload=_payload_debug(payload)), + ) + return + if self._forward_client_candidates: + self._forward_remote_candidate(candidate) + else: + self._pending_client_candidates.append(candidate) + LOGGER.debug( + "X-Sense WebRTC signal relay queued remote ICE candidate for client: %s", + self._debug_context(), + ) + + def _forward_remote_candidate(self, candidate: dict[str, Any]) -> None: + if self._remote_candidate_callback is None: + LOGGER.debug( + "X-Sense WebRTC signal relay dropped remote ICE candidate without client callback: %s", + self._debug_context(**_single_candidate_debug(candidate)), + ) + return + LOGGER.debug( + "X-Sense WebRTC signal relay forwarding remote ICE candidate to client: %s", + self._debug_context(**_single_candidate_debug(candidate)), + ) + self._remote_candidate_callback(candidate) + + async def _send_offer(self) -> None: + if self._closed or self._offer_sent or self._ws is None or self._ws.closed: + LOGGER.debug( + "X-Sense WebRTC signal relay skipped SDP offer send: %s", + self._debug_context(skip_reason=_offer_skip_reason(self)), + ) + return + self._offer_attempt_count += 1 + offer = make_sdp_offer_payload( + offer_sdp=self._offer_sdp, + ticket=self._ticket, + recipient_client_id=self._recipient_client_id, + session_id=self._session_id, + resolution=self._resolution, + ) + relay_offer_sdp, relay_offer_context = _relay_offer_sdp(self._offer_sdp) + LOGGER.debug( + "X-Sense WebRTC signal relay sending SDP offer: %s", + self._debug_context( + offer_sdp=_sdp_debug(self._offer_sdp), + relay_offer_sdp=_sdp_debug(relay_offer_sdp), + relay_offer_normalization=relay_offer_context, + offer_envelope=_signal_envelope_debug(offer), + ), + ) + await self._ws.send_str(offer) + self._offer_sent = True + + candidates = _local_sdp_candidates(self._offer_sdp) + self._local_candidate_count = len(candidates) + LOGGER.debug( + "X-Sense WebRTC signal relay sending local ICE candidates: %s", + self._debug_context(**_candidate_debug_summary(candidates)), + ) + for candidate in candidates: + await self._send_candidate(candidate) + await self._flush_pending_remote_candidates() + + async def _flush_pending_remote_candidates(self) -> None: + """Send any client candidates that arrived before the X-Sense offer was sent.""" + while self._pending_remote_candidates and not self._closed: + await self._send_candidate(self._pending_remote_candidates.pop(0)) + + async def _send_candidate(self, candidate: dict[str, Any]) -> None: + if self._ws is None or self._ws.closed: + return + await self._ws.send_str( + make_ice_candidate_payload( + candidate=candidate["candidate"], + sdp_mid=candidate.get("sdpMid"), + sdp_m_line_index=candidate["sdpMLineIndex"], + ticket=self._ticket, + recipient_client_id=self._recipient_client_id, + session_id=self._session_id, + ) + ) + self._sent_candidate_count += 1 + LOGGER.debug( + "X-Sense WebRTC signal relay sent client ICE candidate to X-Sense: %s", + self._debug_context(**_single_candidate_debug(candidate)), + ) + + def _reset_offer_attempt(self, reason: str) -> None: + self._offer_sent = False + self._local_candidate_count = 0 + self._sent_candidate_count = 0 + LOGGER.debug( + "X-Sense WebRTC signal relay offer attempt reset: %s", + self._debug_context(reset_reason=reason), + ) + + def _should_log_signal_event(self, event: str | None) -> bool: + """Return whether this signal event should emit a debug line.""" + if event in {"SDP_ANSWER", "ICE_CANDIDATE"}: + return True + if event not in {"PEER_IN", "PEER_OUT"}: + return True + count = self._signal_event_counts.get(event, 0) + return count <= 3 or count in {5, 10, 25, 50, 100} + + +def make_sdp_offer_payload( + *, + offer_sdp: str, + ticket: XSenseWebRTCTicket, + recipient_client_id: str, + session_id: str, + resolution: str | None, +) -> str: + """Return the APK-compatible SDP offer envelope.""" + relay_sdp = _relay_offer_sdp(offer_sdp)[0] + payload = _b64_json({"type": "offer", "sdp": relay_sdp}) + envelope: dict[str, Any] = { + "messageType": "SDP_OFFER", + "messagePayload": payload, + "mode": SIGNAL_MODE, + "recipientClientId": recipient_client_id, + "senderClientId": ticket.client_id, + "sessionId": session_id, + "viewerType": SIGNAL_VIEWER_TYPE, + } + if resolution: + envelope["resolution"] = resolution + return json.dumps(envelope, separators=(",", ":")) + + +def make_ice_candidate_payload( + *, + candidate: str, + sdp_mid: str | None, + sdp_m_line_index: int, + ticket: XSenseWebRTCTicket, + recipient_client_id: str, + session_id: str, +) -> str: + """Return the APK-compatible ICE_CANDIDATE signal envelope.""" + payload = _b64_json( + { + "sdpMid": sdp_mid, + "sdpMLineIndex": sdp_m_line_index, + "candidate": candidate, + } + ) + envelope: dict[str, Any] = { + "messageType": "ICE_CANDIDATE", + "messagePayload": payload, + "recipientClientId": recipient_client_id, + "senderClientId": ticket.client_id, + "sessionId": session_id, + } + return json.dumps(envelope, separators=(",", ":")) + + +def parse_signal_message(raw: str | bytes) -> tuple[str | None, Any]: + """Parse a signal-server message into the APK event callback shape.""" + if isinstance(raw, bytes): + raw = raw.decode(errors="ignore") + try: + data = json.loads(raw) + except (TypeError, json.JSONDecodeError): + return None, raw + + event = ( + data.get("messageType") + or data.get("event") + or data.get("type") + or data.get("method") + ) + payload: Any = _signal_payload(data) + if isinstance(payload, str) and event == "SDP_ANSWER": + with suppress(Exception): + decoded = json.loads(payload) + if isinstance(decoded, dict): + payload = decoded + if isinstance(payload, str): + payload = data + elif isinstance(payload, str) and event == "ICE_CANDIDATE": + with suppress(Exception): + payload = json.loads(payload) + if isinstance(payload, str): + with suppress(Exception): + payload = json.loads(_base64_decode_required_text(payload)) + if event in {"PEER_IN", "PEER_OUT"}: + payload = _signal_peer_payload(payload) + return event, payload + + +def _signal_payload(data: dict[str, Any]) -> Any: + for key in ("messagePayload", "payload", "data", "message", "body", "value"): + if key in data: + return data[key] + return data + + +def _signal_peer_payload(payload: Any) -> Any: + if isinstance(payload, str): + payload = _decode_signal_peer_payload(payload) + return payload + + +def _decode_signal_peer_payload(payload: str) -> Any: + with suppress(Exception): + return json.loads(payload) + if not _looks_like_encoded_peer_payload(payload): + return payload + decoded = _base64_decode_text(payload) + if decoded: + with suppress(Exception): + return json.loads(decoded) + return decoded + return payload + + +def _looks_like_encoded_peer_payload(value: str) -> bool: + return any(char in value for char in "=+/") or value.startswith("eyJ") + + +def _answer_sdp(payload: Any) -> str | None: + if not isinstance(payload, dict): + return None + encoded = payload.get("messagePayload") + if not isinstance(encoded, str): + return None + with suppress(Exception): + decoded = json.loads(_base64_decode_required_text(encoded)) + sdp = decoded.get("sdp") + if isinstance(sdp, str): + return sdp + return None + + +def _owned_answer_sdp(payload: Any, ticket: XSenseWebRTCTicket) -> str | None: + """Return the SDP answer only when it belongs to this APK-style session.""" + if not isinstance(payload, dict): + return None + sender = payload.get("senderClientId") + recipient = payload.get("recipientClientId") + if sender != ticket.serial_number: + return None + if recipient != ticket.client_id: + return None + return _answer_sdp(payload) + + +def _answer_reject_reason(payload: Any, ticket: XSenseWebRTCTicket) -> str: + if not isinstance(payload, dict): + return "payload_not_dict" + sender = payload.get("senderClientId") + recipient = payload.get("recipientClientId") + if sender != ticket.serial_number: + return "sender_mismatch" + if recipient != ticket.client_id: + return "recipient_mismatch" + encoded = payload.get("messagePayload") + if not isinstance(encoded, str): + return "missing_message_payload" + with suppress(Exception): + decoded = json.loads(_base64_decode_required_text(encoded)) + if isinstance(decoded.get("sdp"), str): + return "accepted" + return "invalid_sdp_payload" + + +def _is_owned_peer_message(payload: Any, serial_number: str) -> bool: + return _matching_peer_id(payload, serial_number) is not None + + +def _matching_peer_id(payload: Any, serial_number: str) -> str | None: + for value in _peer_payload_candidates(payload): + if value == serial_number: + return value + return None + + +def _peer_payload_candidates(payload: Any) -> list[str]: + if isinstance(payload, str): + return [payload.strip()] + if not isinstance(payload, dict): + return [] + candidates: list[str] = [] + for key in ( + "clientId", + "serialNumber", + "deviceSn", + "deviceSN", + "sn", + "id", + "name", + ): + value = payload.get(key) + if value in (None, ""): + continue + text = str(value).strip() + if text and text not in candidates: + candidates.append(text) + return candidates + + +def _local_sdp_candidates(sdp: str) -> list[dict[str, Any]]: + """Return APK-style candidate payloads from a gathered local SDP.""" + candidates: list[dict[str, Any]] = [] + current_mid: str | None = None + current_index = -1 + for raw_line in sdp.splitlines(): + line = raw_line.strip() + if line.startswith("m="): + current_index += 1 + current_mid = None + elif line.startswith("a=mid:"): + current_mid = line.removeprefix("a=mid:") + elif line.startswith("a=candidate:"): + candidate = line.removeprefix("a=") + if not _is_apk_supported_local_candidate(candidate): + continue + candidates.append( + { + "sdpMid": current_mid, + "sdpMLineIndex": current_index, + "candidate": candidate, + } + ) + return candidates + + +def _candidate_init_payload(candidate: Any) -> dict[str, Any] | None: + value = getattr(candidate, "candidate", None) + if not isinstance(value, str) or not value: + return None + return { + "sdpMid": getattr(candidate, "sdp_mid", None), + "sdpMLineIndex": int(getattr(candidate, "sdp_m_line_index", 0) or 0), + "candidate": value, + } + + +def _remote_candidate_init_payload(payload: Any) -> dict[str, Any] | None: + if not isinstance(payload, dict): + return None + value = payload.get("candidate") + if not isinstance(value, str) or not value: + return None + sdp_m_line_index = payload.get("sdpMLineIndex") + if sdp_m_line_index is None: + sdp_m_line_index = payload.get("sdp_m_line_index") + return { + "sdpMid": payload.get("sdpMid") or payload.get("sdp_mid"), + "sdpMLineIndex": int(sdp_m_line_index or 0), + "candidate": value, + } + + +def _is_apk_supported_local_candidate(candidate: str) -> bool: + parts = candidate.split() + protocol = parts[2].lower() if len(parts) > 2 else "" + return protocol != "tcp" and "127.0.0.1" not in candidate and "::1" not in candidate + + +def _sdp_without_local_candidates(sdp: str) -> str: + lines = [ + line + for line in sdp.splitlines() + if not line.startswith("a=candidate:") + and not line.startswith("a=end-of-candidates") + ] + ending = "\r\n" if "\r\n" in sdp else "\n" + return ending.join(lines) + (ending if sdp.endswith(("\r\n", "\n")) else "") + + +def _relay_offer_sdp(sdp: str) -> tuple[str, dict[str, Any]]: + """Return an X-Sense camera friendly SDP offer without changing media transport.""" + sdp = _sdp_without_local_candidates(sdp) + sections = _sdp_sections(sdp) + if not sections: + return sdp, {"sections": 0} + + normalized_sections: list[list[str]] = [sections[0]] + context: dict[str, Any] = { + "sections": len(sections), + "audio_removed_payloads": 0, + "video_removed_payloads": 0, + "audio_kept_payloads": 0, + "video_kept_payloads": 0, + } + for section in sections[1:]: + normalized, section_context = _normalize_offer_media_section(section) + normalized_sections.append(normalized) + kind = section_context.get("kind") + if kind in {"audio", "video"}: + context[f"{kind}_removed_payloads"] += section_context[ + "removed_payloads" + ] + context[f"{kind}_kept_payloads"] += section_context["kept_payloads"] + return "".join(line for section in normalized_sections for line in section), context + + +def _sdp_sections(sdp: str) -> list[list[str]]: + sections: list[list[str]] = [[]] + for line in sdp.splitlines(keepends=True): + if line.startswith("m="): + sections.append([line]) + else: + sections[-1].append(line) + return [section for section in sections if section] + + +def _normalize_offer_media_section( + section: list[str], +) -> tuple[list[str], dict[str, Any]]: + if not section or not section[0].startswith("m="): + return section, {"kind": None, "removed_payloads": 0, "kept_payloads": 0} + media_line = section[0].rstrip("\r\n") + parts = media_line.split() + if len(parts) < 4: + return section, {"kind": None, "removed_payloads": 0, "kept_payloads": 0} + kind = parts[0].removeprefix("m=") + payloads = parts[3:] + if kind == "application": + return section, {"kind": kind, "removed_payloads": 0, "kept_payloads": 0} + if kind not in {"audio", "video"}: + return section, { + "kind": kind, + "removed_payloads": 0, + "kept_payloads": len(payloads), + } + + codec_by_payload = _payload_codecs(section) + allowed_payloads = [ + payload + for payload in payloads + if _offer_payload_allowed(kind, payload, codec_by_payload) + ] + if not allowed_payloads: + return section, { + "kind": kind, + "removed_payloads": 0, + "kept_payloads": len(payloads), + } + + allowed = set(allowed_payloads) + normalized = [_replace_media_payloads(section[0], allowed_payloads)] + for line in section[1:]: + attribute_payload = _offer_attribute_payload(line) + if attribute_payload in (None, "*") or attribute_payload in allowed: + normalized.append(line) + return normalized, { + "kind": kind, + "removed_payloads": len(payloads) - len(allowed_payloads), + "kept_payloads": len(allowed_payloads), + } + + +def _media_section_kind(section: list[str]) -> str | None: + if not section or not section[0].startswith("m="): + return None + return section[0].split(maxsplit=1)[0].removeprefix("m=") + + +def _payload_codecs(section: list[str]) -> dict[str, str]: + codecs: dict[str, str] = {} + for line in section: + if not line.startswith("a=rtpmap:"): + continue + value = line.removeprefix("a=rtpmap:").strip() + payload, _, codec = value.partition(" ") + codec_name = codec.split("/", 1)[0].upper() + if payload and codec_name: + codecs[payload] = codec_name + return codecs + + +def _offer_payload_allowed( + kind: str, payload: str, codec_by_payload: dict[str, str] +) -> bool: + codec = codec_by_payload.get(payload) + if kind == "audio": + return payload == "0" or codec == "PCMU" + if kind == "video": + return codec == "H264" + return True + + +def _replace_media_payloads(line: str, payloads: list[str]) -> str: + ending = _line_ending(line) + parts = line.rstrip("\r\n").split() + return " ".join([*parts[:3], *payloads]) + ending + + +def _line_ending(line: str) -> str: + return "\r\n" if line.endswith("\r\n") else "\n" if line.endswith("\n") else "" + + +def _offer_attribute_payload(line: str) -> str | None: + for prefix in ("a=rtpmap:", "a=fmtp:", "a=rtcp-fb:"): + if not line.startswith(prefix): + continue + value = line.removeprefix(prefix).strip() + payload = value.split(maxsplit=1)[0] + return payload.split(":", 1)[0] + return None + + +def _sdp_debug(sdp: str | None) -> dict[str, Any]: + if not isinstance(sdp, str): + return {"type": type(sdp).__name__} + lines = sdp.splitlines() + return { + "sdp_len": len(sdp), + "media": [ + line.removeprefix("m=") + for line in lines + if line.startswith("m=") + ], + "mids": [ + line.removeprefix("a=mid:") + for line in lines + if line.startswith("a=mid:") + ], + "groups": [ + line.removeprefix("a=group:") + for line in lines + if line.startswith("a=group:") + ], + "setup": [ + line.removeprefix("a=setup:") + for line in lines + if line.startswith("a=setup:") + ], + "directions": [ + line.removeprefix("a=") + for line in lines + if line in {"a=sendrecv", "a=sendonly", "a=recvonly", "a=inactive"} + ], + "ice_ufrag_count": sum(1 for line in lines if line.startswith("a=ice-ufrag:")), + "ice_pwd_count": sum(1 for line in lines if line.startswith("a=ice-pwd:")), + "fingerprint_count": sum( + 1 for line in lines if line.startswith("a=fingerprint:") + ), + "rtcp_mux_count": sum(1 for line in lines if line == "a=rtcp-mux"), + "candidate_lines": sum( + 1 for line in lines if line.startswith("a=candidate:") + ), + } + + +def _normalize_answer_sdp( + sdp: str, offer_sdp: str | None = None +) -> tuple[str, dict[str, Any]]: + """Normalize browser-rejected SDP answer attributes without changing media.""" + setup_actpass_replaced = 0 + sendrecv_replaced = 0 + recvonly_offer_mids = _offer_recvonly_media_mids(offer_sdp) + normalized_sections: list[list[str]] = [] + for section in _sdp_sections(sdp): + normalized_section: list[str] = [] + media_kind = _media_section_kind(section) + mid = _media_section_mid(section) + for line in section: + stripped = line.rstrip("\r\n") + ending = _line_ending(line) + if stripped == "a=setup:actpass": + normalized_section.append(f"a=setup:passive{ending}") + setup_actpass_replaced += 1 + elif ( + media_kind in {"audio", "video"} + and stripped == "a=sendrecv" + and ( + (recvonly_offer_mids is None) + or (mid is not None and mid in recvonly_offer_mids) + ) + ): + normalized_section.append(f"a=sendonly{ending}") + sendrecv_replaced += 1 + else: + normalized_section.append(line) + normalized_sections.append(normalized_section) + return "".join(line for section in normalized_sections for line in section), { + "setup_actpass_replaced": setup_actpass_replaced, + "sendrecv_replaced": sendrecv_replaced, + } + + +def _offer_recvonly_media_mids(sdp: str | None) -> set[str] | None: + if sdp is None: + return None + mids: set[str] = set() + for section in _sdp_sections(sdp): + if _media_section_kind(section) not in {"audio", "video"}: + continue + if _media_section_direction(section) != "recvonly": + continue + mid = _media_section_mid(section) + if mid is not None: + mids.add(mid) + return mids + + +def _media_section_mid(section: list[str]) -> str | None: + for line in section: + if line.startswith("a=mid:"): + return line.removeprefix("a=mid:").strip() + return None + + +def _media_section_direction(section: list[str]) -> str | None: + for line in section: + stripped = line.rstrip("\r\n") + if stripped in {"a=sendrecv", "a=sendonly", "a=recvonly", "a=inactive"}: + return stripped.removeprefix("a=") + return None + + +def _candidate_debug_summary(candidates: list[dict[str, Any]]) -> dict[str, Any]: + protocols: Counter[str] = Counter() + candidate_types: Counter[str] = Counter() + mids: Counter[str] = Counter() + for candidate in candidates: + parts = str(candidate.get("candidate", "")).split() + if len(parts) >= 3: + protocols[parts[2].lower()] += 1 + if "typ" in parts: + index = parts.index("typ") + if index + 1 < len(parts): + candidate_types[parts[index + 1]] += 1 + mids[str(candidate.get("sdpMid"))] += 1 + return { + "candidate_count": len(candidates), + "candidate_protocols": dict(protocols), + "candidate_types": dict(candidate_types), + "candidate_mids": dict(mids), + } + + +def _single_candidate_debug(candidate: dict[str, Any]) -> dict[str, Any]: + return _candidate_debug_summary([candidate]) + + +def _candidate_queue_reason(session: XSenseWebRTCSignalSession) -> str: + if session._ws is None: + return "signal_not_connected" + if session._ws.closed: + return "signal_closed" + if not session._offer_sent: + return "waiting_for_peer_offer" + return "unknown" + + +def _offer_skip_reason(session: XSenseWebRTCSignalSession) -> str: + if session._closed: + return "session_closed" + if session._offer_sent: + return "offer_already_sent" + if session._ws is None: + return "signal_not_connected" + if session._ws.closed: + return "signal_closed" + return "unknown" + + +def _peer_event_debug(payload: Any, serial_number: str) -> dict[str, Any]: + match = _matching_peer_id(payload, serial_number) + return { + "payload": _payload_debug(payload), + "payload_fields": _debug_payload_fields(payload), + "payload_matches_camera": match is not None, + "camera": _short_id(serial_number), + "peer": _short_id(match), + "peer_candidates": [ + _short_id(value) for value in _peer_payload_candidates(payload) + ], + } + + +def _debug_payload_fields(payload: Any) -> dict[str, str | None]: + if not isinstance(payload, dict): + return {} + return { + key: _short_id(payload.get(key)) + for key in ("group", "role", "id", "name", "clientId", "serialNumber") + if payload.get(key) not in (None, "") + } + + +def _signal_envelope_debug(payload: str) -> dict[str, Any]: + with suppress(Exception): + data = json.loads(payload) + if isinstance(data, dict): + return { + "keys": sorted(str(key) for key in data.keys()), + "message_type": data.get("messageType"), + "sender": _short_id(data.get("senderClientId")), + "recipient": _short_id(data.get("recipientClientId")), + "session": _short_id(data.get("sessionId")), + "mode": data.get("mode"), + "viewer_type": data.get("viewerType"), + "has_resolution": "resolution" in data, + "payload_len": len(str(data.get("messagePayload", ""))), + } + return {"raw_len": len(payload)} + + +def _ticket_debug_context(ticket: XSenseWebRTCTicket) -> dict[str, Any]: + return { + "camera": _short_id(ticket.serial_number), + "client": _short_id(ticket.client_id), + "role": ticket.role, + "signal_host": _safe_host(ticket.signal_server), + "signal_ip_override": bool(ticket.signal_server_ip_address), + "ice_servers": len(ticket.ice_servers or []), + "signal_ping_interval": ticket.signal_ping_interval, + "ticket_expires_in_s": ( + round((ticket.expiration_time - int(time.time() * 1000)) / 1000) + if ticket.expiration_time is not None + else None + ), + } + + +def _future_has_result(future: asyncio.Future[Any]) -> bool: + if not future.done() or future.cancelled(): + return False + with suppress(asyncio.CancelledError, Exception): + return future.exception() is None + return False + + +def _exception_debug(err: BaseException) -> dict[str, Any]: + text = str(err) + return { + "type": type(err).__name__, + "message_len": len(text), + "has_message": bool(text), + } + + +def _payload_debug(payload: Any) -> str: + if isinstance(payload, dict): + keys = sorted(str(key) for key in payload.keys()) + return f"dict_keys={keys}" + if isinstance(payload, str): + return f"str:{_short_id(payload)}" + return type(payload).__name__ + + +def _base64_decode_required_text(value: str) -> str: + missing_padding = (-len(value)) % 4 + return base64.b64decode(value + ("=" * missing_padding), validate=False).decode() + + +def _base64_decode_text(value: str) -> str | None: + with suppress(Exception): + decoded = _base64_decode_required_text(value) + if decoded: + return decoded + return None + + +def _b64_json(data: dict[str, Any]) -> str: + raw = json.dumps(data, separators=(",", ":")).encode() + return base64.b64encode(raw).decode() + + +def _optional_int(value: Any) -> int | None: + if value is None or value == "": + return None + try: + return int(value) + except (TypeError, ValueError): + return None + + +def _short_id(value: Any) -> str | None: + if value in (None, ""): + return None + text = str(value) + return text if len(text) <= 6 else f"...{text[-6:]}" + + +def _safe_host(value: str | None) -> str | None: + if not value: + return None + parsed = urlparse(value if "://" in value else f"//{value}") + return parsed.hostname or parsed.path or None diff --git a/xsense/xsense.py b/xsense/xsense.py deleted file mode 100644 index 9206bff..0000000 --- a/xsense/xsense.py +++ /dev/null @@ -1,378 +0,0 @@ -from datetime import datetime, timedelta -from typing import Dict, Optional - -import requests - -from xsense.aws_signer import AWSSigner -from xsense.base import XSenseBase -from xsense.entity import Entity -from xsense.entity_map import entities -from xsense.exceptions import APIFailure, SessionExpired, NotFoundError, XSenseError -from xsense.house import House -from xsense.station import Station - - -class XSense(XSenseBase): - def __init__(self, session=None): - super().__init__() - self.session = session or requests.Session() - self._owns_session = session is None - - def close(self): - if self._owns_session: - self.session.close() - - def __enter__(self): - return self - - def __exit__(self, exc_type, exc, traceback): - self.close() - - def api_call(self, code, unauth=False, **kwargs): - data = { - **kwargs - } - - if unauth: - headers = None - mac = 'abcdefg' - else: - if self._access_token_expiring(): - self.refresh() - headers = {'Authorization': self.access_token} - mac = self._calculate_mac(data) - - res = self.session.post( - f'{self.API}/app', - json={ - **data, - "clientType": self.CLIENTYPE, - "mac": mac, - "appVersion": self.VERSION, - "bizCode": code, - "appCode": self.APPCODE, - }, - headers=headers - ) - self._lastres = res - - data = res.json() - if res.status_code >= 400: - message = data.get('message') or 'unknown error' - raise APIFailure(f'API failure: {res.status_code}/{message}') - - if 'reCode' not in data: - raise APIFailure('API failure: Cannot understand response') - - if data['reCode'] != 200: - errCode = data.get('errCode', 0) - if errCode in ('10000008', '10000020'): - raise SessionExpired(data.get('reMsg')) - raise APIFailure(f"Request for code {code} failed with error {errCode}/{data['reCode']} {data.get('reMsg')}") - - return data['reData'] - - def get_house(self, house: House, page: str): - if self._aws_token_expiring(): - self.load_aws() - - url, headers = self._house_request(house, page) - res = self.session.get(url, headers=headers) - self._lastres = res - return res.json() - - def get_thing(self, station: Station, page: str): - if self._aws_token_expiring(): - self.load_aws() - - url, headers = self._thing_request(station, page) - res = self.session.get(url, headers=headers) - self._lastres = res - return res.json() - - def do_thing(self, station: Station, page: str, data: Dict): - if self._aws_token_expiring(): - self.load_aws() - - url, headers = self._thing_request(station, page, data) - res = self.session.post(url, headers=headers, json=data) - self._lastres = res - return res.json() - - def login(self, username, password): - self.sync_login(username, password) - self.load_aws() - - def refresh(self): - url, data, headers = self._refresh_request() - - res = self.session.post( - url, - json=data, - headers=headers - ) - self._lastres = res - data = res.json() - - if res.status_code == 400: - raise SessionExpired(data.get('message', 'token refresh failed')) - - self._parse_refresh_result(data.get('AuthenticationResult', {})) - - def init(self): - self.get_client_info() - - def load_aws(self): - self.get_aws_tokens() - if self.signer: - self.signer.update(self.aws_access_key, self.aws_secret_access_key, self.aws_session_token) - else: - self.signer = AWSSigner(self.aws_access_key, self.aws_secret_access_key, self.aws_session_token) - - def load_all(self): - result = {} - for i in self.get_houses(): - h = House( - self.signer, - i['houseId'], - i['houseName'], - i['houseRegion'], - i['mqttRegion'], - i['mqttServer'] - ) - result[i['houseId']] = h - - if rooms := self.get_rooms(h.house_id): - h.set_rooms(rooms) - - if station := self.get_stations(h.house_id): - h.set_stations(station) - self.houses = result - - def get_client_info(self): - data = self.api_call("101001", unauth=True) - self.clientid = data['clientId'] - self.clientsecret = self._decode_secret(data['clientSecret']) - self.region = data['cgtRegion'] - self.userpool = data['userPoolId'] - - def get_aws_tokens(self): - data = self.api_call("101003", userName=self.username) - self.aws_access_key = data['accessKeyId'] - self.aws_secret_access_key = data['secretAccessKey'] - self.aws_session_token = data['sessionToken'] - self.aws_access_expiry = datetime.strptime(data['expiration'], "%Y-%m-%d %H:%M:%S%z") - - def get_houses(self): - params = { - 'utctimestamp': "0" - } - return self.api_call("102007", **params) - - def get_rooms(self, houseId: str): - params = { - 'houseId': houseId, - 'utctimestamp': "0" - } - return self.api_call("102008", **params) - - def get_stations(self, houseId: str): - params = { - 'houseId': houseId, - 'utctimestamp': "0" - } - return self.api_call("103007", **params) - - def get_history(self, houseId: str, dayTime: str, timeZone: str, nextToken: Optional[str] = None): - params = { - 'houseId': houseId, - 'dayTime': dayTime, - 'timeZone': timeZone, - } - if nextToken: - params['nextToken'] = nextToken - return self.api_call("104001", **params) - - def get_history_month(self, houseId: str, hisMonth: str, timeZone: str): - params = { - 'houseId': houseId, - 'hisMonth': hisMonth, - 'timeZone': timeZone, - } - return self.api_call("104006", **params) - - def get_station_history( - self, - houseId: str, - stationId: str, - dayTime: str, - timeZone: str, - deviceId: Optional[str] = None, - nextToken: Optional[str] = None, - ): - params = { - 'houseId': houseId, - 'dayTime': dayTime, - 'timeZone': timeZone, - 'stationId': stationId, - } - if deviceId: - params['deviceId'] = deviceId - if nextToken: - params['nextToken'] = nextToken - return self.api_call("104007", **params) - - def get_station_history_month( - self, - houseId: str, - stationId: str, - deviceId: str, - hisMonth: str, - timeZone: str, - ): - params = { - 'houseId': houseId, - 'hisMonth': hisMonth, - 'timeZone': timeZone, - 'stationId': stationId, - 'deviceId': deviceId, - } - return self.api_call("104008", **params) - - def get_security_history(self, serverId: str, nextToken: Optional[str] = None): - params = { - 'serverId': serverId, - } - if nextToken: - params['nextToken'] = nextToken - return self.api_call("505001", **params) - - def get_sth_history( - self, - houseId: str, - stationId: str, - lastTime: str = "", - nextToken: Optional[str] = None, - ): - params = { - 'houseId': houseId, - 'stationId': stationId, - 'lastTime': lastTime, - } - if nextToken: - params['nextToken'] = nextToken - return self.api_call("104020", **params) - - def get_co_ppm_history(self, stationId: str, timeZone: str, deviceId: Optional[str] = None): - params = { - 'stationId': stationId, - 'timeZone': timeZone, - } - if deviceId: - params['deviceId'] = deviceId - return self.api_call("104009", **params) - return self.api_call("104014", **params) - - def get_co_ppm_history_details( - self, - houseId: str, - stationId: str, - dayTime: str, - timeZone: str, - deviceId: Optional[str] = None, - ): - params = { - 'houseId': houseId, - 'stationId': stationId, - 'dayTime': dayTime, - 'timeZone': timeZone, - } - if deviceId: - params['deviceId'] = deviceId - return self.api_call("104010", **params) - return self.api_call("104015", **params) - - def get_house_state(self, house: House): - for page in ('mainpage', '2nd_mainpage'): - res = self.get_house(house, page) - - if self._lastres.status_code == 404: - continue - - if 'reported' in res.get('state', {}): - self._parse_get_house_state(house, res['state']['reported']) - # else: - # raise APIFailure(f'Unable to retrieve station data: {self._lastres.status_code}/{self._lastres.text}') - - def get_alarm_state(self, station: Station): - res = self.get_thing(station, '2nd_safemode') - - if self._lastres.status_code == 404: - return - - if 'reported' in res.get('state', {}): - station.set_alarm_data(res['state']['reported']) - - def get_station_state(self, station: Station): - res = None - if station.type not in ('SBS50', 'SC07-WX', 'XC04-WX'): - res = self.get_thing(station, f'info_{station.sn}') - - if res is None or self._lastres.status_code == 404: - res = self.get_thing(station, f'2nd_info_{station.sn}') - - if self._lastres.status_code == 404: - return - - if 'reported' in res.get('state', {}): - station.set_data(res['state']['reported']) - else: - raise APIFailure(f'Unable to retrieve station data: {self._lastres.status_code}/{self._lastres.text}') - - def get_state(self, station: Station): - if not station.devices: - return - - res = None - if station.type not in ('SBS10',): - res = self.get_thing(station, '2nd_mainpage') - - if res is None or self._lastres.status_code == 404: - res = self.get_thing(station, f'mainpage') - - if 'reported' in res.get('state', {}): - self.parse_get_state(station, res['state']['reported']) - else: - raise APIFailure(f'Unable to retrieve station data: {self._lastres.status_code}/{self._lastres.text}') - - def set_state(self, entity: Entity, shadow: str, topic: str, definition: Dict): - station = entity.station - t = datetime.now() - timestamp = t.strftime('%Y%m%d%H%M%S') - - desired = { - "deviceSN": entity.sn, - "shadow": shadow, - "stationSN": station.sn, - "time": timestamp, - "userId": self.userid - } - desired.update(definition.get('extra', {})) - - data = {"state": {"desired": desired}} - - res = self.do_thing(station, topic, data) - - def action(self, entity: Entity, action: str): - entity_def = entities.get(entity.type) - if not entity_def: - raise XSenseError(f'Entity type {entity.type} is unkown, action {action} not possible') - - action_def = next((a for a in entity_def.get('actions', []) if a.get('action') == action), None) - if not action_def: - raise XSenseError(f'Action {action} is not supported for entity type {entity.type}') - - topic = action_def.get('topic') - if callable(topic): - topic = topic(entity) - self.set_state(entity, action_def['shadow'], topic, action_def)