From bbf6414f50ec5070f5d4c7a063e79bc0bb9136c9 Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Wed, 11 Mar 2026 12:56:59 -0400 Subject: [PATCH 01/13] Premier jet Besoin de refactor --- pyhilo/api.py | 459 +++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 452 insertions(+), 7 deletions(-) diff --git a/pyhilo/api.py b/pyhilo/api.py index cdbc452..b43fe28 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -3,6 +3,7 @@ import asyncio import base64 from datetime import datetime, timedelta +import hashlib import json import random import string @@ -14,6 +15,7 @@ from aiohttp.client_exceptions import ClientResponseError import backoff from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session +import httpx from pyhilo.const import ( ANDROID_CLIENT_ENDPOINT, @@ -40,6 +42,7 @@ FB_SDK_VERSION, HILO_READING_TYPES, LOG, + PLATFORM_HOST, REQUEST_RETRY, SUBSCRIPTION_KEY, ) @@ -544,16 +547,458 @@ async def get_location_ids(self) -> tuple[int, str]: req: list[dict[str, Any]] = await self.async_request("get", url) return (req[0]["id"], req[0]["locationHiloId"]) + async def get_devices_graphql(self, location_hilo_id: str) -> list[dict[str, Any]]: + """Get list of all devices using GraphQL. + + This replaces the REST endpoint /api/Locations/{LocationId}/Devices + which is being deprecated. + + Args: + location_hilo_id: The location Hilo ID (URN) + + Returns: + List of device dictionaries in the same format as the REST endpoint + """ + # GraphQL query to fetch all devices (using existing query from graphql.py) + query = """query getLocation($locationHiloId: String!) { + getLocation(id:$locationHiloId) { + hiloId + devices { + deviceType + hiloId + physicalAddress + connectionStatus + ... on Gateway { + connectionStatus + controllerSoftwareVersion + lastConnectionTime + willBeConnectedToSmartMeter + zigBeeChannel + zigBeePairingModeEnhanced + smartMeterZigBeeChannel + smartMeterPairingStatus + } + ... on BasicSmartMeter { + zigBeeChannel + power { + value + kind + } + } + ... on LowVoltageThermostat { + coolTempSetpoint { + value + } + fanMode + fanSpeed + mode + currentState + power { + value + kind + } + ambientHumidity + gDState + ambientTemperature { + value + kind + } + ambientTempSetpoint { + value + kind + } + version + zigbeeVersion + maxAmbientCoolSetPoint { + value + kind + } + minAmbientCoolSetPoint { + value + kind + } + maxAmbientTempSetpoint { + value + kind + } + minAmbientTempSetpoint { + value + kind + } + allowedModes + fanAllowedModes + } + ... on BasicSwitch { + state + power { + value + kind + } + } + ... on BasicLight { + state + hue + level + saturation + colorTemperature + lightType + } + ... on BasicEVCharger { + status + power { + value + kind + } + } + ... on BasicChargeController { + gDState + version + zigbeeVersion + state + power { + value + kind + } + ccrMode + ccrAllowedModes + } + ... on HeatingFloorThermostat { + ambientHumidity + gDState + version + zigbeeVersion + thermostatType + floorMode + power { + value + kind + } + ambientTemperature { + value + kind + } + ambientTempSetpoint { + value + kind + } + maxAmbientTempSetpoint { + value + kind + } + minAmbientTempSetpoint { + value + kind + } + floorLimit { + value + } + } + ... on WaterHeater { + gDState + version + probeTemp { + value + kind + } + zigbeeVersion + state + ccrType + alerts + power { + value + kind + } + } + ... on BasicDimmer { + state + level + power { + value + kind + } + } + ... on BasicThermostat { + ambientHumidity + gDState + version + zigbeeVersion + ambientTemperature { + value + kind + } + ambientTempSetpoint { + value + kind + } + maxAmbientTempSetpoint { + value + kind + } + minAmbientTempSetpoint { + value + kind + } + maxAmbientTempSetpointLimit { + value + kind + } + minAmbientTempSetpointLimit { + value + kind + } + power { + value + kind + } + } + } + } + }""" + + # Get access token + access_token = await self.async_get_access_token() + url = f"https://{PLATFORM_HOST}/api/digital-twin/v3/graphql" + headers = {"Authorization": f"Bearer {access_token}"} + + # Calculate query hash for Automatic Persisted Queries (APQ) + query_hash = hashlib.sha256(query.encode("utf-8")).hexdigest() + + payload = { + "extensions": { + "persistedQuery": { + "version": 1, + "sha256Hash": query_hash, + } + }, + "variables": {"locationHiloId": location_hilo_id}, + } + + # Make GraphQL request + async with httpx.AsyncClient(http2=True) as client: + try: + response = await client.post(url, json=payload, headers=headers) + + # Don't raise yet - need to check for PersistedQueryNotFound first + response_json = response.json() + except Exception as e: + LOG.error("Unexpected error calling GraphQL API: %s", e) + raise + + # Handle Persisted Query Not Found error (can come as 400 status) + if "errors" in response_json: + for error in response_json["errors"]: + if error.get("message") == "PersistedQueryNotFound": + LOG.debug("Persisted query not found, retrying with full query") + payload["query"] = query + try: + response = await client.post(url, json=payload, headers=headers) + response.raise_for_status() + response_json = response.json() + except Exception as e: + LOG.error("Error parsing response on retry: %s", e) + raise + break + else: + # Other GraphQL errors + LOG.error("GraphQL errors: %s", response_json["errors"]) + raise Exception(f"GraphQL errors: {response_json['errors']}") + elif response.status_code != 200: + # Non-GraphQL error + error_body = response.text + LOG.error("GraphQL API returned status %d: %s", response.status_code, error_body) + response.raise_for_status() + + if "data" not in response_json: + LOG.error("No data in GraphQL response: %s", response_json) + raise Exception("No data in GraphQL response") + + # Transform GraphQL response to REST format + graphql_devices = response_json["data"]["getLocation"]["devices"] + rest_devices = [] + + for idx, gql_device in enumerate(graphql_devices, start=2): + rest_device = self._transform_graphql_device_to_rest(gql_device, idx) + if rest_device: + rest_devices.append(rest_device) + + LOG.debug("Fetched %d devices via GraphQL", len(rest_devices)) + return rest_devices + + def _transform_graphql_device_to_rest( + self, gql_device: dict[str, Any], device_id: int + ) -> dict[str, Any] | None: + """Transform a GraphQL device object to REST format. + + Args: + gql_device: Device object from GraphQL + device_id: Numeric device ID to assign + + Returns: + Device dictionary in REST format, or None if device type is Gateway + (Gateway is handled separately by get_gateway()) + """ + device_type = gql_device.get("deviceType", "Unknown") + + # Map GraphQL device types to REST device types + type_mapping = { + "Tstat": "Thermostat", + "BasicThermostat": "Thermostat", + "LowVoltageTstat": "Thermostat24V", + "HeatingFloor": "FloorThermostat", + "Cee": "Cee", + "Ccr": "Ccr", + "Switch": "LightSwitch", + "BasicSwitch": "LightSwitch", + "Dimmer": "LightDimmer", + "BasicDimmer": "LightDimmer", + "ColorBulb": "ColorBulb", + "WhiteBulb": "WhiteBulb", + "BasicLight": "WhiteBulb", + "Meter": "Meter", + "BasicSmartMeter": "Meter", + "ChargingPoint": "ChargingPoint", + "BasicEVCharger": "ChargingPoint", + "BasicChargeController": "Ccr", + "Hub": "Gateway", + } + + rest_type = type_mapping.get(device_type, device_type) + + # Skip Gateway - it's fetched separately + if rest_type == "Gateway": + return None + + # Build the device dictionary + rest_device = { + "id": device_id, + "hilo_id": gql_device.get("hiloId", ""), + "identifier": gql_device.get("physicalAddress", ""), + "type": rest_type, + "name": f"{rest_type} {device_id}", + "category": rest_type, + "supportedAttributes": "", + "settableAttributes": "", + "provider": 1, + } + + # Add all attributes from GraphQL device + supported_attrs = [] + settable_attrs = [] + + # Common attributes + if "connectionStatus" in gql_device: + rest_device["Disconnected"] = { + "value": gql_device["connectionStatus"] == 2 + } + supported_attrs.append("Disconnected") + + if "power" in gql_device and gql_device["power"]: + rest_device["Power"] = { + "value": gql_device["power"].get("value", 0) + } + supported_attrs.append("Power") + + # Thermostat attributes + if "ambientTemperature" in gql_device and gql_device["ambientTemperature"]: + rest_device["CurrentTemperature"] = { + "value": gql_device["ambientTemperature"].get("value", 0) + } + supported_attrs.append("CurrentTemperature") + + if "ambientTempSetpoint" in gql_device and gql_device["ambientTempSetpoint"]: + rest_device["TargetTemperature"] = { + "value": gql_device["ambientTempSetpoint"].get("value", 0) + } + supported_attrs.append("TargetTemperature") + settable_attrs.append("TargetTemperature") + + if "ambientHumidity" in gql_device: + rest_device["CurrentHumidity"] = { + "value": gql_device["ambientHumidity"] + } + supported_attrs.append("CurrentHumidity") + + if "mode" in gql_device: + rest_device["Mode"] = {"value": gql_device["mode"]} + supported_attrs.append("Mode") + settable_attrs.append("Mode") + + if "gDState" in gql_device: + rest_device["GDState"] = {"value": gql_device["gDState"]} + supported_attrs.append("GDState") + + # Light/Switch attributes + if "state" in gql_device: + rest_device["OnOff"] = {"value": gql_device["state"]} + supported_attrs.append("OnOff") + settable_attrs.append("OnOff") + + if "level" in gql_device: + rest_device["Intensity"] = {"value": gql_device["level"]} + supported_attrs.append("Intensity") + settable_attrs.append("Intensity") + + if "hue" in gql_device: + rest_device["Hue"] = {"value": gql_device["hue"]} + supported_attrs.append("Hue") + settable_attrs.append("Hue") + + if "saturation" in gql_device: + rest_device["Saturation"] = {"value": gql_device["saturation"]} + supported_attrs.append("Saturation") + settable_attrs.append("Saturation") + + if "colorTemperature" in gql_device: + rest_device["ColorTemperature"] = {"value": gql_device["colorTemperature"]} + supported_attrs.append("ColorTemperature") + settable_attrs.append("ColorTemperature") + + # Version info + if "version" in gql_device: + rest_device["sw_version"] = gql_device["version"] + + # Set the attributes strings + rest_device["supportedAttributes"] = ", ".join(supported_attrs) + rest_device["settableAttributes"] = ", ".join(settable_attrs) + + return rest_device + + async def get_devices(self, location_id: int) -> list[dict[str, Any]]: - """Get list of all devices""" - url = self._get_url("Devices", location_id=location_id) - LOG.debug("Devices URL is %s", url) - devices: list[dict[str, Any]] = await self.async_request("get", url) + """Get list of all devices. + + Now uses GraphQL instead of the deprecated REST endpoint. + Falls back to REST if GraphQL fails or URN is not available. + """ + devices: list[dict[str, Any]] = [] + + # Try GraphQL first if we have a URN + if self.urn: + try: + LOG.debug("Fetching devices via GraphQL for URN: %s", self.urn) + devices = await self.get_devices_graphql(self.urn) + except Exception as e: + LOG.warning( + "GraphQL device fetch failed, falling back to REST: %s", e + ) + # Fallback to REST + url = self._get_url("Devices", location_id=location_id) + LOG.debug("Devices URL is %s", url) + devices = await self.async_request("get", url) + else: + # No URN available, use REST + LOG.debug("No URN available, using REST endpoint") + url = self._get_url("Devices", location_id=location_id) + LOG.debug("Devices URL is %s", url) + devices = await self.async_request("get", url) + + # Add gateway device (still uses REST endpoint) devices.append(await self.get_gateway(location_id)) - # Now it's time to add devices coming from external sources like hass - # integration. + + # Add devices from external callbacks for callback in self._get_device_callbacks: devices.append(callback()) + return devices async def _set_device_attribute( @@ -764,4 +1209,4 @@ async def get_weather(self, location_id: int) -> dict[str, Any]: LOG.debug("Weather URL is %s", url) response = await self.async_request("get", url) LOG.debug("Weather API response: %s", response) - return cast(dict[str, Any], response) + return cast(dict[str, Any], response) \ No newline at end of file From 46fab302dbe065d0a0dc62aa0b713f20cf89f209 Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Wed, 11 Mar 2026 13:26:54 -0400 Subject: [PATCH 02/13] Update api.py --- pyhilo/api.py | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/pyhilo/api.py b/pyhilo/api.py index b43fe28..93cf0f2 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -874,7 +874,7 @@ def _transform_graphql_device_to_rest( "hilo_id": gql_device.get("hiloId", ""), "identifier": gql_device.get("physicalAddress", ""), "type": rest_type, - "name": f"{rest_type} {device_id}", + "name": gql_device.get("name", f"{rest_type} {device_id}"), # Use GraphQL name or fallback "category": rest_type, "supportedAttributes": "", "settableAttributes": "", @@ -977,6 +977,43 @@ async def get_devices(self, location_id: int) -> list[dict[str, Any]]: try: LOG.debug("Fetching devices via GraphQL for URN: %s", self.urn) devices = await self.get_devices_graphql(self.urn) + + # WORKAROUND: Fetch REST device IDs and names for attribute setting + # This is needed because: + # 1. The attribute endpoint still uses numeric IDs + # 2. GraphQL doesn't provide device names in the base query + try: + url = self._get_url("Devices", location_id=location_id) + rest_devices = await self.async_request("get", url) + + # Build mapping of identifier -> (id, name) + device_mapping = { + d.get("identifier"): { + "id": d.get("id"), + "name": d.get("name", "") + } + for d in rest_devices if d.get("identifier") + } + + # Update GraphQL devices with real REST IDs and names + for device in devices: + identifier = device.get("identifier") + if identifier in device_mapping: + device["id"] = device_mapping[identifier]["id"] + # Only update name if GraphQL didn't provide one + if not device.get("name") or device.get("name").startswith(device.get("type", "")): + device["name"] = device_mapping[identifier]["name"] + LOG.debug( + "Mapped device %s (id=%d, name=%s)", + identifier, + device["id"], + device.get("name") + ) + + except Exception as e: + LOG.warning("Failed to fetch device ID/name mapping from REST: %s", e) + # Continue without mapping - devices will work read-only + except Exception as e: LOG.warning( "GraphQL device fetch failed, falling back to REST: %s", e From 24ca5ba20221b4ebd7bb7bba8ea6c6edf7ea702f Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Wed, 11 Mar 2026 17:51:50 -0400 Subject: [PATCH 03/13] Refactor --- pyhilo/api.py | 401 ++++++++++++++------------------------------------ 1 file changed, 109 insertions(+), 292 deletions(-) diff --git a/pyhilo/api.py b/pyhilo/api.py index 93cf0f2..c98292a 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -547,243 +547,45 @@ async def get_location_ids(self) -> tuple[int, str]: req: list[dict[str, Any]] = await self.async_request("get", url) return (req[0]["id"], req[0]["locationHiloId"]) - async def get_devices_graphql(self, location_hilo_id: str) -> list[dict[str, Any]]: - """Get list of all devices using GraphQL. - - This replaces the REST endpoint /api/Locations/{LocationId}/Devices - which is being deprecated. - + async def _call_graphql_query( + self, query: str, variables: dict[str, Any] + ) -> dict[str, Any]: + """Execute a GraphQL query and return the raw response data. + + This is a simplified helper that returns raw GraphQL data without + going through the GraphqlValueMapper. Used for get_devices migration. + Args: - location_hilo_id: The location Hilo ID (URN) - + query: GraphQL query string + variables: Query variables + Returns: - List of device dictionaries in the same format as the REST endpoint + Raw GraphQL response data """ - # GraphQL query to fetch all devices (using existing query from graphql.py) - query = """query getLocation($locationHiloId: String!) { - getLocation(id:$locationHiloId) { - hiloId - devices { - deviceType - hiloId - physicalAddress - connectionStatus - ... on Gateway { - connectionStatus - controllerSoftwareVersion - lastConnectionTime - willBeConnectedToSmartMeter - zigBeeChannel - zigBeePairingModeEnhanced - smartMeterZigBeeChannel - smartMeterPairingStatus - } - ... on BasicSmartMeter { - zigBeeChannel - power { - value - kind - } - } - ... on LowVoltageThermostat { - coolTempSetpoint { - value - } - fanMode - fanSpeed - mode - currentState - power { - value - kind - } - ambientHumidity - gDState - ambientTemperature { - value - kind - } - ambientTempSetpoint { - value - kind - } - version - zigbeeVersion - maxAmbientCoolSetPoint { - value - kind - } - minAmbientCoolSetPoint { - value - kind - } - maxAmbientTempSetpoint { - value - kind - } - minAmbientTempSetpoint { - value - kind - } - allowedModes - fanAllowedModes - } - ... on BasicSwitch { - state - power { - value - kind - } - } - ... on BasicLight { - state - hue - level - saturation - colorTemperature - lightType - } - ... on BasicEVCharger { - status - power { - value - kind - } - } - ... on BasicChargeController { - gDState - version - zigbeeVersion - state - power { - value - kind - } - ccrMode - ccrAllowedModes - } - ... on HeatingFloorThermostat { - ambientHumidity - gDState - version - zigbeeVersion - thermostatType - floorMode - power { - value - kind - } - ambientTemperature { - value - kind - } - ambientTempSetpoint { - value - kind - } - maxAmbientTempSetpoint { - value - kind - } - minAmbientTempSetpoint { - value - kind - } - floorLimit { - value - } - } - ... on WaterHeater { - gDState - version - probeTemp { - value - kind - } - zigbeeVersion - state - ccrType - alerts - power { - value - kind - } - } - ... on BasicDimmer { - state - level - power { - value - kind - } - } - ... on BasicThermostat { - ambientHumidity - gDState - version - zigbeeVersion - ambientTemperature { - value - kind - } - ambientTempSetpoint { - value - kind - } - maxAmbientTempSetpoint { - value - kind - } - minAmbientTempSetpoint { - value - kind - } - maxAmbientTempSetpointLimit { - value - kind - } - minAmbientTempSetpointLimit { - value - kind - } - power { - value - kind - } - } - } - } - }""" - - # Get access token access_token = await self.async_get_access_token() url = f"https://{PLATFORM_HOST}/api/digital-twin/v3/graphql" headers = {"Authorization": f"Bearer {access_token}"} - - # Calculate query hash for Automatic Persisted Queries (APQ) + query_hash = hashlib.sha256(query.encode("utf-8")).hexdigest() - - payload = { + + payload: dict[str, Any] = { "extensions": { "persistedQuery": { "version": 1, "sha256Hash": query_hash, } }, - "variables": {"locationHiloId": location_hilo_id}, + "variables": variables, } - - # Make GraphQL request + async with httpx.AsyncClient(http2=True) as client: try: response = await client.post(url, json=payload, headers=headers) - - # Don't raise yet - need to check for PersistedQueryNotFound first response_json = response.json() except Exception as e: LOG.error("Unexpected error calling GraphQL API: %s", e) raise - + # Handle Persisted Query Not Found error (can come as 400 status) if "errors" in response_json: for error in response_json["errors"]: @@ -791,7 +593,9 @@ async def get_devices_graphql(self, location_hilo_id: str) -> list[dict[str, Any LOG.debug("Persisted query not found, retrying with full query") payload["query"] = query try: - response = await client.post(url, json=payload, headers=headers) + response = await client.post( + url, json=payload, headers=headers + ) response.raise_for_status() response_json = response.json() except Exception as e: @@ -805,22 +609,52 @@ async def get_devices_graphql(self, location_hilo_id: str) -> list[dict[str, Any elif response.status_code != 200: # Non-GraphQL error error_body = response.text - LOG.error("GraphQL API returned status %d: %s", response.status_code, error_body) + LOG.error( + "GraphQL API returned status %d: %s", + response.status_code, + error_body, + ) response.raise_for_status() - + if "data" not in response_json: LOG.error("No data in GraphQL response: %s", response_json) raise Exception("No data in GraphQL response") - + + return cast(dict[str, Any], response_json["data"]) + + async def get_devices_graphql(self, location_hilo_id: str) -> list[dict[str, Any]]: + """Get list of all devices using GraphQL. + + This replaces the REST endpoint /api/Locations/{LocationId}/Devices + which is being deprecated. + + Uses the existing QUERY_GET_LOCATION from GraphQlHelper to avoid duplication. + + Args: + location_hilo_id: The location Hilo ID (URN) + + Returns: + List of device dictionaries in the same format as the REST endpoint + """ + from pyhilo.graphql import GraphQlHelper + + # Use the existing comprehensive GraphQL query from GraphQlHelper + query = GraphQlHelper.QUERY_GET_LOCATION + + # Call GraphQL using our helper + data = await self._call_graphql_query( + query, {"locationHiloId": location_hilo_id} + ) + # Transform GraphQL response to REST format - graphql_devices = response_json["data"]["getLocation"]["devices"] + graphql_devices = data["getLocation"]["devices"] rest_devices = [] - + for idx, gql_device in enumerate(graphql_devices, start=2): rest_device = self._transform_graphql_device_to_rest(gql_device, idx) if rest_device: rest_devices.append(rest_device) - + LOG.debug("Fetched %d devices via GraphQL", len(rest_devices)) return rest_devices @@ -828,17 +662,17 @@ def _transform_graphql_device_to_rest( self, gql_device: dict[str, Any], device_id: int ) -> dict[str, Any] | None: """Transform a GraphQL device object to REST format. - + Args: gql_device: Device object from GraphQL device_id: Numeric device ID to assign - + Returns: Device dictionary in REST format, or None if device type is Gateway (Gateway is handled separately by get_gateway()) """ device_type = gql_device.get("deviceType", "Unknown") - + # Map GraphQL device types to REST device types type_mapping = { "Tstat": "Thermostat", @@ -861,163 +695,146 @@ def _transform_graphql_device_to_rest( "BasicChargeController": "Ccr", "Hub": "Gateway", } - + rest_type = type_mapping.get(device_type, device_type) - + # Skip Gateway - it's fetched separately if rest_type == "Gateway": return None - + # Build the device dictionary rest_device = { "id": device_id, "hilo_id": gql_device.get("hiloId", ""), "identifier": gql_device.get("physicalAddress", ""), "type": rest_type, - "name": gql_device.get("name", f"{rest_type} {device_id}"), # Use GraphQL name or fallback + "name": gql_device.get( + "name", f"{rest_type} {device_id}" + ), # Use GraphQL name or fallback "category": rest_type, "supportedAttributes": "", "settableAttributes": "", "provider": 1, } - + # Add all attributes from GraphQL device supported_attrs = [] settable_attrs = [] - + # Common attributes if "connectionStatus" in gql_device: - rest_device["Disconnected"] = { - "value": gql_device["connectionStatus"] == 2 - } + rest_device["Disconnected"] = {"value": gql_device["connectionStatus"] == 2} supported_attrs.append("Disconnected") - + if "power" in gql_device and gql_device["power"]: - rest_device["Power"] = { - "value": gql_device["power"].get("value", 0) - } + rest_device["Power"] = {"value": gql_device["power"].get("value", 0)} supported_attrs.append("Power") - + # Thermostat attributes if "ambientTemperature" in gql_device and gql_device["ambientTemperature"]: rest_device["CurrentTemperature"] = { "value": gql_device["ambientTemperature"].get("value", 0) } supported_attrs.append("CurrentTemperature") - + if "ambientTempSetpoint" in gql_device and gql_device["ambientTempSetpoint"]: rest_device["TargetTemperature"] = { "value": gql_device["ambientTempSetpoint"].get("value", 0) } supported_attrs.append("TargetTemperature") settable_attrs.append("TargetTemperature") - + if "ambientHumidity" in gql_device: - rest_device["CurrentHumidity"] = { - "value": gql_device["ambientHumidity"] - } + rest_device["CurrentHumidity"] = {"value": gql_device["ambientHumidity"]} supported_attrs.append("CurrentHumidity") - + if "mode" in gql_device: rest_device["Mode"] = {"value": gql_device["mode"]} supported_attrs.append("Mode") settable_attrs.append("Mode") - + if "gDState" in gql_device: rest_device["GDState"] = {"value": gql_device["gDState"]} supported_attrs.append("GDState") - + # Light/Switch attributes if "state" in gql_device: rest_device["OnOff"] = {"value": gql_device["state"]} supported_attrs.append("OnOff") settable_attrs.append("OnOff") - + if "level" in gql_device: rest_device["Intensity"] = {"value": gql_device["level"]} supported_attrs.append("Intensity") settable_attrs.append("Intensity") - + if "hue" in gql_device: rest_device["Hue"] = {"value": gql_device["hue"]} supported_attrs.append("Hue") settable_attrs.append("Hue") - + if "saturation" in gql_device: rest_device["Saturation"] = {"value": gql_device["saturation"]} supported_attrs.append("Saturation") settable_attrs.append("Saturation") - + if "colorTemperature" in gql_device: rest_device["ColorTemperature"] = {"value": gql_device["colorTemperature"]} supported_attrs.append("ColorTemperature") settable_attrs.append("ColorTemperature") - + # Version info if "version" in gql_device: rest_device["sw_version"] = gql_device["version"] - + # Set the attributes strings rest_device["supportedAttributes"] = ", ".join(supported_attrs) rest_device["settableAttributes"] = ", ".join(settable_attrs) - - return rest_device + return rest_device async def get_devices(self, location_id: int) -> list[dict[str, Any]]: """Get list of all devices. - + Now uses GraphQL instead of the deprecated REST endpoint. Falls back to REST if GraphQL fails or URN is not available. """ devices: list[dict[str, Any]] = [] - + # Try GraphQL first if we have a URN if self.urn: try: LOG.debug("Fetching devices via GraphQL for URN: %s", self.urn) devices = await self.get_devices_graphql(self.urn) - - # WORKAROUND: Fetch REST device IDs and names for attribute setting - # This is needed because: - # 1. The attribute endpoint still uses numeric IDs - # 2. GraphQL doesn't provide device names in the base query + + # WORKAROUND: Fetch REST device IDs for attribute setting + # This is needed because the attribute endpoint still uses numeric IDs try: url = self._get_url("Devices", location_id=location_id) rest_devices = await self.async_request("get", url) - - # Build mapping of identifier -> (id, name) - device_mapping = { - d.get("identifier"): { - "id": d.get("id"), - "name": d.get("name", "") - } - for d in rest_devices if d.get("identifier") + + # Build mapping of identifier -> numeric id + id_mapping = { + d.get("identifier"): d.get("id") + for d in rest_devices + if d.get("identifier") } - - # Update GraphQL devices with real REST IDs and names + + # Update GraphQL devices with real REST IDs for device in devices: identifier = device.get("identifier") - if identifier in device_mapping: - device["id"] = device_mapping[identifier]["id"] - # Only update name if GraphQL didn't provide one - if not device.get("name") or device.get("name").startswith(device.get("type", "")): - device["name"] = device_mapping[identifier]["name"] + if identifier in id_mapping: + device["id"] = id_mapping[identifier] LOG.debug( - "Mapped device %s (id=%d, name=%s)", - identifier, - device["id"], - device.get("name") + "Mapped device %s to ID %d", identifier, device["id"] ) - + except Exception as e: - LOG.warning("Failed to fetch device ID/name mapping from REST: %s", e) + LOG.warning("Failed to fetch device ID mapping from REST: %s", e) # Continue without mapping - devices will work read-only - + except Exception as e: - LOG.warning( - "GraphQL device fetch failed, falling back to REST: %s", e - ) + LOG.warning("GraphQL device fetch failed, falling back to REST: %s", e) # Fallback to REST url = self._get_url("Devices", location_id=location_id) LOG.debug("Devices URL is %s", url) @@ -1028,14 +845,14 @@ async def get_devices(self, location_id: int) -> list[dict[str, Any]]: url = self._get_url("Devices", location_id=location_id) LOG.debug("Devices URL is %s", url) devices = await self.async_request("get", url) - + # Add gateway device (still uses REST endpoint) devices.append(await self.get_gateway(location_id)) - + # Add devices from external callbacks for callback in self._get_device_callbacks: devices.append(callback()) - + return devices async def _set_device_attribute( @@ -1246,4 +1063,4 @@ async def get_weather(self, location_id: int) -> dict[str, Any]: LOG.debug("Weather URL is %s", url) response = await self.async_request("get", url) LOG.debug("Weather API response: %s", response) - return cast(dict[str, Any], response) \ No newline at end of file + return cast(dict[str, Any], response) From fe5f773ccced84c210265ea068094e9c2dab6e65 Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:06:55 -0400 Subject: [PATCH 04/13] Update api.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ajoute une fonction pour aller chercher le devicelist venant de DeviceListInitialValuesReceived au lieu du REST. C'est slow à mon goût --- pyhilo/api.py | 52 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 48 insertions(+), 4 deletions(-) diff --git a/pyhilo/api.py b/pyhilo/api.py index c98292a..5992d85 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -97,6 +97,8 @@ def __init__( self.ws_token: str = "" self.endpoint: str = "" self._urn: str | None = None + self._websocket_device_cache: list[dict[str, Any]] = [] + self._device_cache_ready: asyncio.Event = asyncio.Event() @classmethod async def async_create( @@ -796,13 +798,19 @@ def _transform_graphql_device_to_rest( async def get_devices(self, location_id: int) -> list[dict[str, Any]]: """Get list of all devices. - Now uses GraphQL instead of the deprecated REST endpoint. - Falls back to REST if GraphQL fails or URN is not available. + Prioritizes websocket-cached device data (from DeviceListInitialValuesReceived) + over REST/GraphQL since the websocket provides everything we need. + Falls back to GraphQL, then REST if websocket data unavailable. """ devices: list[dict[str, Any]] = [] - # Try GraphQL first if we have a URN - if self.urn: + # Try to use cached websocket device data first + # The DeviceHub websocket sends DeviceListInitialValuesReceived with full device info + if self._websocket_device_cache: + LOG.debug("Using cached device list from websocket (%d devices)", len(self._websocket_device_cache)) + devices = self._websocket_device_cache.copy() + # Try GraphQL if we have a URN and no websocket cache + elif self.urn: try: LOG.debug("Fetching devices via GraphQL for URN: %s", self.urn) devices = await self.get_devices_graphql(self.urn) @@ -854,6 +862,42 @@ async def get_devices(self, location_id: int) -> list[dict[str, Any]]: devices.append(callback()) return devices + + def cache_websocket_devices(self, device_list: list[dict[str, Any]]) -> None: + """Cache device list received from DeviceHub websocket. + + The DeviceListInitialValuesReceived message contains the full device list + with all the info we need (id, name, identifier, etc.) in REST format. + This eliminates the need to call the deprecated REST endpoint. + + Args: + device_list: List of devices from DeviceListInitialValuesReceived + """ + self._websocket_device_cache = device_list + self._device_cache_ready.set() + LOG.debug("Cached %d devices from websocket", len(device_list)) + + async def wait_for_device_cache(self, timeout: float = 10.0) -> bool: + """Wait for the websocket device cache to be populated. + + This should be called before devices.async_init() to ensure + device names and IDs are available from the websocket. + + Args: + timeout: Maximum time to wait in seconds (default: 10.0) + + Returns: + True if cache was populated, False if timeout occurred + """ + try: + await asyncio.wait_for(self._device_cache_ready.wait(), timeout=timeout) + LOG.debug("Device cache ready after waiting") + return True + except asyncio.TimeoutError: + LOG.warning( + "Timeout waiting for websocket device cache, will use fallback method" + ) + return False async def _set_device_attribute( self, From 1f06a6b01fe28375584d581fc83f9890a57f4cbc Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Thu, 12 Mar 2026 12:10:15 -0400 Subject: [PATCH 05/13] Update api.py --- pyhilo/api.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/pyhilo/api.py b/pyhilo/api.py index 5992d85..26d0a8e 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -847,6 +847,8 @@ async def get_devices(self, location_id: int) -> list[dict[str, Any]]: url = self._get_url("Devices", location_id=location_id) LOG.debug("Devices URL is %s", url) devices = await self.async_request("get", url) + + #TODO: retirer bloc else, sert comme plus à rien else: # No URN available, use REST LOG.debug("No URN available, using REST endpoint") From a94cc4092c04090c41fbd2654beebbdf5d0c9b7c Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Thu, 12 Mar 2026 17:12:29 -0400 Subject: [PATCH 06/13] Update websocket.py --- pyhilo/websocket.py | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/pyhilo/websocket.py b/pyhilo/websocket.py index b6d69c9..056ef9c 100755 --- a/pyhilo/websocket.py +++ b/pyhilo/websocket.py @@ -242,6 +242,14 @@ def _parse_message(self, msg: dict[str, Any]) -> None: self._ready_event.set() LOG.info("Websocket: Ready for data") return + + # Cache device list from DeviceListInitialValuesReceived + if msg.get("target") == "DeviceListInitialValuesReceived": + if "arguments" in msg and len(msg["arguments"]) > 0: + device_list = msg["arguments"][0] + if isinstance(device_list, list) and self._api.api: + self._api.api.cache_websocket_devices(device_list) + event = websocket_event_from_payload(msg) for callback in self._event_callbacks: schedule_callback(callback, event) @@ -436,6 +444,7 @@ class WebsocketConfig: """Configuration for a websocket connection""" endpoint: str + api: API | None = None url: Optional[str] = None token: Optional[str] = None connection_id: Optional[str] = None @@ -453,6 +462,7 @@ def __init__( async_request: Callable[..., Any], state_yaml: str, set_state_callback: Callable[..., Any], + api: API, ) -> None: """Initialize the websocket manager. @@ -461,18 +471,20 @@ def __init__( async_request: The async request method from the API class state_yaml: Path to the state file set_state_callback: Callback to save state + api: The API instance """ self.session = session self.async_request = async_request self._state_yaml = state_yaml self._set_state = set_state_callback + self._api = api self._shared_token: Optional[str] = None # Initialize websocket configurations, more can be added here self.devicehub = WebsocketConfig( - endpoint=AUTOMATION_DEVICEHUB_ENDPOINT, session=session + endpoint=AUTOMATION_DEVICEHUB_ENDPOINT, session=session, api=api ) self.challengehub = WebsocketConfig( - endpoint=AUTOMATION_CHALLENGE_ENDPOINT, session=session + endpoint=AUTOMATION_CHALLENGE_ENDPOINT, session=session, api=api ) async def initialize_websockets(self) -> None: @@ -567,4 +579,4 @@ async def _get_websocket_params(self, config: WebsocketConfig) -> None: else "websocketChallenges" ) LOG.debug("Calling set_state %s_params", state_key) - await self._set_state(self._state_yaml, state_key, websocket_dict) + await self._set_state(self._state_yaml, state_key, websocket_dict) \ No newline at end of file From 96591226ff8470bc159b85786d4df5b8bf27f648 Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Thu, 12 Mar 2026 20:44:35 -0400 Subject: [PATCH 07/13] Update api.py Ajout d'un caching de devices pour le build. --- pyhilo/api.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/pyhilo/api.py b/pyhilo/api.py index 26d0a8e..40efe0e 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -421,7 +421,7 @@ async def _async_post_init(self) -> None: # Initialize WebsocketManager ic-dev21 self.websocket_manager = WebsocketManager( - self.session, self.async_request, self._state_yaml, set_state + self.session, self.async_request, self._state_yaml, set_state, api=self ) await self.websocket_manager.initialize_websockets() @@ -847,8 +847,6 @@ async def get_devices(self, location_id: int) -> list[dict[str, Any]]: url = self._get_url("Devices", location_id=location_id) LOG.debug("Devices URL is %s", url) devices = await self.async_request("get", url) - - #TODO: retirer bloc else, sert comme plus à rien else: # No URN available, use REST LOG.debug("No URN available, using REST endpoint") @@ -891,13 +889,20 @@ async def wait_for_device_cache(self, timeout: float = 10.0) -> bool: Returns: True if cache was populated, False if timeout occurred """ + import time + start_time = time.time() + LOG.debug("Waiting for websocket device cache (timeout: %.1fs)...", timeout) + try: await asyncio.wait_for(self._device_cache_ready.wait(), timeout=timeout) - LOG.debug("Device cache ready after waiting") + elapsed = time.time() - start_time + LOG.debug("Device cache ready after %.2f seconds", elapsed) return True except asyncio.TimeoutError: + elapsed = time.time() - start_time LOG.warning( - "Timeout waiting for websocket device cache, will use fallback method" + "Timeout waiting for websocket device cache after %.2f seconds, will use fallback method", + elapsed ) return False @@ -1109,4 +1114,4 @@ async def get_weather(self, location_id: int) -> dict[str, Any]: LOG.debug("Weather URL is %s", url) response = await self.async_request("get", url) LOG.debug("Weather API response: %s", response) - return cast(dict[str, Any], response) + return cast(dict[str, Any], response) \ No newline at end of file From 18fe145270cf2d216b44b686836bd2c08d8dae58 Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:01:48 -0400 Subject: [PATCH 08/13] =?UTF-8?q?Linting=20pr=C3=A9liminaire?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L250 dans websocket ne lintera pas, le call est malformé. À revoir --- pyhilo/api.py | 26 +++++++++++++++----------- pyhilo/websocket.py | 6 +++--- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/pyhilo/api.py b/pyhilo/api.py index 40efe0e..ed08cd4 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -807,7 +807,10 @@ async def get_devices(self, location_id: int) -> list[dict[str, Any]]: # Try to use cached websocket device data first # The DeviceHub websocket sends DeviceListInitialValuesReceived with full device info if self._websocket_device_cache: - LOG.debug("Using cached device list from websocket (%d devices)", len(self._websocket_device_cache)) + LOG.debug( + "Using cached device list from websocket (%d devices)", + len(self._websocket_device_cache), + ) devices = self._websocket_device_cache.copy() # Try GraphQL if we have a URN and no websocket cache elif self.urn: @@ -862,37 +865,38 @@ async def get_devices(self, location_id: int) -> list[dict[str, Any]]: devices.append(callback()) return devices - + def cache_websocket_devices(self, device_list: list[dict[str, Any]]) -> None: """Cache device list received from DeviceHub websocket. - + The DeviceListInitialValuesReceived message contains the full device list with all the info we need (id, name, identifier, etc.) in REST format. This eliminates the need to call the deprecated REST endpoint. - + Args: device_list: List of devices from DeviceListInitialValuesReceived """ self._websocket_device_cache = device_list self._device_cache_ready.set() LOG.debug("Cached %d devices from websocket", len(device_list)) - + async def wait_for_device_cache(self, timeout: float = 10.0) -> bool: """Wait for the websocket device cache to be populated. - + This should be called before devices.async_init() to ensure device names and IDs are available from the websocket. - + Args: timeout: Maximum time to wait in seconds (default: 10.0) - + Returns: True if cache was populated, False if timeout occurred """ import time + start_time = time.time() LOG.debug("Waiting for websocket device cache (timeout: %.1fs)...", timeout) - + try: await asyncio.wait_for(self._device_cache_ready.wait(), timeout=timeout) elapsed = time.time() - start_time @@ -902,7 +906,7 @@ async def wait_for_device_cache(self, timeout: float = 10.0) -> bool: elapsed = time.time() - start_time LOG.warning( "Timeout waiting for websocket device cache after %.2f seconds, will use fallback method", - elapsed + elapsed, ) return False @@ -1114,4 +1118,4 @@ async def get_weather(self, location_id: int) -> dict[str, Any]: LOG.debug("Weather URL is %s", url) response = await self.async_request("get", url) LOG.debug("Weather API response: %s", response) - return cast(dict[str, Any], response) \ No newline at end of file + return cast(dict[str, Any], response) diff --git a/pyhilo/websocket.py b/pyhilo/websocket.py index 056ef9c..67c9e3f 100755 --- a/pyhilo/websocket.py +++ b/pyhilo/websocket.py @@ -242,14 +242,14 @@ def _parse_message(self, msg: dict[str, Any]) -> None: self._ready_event.set() LOG.info("Websocket: Ready for data") return - + # Cache device list from DeviceListInitialValuesReceived if msg.get("target") == "DeviceListInitialValuesReceived": if "arguments" in msg and len(msg["arguments"]) > 0: device_list = msg["arguments"][0] if isinstance(device_list, list) and self._api.api: self._api.api.cache_websocket_devices(device_list) - + event = websocket_event_from_payload(msg) for callback in self._event_callbacks: schedule_callback(callback, event) @@ -579,4 +579,4 @@ async def _get_websocket_params(self, config: WebsocketConfig) -> None: else "websocketChallenges" ) LOG.debug("Calling set_state %s_params", state_key) - await self._set_state(self._state_yaml, state_key, websocket_dict) \ No newline at end of file + await self._set_state(self._state_yaml, state_key, websocket_dict) From 2cbcd7526b0ddf05d703eebc2a9d25e94e9017c5 Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:03:32 -0400 Subject: [PATCH 09/13] Retrait fallback REST MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sert à rien, c'est mort ce endpoint-là. --- pyhilo/api.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/pyhilo/api.py b/pyhilo/api.py index ed08cd4..5c5c1f3 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -845,11 +845,8 @@ async def get_devices(self, location_id: int) -> list[dict[str, Any]]: # Continue without mapping - devices will work read-only except Exception as e: - LOG.warning("GraphQL device fetch failed, falling back to REST: %s", e) - # Fallback to REST - url = self._get_url("Devices", location_id=location_id) - LOG.debug("Devices URL is %s", url) - devices = await self.async_request("get", url) + LOG.warning("Fallback GraphQL device fetch failed %s", e) + else: # No URN available, use REST LOG.debug("No URN available, using REST endpoint") From 20d2751f7dd2dc5d47aa57986bc1e26d9ff4910e Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Thu, 12 Mar 2026 21:05:55 -0400 Subject: [PATCH 10/13] Comment out REST block MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sert à rien de garder ça --- pyhilo/api.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/pyhilo/api.py b/pyhilo/api.py index 5c5c1f3..3d7bced 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -846,13 +846,12 @@ async def get_devices(self, location_id: int) -> list[dict[str, Any]]: except Exception as e: LOG.warning("Fallback GraphQL device fetch failed %s", e) - - else: - # No URN available, use REST - LOG.debug("No URN available, using REST endpoint") - url = self._get_url("Devices", location_id=location_id) - LOG.debug("Devices URL is %s", url) - devices = await self.async_request("get", url) +# TODO: ic-dev21 Remove this commented out block, it for reference only but will no longer work. +# # No URN available, use REST +# LOG.debug("No URN available, using REST endpoint") +# url = self._get_url("Devices", location_id=location_id) +# LOG.debug("Devices URL is %s", url) +# devices = await self.async_request("get", url) # Add gateway device (still uses REST endpoint) devices.append(await self.get_gateway(location_id)) From 077b40192a93bb8839f301dc0de554d3deb2451f Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:46:08 -0400 Subject: [PATCH 11/13] On passe la gratte MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Grand ménage, le stock GraphQL était pas une bonne idée --- pyhilo/api.py | 412 +++++++------------------------------------- pyhilo/devices.py | 76 +++++++- pyhilo/websocket.py | 16 +- 3 files changed, 136 insertions(+), 368 deletions(-) diff --git a/pyhilo/api.py b/pyhilo/api.py index 3d7bced..feb5098 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -3,7 +3,6 @@ import asyncio import base64 from datetime import datetime, timedelta -import hashlib import json import random import string @@ -15,7 +14,6 @@ from aiohttp.client_exceptions import ClientResponseError import backoff from homeassistant.helpers.config_entry_oauth2_flow import OAuth2Session -import httpx from pyhilo.const import ( ANDROID_CLIENT_ENDPOINT, @@ -42,7 +40,6 @@ FB_SDK_VERSION, HILO_READING_TYPES, LOG, - PLATFORM_HOST, REQUEST_RETRY, SUBSCRIPTION_KEY, ) @@ -97,8 +94,9 @@ def __init__( self.ws_token: str = "" self.endpoint: str = "" self._urn: str | None = None - self._websocket_device_cache: list[dict[str, Any]] = [] - self._device_cache_ready: asyncio.Event = asyncio.Event() + # Device cache from websocket DeviceListInitialValuesReceived + self._device_cache: list[dict[str, Any]] = [] + self._device_cache_event: asyncio.Event = asyncio.Event() @classmethod async def async_create( @@ -421,7 +419,7 @@ async def _async_post_init(self) -> None: # Initialize WebsocketManager ic-dev21 self.websocket_manager = WebsocketManager( - self.session, self.async_request, self._state_yaml, set_state, api=self + self.session, self.async_request, self._state_yaml, set_state ) await self.websocket_manager.initialize_websockets() @@ -549,362 +547,86 @@ async def get_location_ids(self) -> tuple[int, str]: req: list[dict[str, Any]] = await self.async_request("get", url) return (req[0]["id"], req[0]["locationHiloId"]) - async def _call_graphql_query( - self, query: str, variables: dict[str, Any] - ) -> dict[str, Any]: - """Execute a GraphQL query and return the raw response data. - - This is a simplified helper that returns raw GraphQL data without - going through the GraphqlValueMapper. Used for get_devices migration. - - Args: - query: GraphQL query string - variables: Query variables - - Returns: - Raw GraphQL response data - """ - access_token = await self.async_get_access_token() - url = f"https://{PLATFORM_HOST}/api/digital-twin/v3/graphql" - headers = {"Authorization": f"Bearer {access_token}"} - - query_hash = hashlib.sha256(query.encode("utf-8")).hexdigest() - - payload: dict[str, Any] = { - "extensions": { - "persistedQuery": { - "version": 1, - "sha256Hash": query_hash, - } - }, - "variables": variables, - } - - async with httpx.AsyncClient(http2=True) as client: - try: - response = await client.post(url, json=payload, headers=headers) - response_json = response.json() - except Exception as e: - LOG.error("Unexpected error calling GraphQL API: %s", e) - raise - - # Handle Persisted Query Not Found error (can come as 400 status) - if "errors" in response_json: - for error in response_json["errors"]: - if error.get("message") == "PersistedQueryNotFound": - LOG.debug("Persisted query not found, retrying with full query") - payload["query"] = query - try: - response = await client.post( - url, json=payload, headers=headers - ) - response.raise_for_status() - response_json = response.json() - except Exception as e: - LOG.error("Error parsing response on retry: %s", e) - raise - break - else: - # Other GraphQL errors - LOG.error("GraphQL errors: %s", response_json["errors"]) - raise Exception(f"GraphQL errors: {response_json['errors']}") - elif response.status_code != 200: - # Non-GraphQL error - error_body = response.text - LOG.error( - "GraphQL API returned status %d: %s", - response.status_code, - error_body, - ) - response.raise_for_status() - - if "data" not in response_json: - LOG.error("No data in GraphQL response: %s", response_json) - raise Exception("No data in GraphQL response") - - return cast(dict[str, Any], response_json["data"]) - - async def get_devices_graphql(self, location_hilo_id: str) -> list[dict[str, Any]]: - """Get list of all devices using GraphQL. - - This replaces the REST endpoint /api/Locations/{LocationId}/Devices - which is being deprecated. + def set_device_cache(self, devices: list[dict[str, Any]]) -> None: + """Store devices received from websocket DeviceListInitialValuesReceived. - Uses the existing QUERY_GET_LOCATION from GraphQlHelper to avoid duplication. - - Args: - location_hilo_id: The location Hilo ID (URN) - - Returns: - List of device dictionaries in the same format as the REST endpoint + This replaces the old REST API get_devices call. The websocket sends + device data with list-type attributes (supportedAttributesList, etc.) + which need to be converted to comma-separated strings to match the + format that HiloDevice.update() expects. """ - from pyhilo.graphql import GraphQlHelper - - # Use the existing comprehensive GraphQL query from GraphQlHelper - query = GraphQlHelper.QUERY_GET_LOCATION - - # Call GraphQL using our helper - data = await self._call_graphql_query( - query, {"locationHiloId": location_hilo_id} + self._device_cache = [self._convert_ws_device(device) for device in devices] + LOG.debug( + "Device cache populated with %d devices from websocket", + len(self._device_cache), ) + self._device_cache_event.set() - # Transform GraphQL response to REST format - graphql_devices = data["getLocation"]["devices"] - rest_devices = [] - - for idx, gql_device in enumerate(graphql_devices, start=2): - rest_device = self._transform_graphql_device_to_rest(gql_device, idx) - if rest_device: - rest_devices.append(rest_device) - - LOG.debug("Fetched %d devices via GraphQL", len(rest_devices)) - return rest_devices - - def _transform_graphql_device_to_rest( - self, gql_device: dict[str, Any], device_id: int - ) -> dict[str, Any] | None: - """Transform a GraphQL device object to REST format. - - Args: - gql_device: Device object from GraphQL - device_id: Numeric device ID to assign + @staticmethod + def _convert_ws_device(ws_device: dict[str, Any]) -> dict[str, Any]: + """Convert a websocket device dict to the format generate_device expects. - Returns: - Device dictionary in REST format, or None if device type is Gateway - (Gateway is handled separately by get_gateway()) + The REST API returned supportedAttributes/settableAttributes as + comma-separated strings. The websocket returns supportedAttributesList/ + settableAttributesList/supportedParametersList as Python lists. + We convert to the old format so HiloDevice.update() works unchanged. """ - device_type = gql_device.get("deviceType", "Unknown") - - # Map GraphQL device types to REST device types - type_mapping = { - "Tstat": "Thermostat", - "BasicThermostat": "Thermostat", - "LowVoltageTstat": "Thermostat24V", - "HeatingFloor": "FloorThermostat", - "Cee": "Cee", - "Ccr": "Ccr", - "Switch": "LightSwitch", - "BasicSwitch": "LightSwitch", - "Dimmer": "LightDimmer", - "BasicDimmer": "LightDimmer", - "ColorBulb": "ColorBulb", - "WhiteBulb": "WhiteBulb", - "BasicLight": "WhiteBulb", - "Meter": "Meter", - "BasicSmartMeter": "Meter", - "ChargingPoint": "ChargingPoint", - "BasicEVCharger": "ChargingPoint", - "BasicChargeController": "Ccr", - "Hub": "Gateway", - } + device = dict(ws_device) - rest_type = type_mapping.get(device_type, device_type) + # Convert list attributes to comma-separated strings + list_to_csv_mappings = { + "supportedAttributesList": "supportedAttributes", + "settableAttributesList": "settableAttributes", + "supportedParametersList": "supportedParameters", + } + for list_key, csv_key in list_to_csv_mappings.items(): + if list_key in device: + items = device.pop(list_key) + if isinstance(items, list): + device[csv_key] = ", ".join(str(i) for i in items) + else: + device[csv_key] = str(items) if items else "" - # Skip Gateway - it's fetched separately - if rest_type == "Gateway": - return None + return device - # Build the device dictionary - rest_device = { - "id": device_id, - "hilo_id": gql_device.get("hiloId", ""), - "identifier": gql_device.get("physicalAddress", ""), - "type": rest_type, - "name": gql_device.get( - "name", f"{rest_type} {device_id}" - ), # Use GraphQL name or fallback - "category": rest_type, - "supportedAttributes": "", - "settableAttributes": "", - "provider": 1, - } + async def wait_for_device_cache(self, timeout: float = 30.0) -> None: + """Wait for the device cache to be populated from websocket. - # Add all attributes from GraphQL device - supported_attrs = [] - settable_attrs = [] - - # Common attributes - if "connectionStatus" in gql_device: - rest_device["Disconnected"] = {"value": gql_device["connectionStatus"] == 2} - supported_attrs.append("Disconnected") - - if "power" in gql_device and gql_device["power"]: - rest_device["Power"] = {"value": gql_device["power"].get("value", 0)} - supported_attrs.append("Power") - - # Thermostat attributes - if "ambientTemperature" in gql_device and gql_device["ambientTemperature"]: - rest_device["CurrentTemperature"] = { - "value": gql_device["ambientTemperature"].get("value", 0) - } - supported_attrs.append("CurrentTemperature") - - if "ambientTempSetpoint" in gql_device and gql_device["ambientTempSetpoint"]: - rest_device["TargetTemperature"] = { - "value": gql_device["ambientTempSetpoint"].get("value", 0) - } - supported_attrs.append("TargetTemperature") - settable_attrs.append("TargetTemperature") - - if "ambientHumidity" in gql_device: - rest_device["CurrentHumidity"] = {"value": gql_device["ambientHumidity"]} - supported_attrs.append("CurrentHumidity") - - if "mode" in gql_device: - rest_device["Mode"] = {"value": gql_device["mode"]} - supported_attrs.append("Mode") - settable_attrs.append("Mode") - - if "gDState" in gql_device: - rest_device["GDState"] = {"value": gql_device["gDState"]} - supported_attrs.append("GDState") - - # Light/Switch attributes - if "state" in gql_device: - rest_device["OnOff"] = {"value": gql_device["state"]} - supported_attrs.append("OnOff") - settable_attrs.append("OnOff") - - if "level" in gql_device: - rest_device["Intensity"] = {"value": gql_device["level"]} - supported_attrs.append("Intensity") - settable_attrs.append("Intensity") - - if "hue" in gql_device: - rest_device["Hue"] = {"value": gql_device["hue"]} - supported_attrs.append("Hue") - settable_attrs.append("Hue") - - if "saturation" in gql_device: - rest_device["Saturation"] = {"value": gql_device["saturation"]} - supported_attrs.append("Saturation") - settable_attrs.append("Saturation") - - if "colorTemperature" in gql_device: - rest_device["ColorTemperature"] = {"value": gql_device["colorTemperature"]} - supported_attrs.append("ColorTemperature") - settable_attrs.append("ColorTemperature") - - # Version info - if "version" in gql_device: - rest_device["sw_version"] = gql_device["version"] - - # Set the attributes strings - rest_device["supportedAttributes"] = ", ".join(supported_attrs) - rest_device["settableAttributes"] = ", ".join(settable_attrs) - - return rest_device - - async def get_devices(self, location_id: int) -> list[dict[str, Any]]: - """Get list of all devices. - - Prioritizes websocket-cached device data (from DeviceListInitialValuesReceived) - over REST/GraphQL since the websocket provides everything we need. - Falls back to GraphQL, then REST if websocket data unavailable. + :param timeout: Maximum time to wait in seconds + :raises TimeoutError: If the device cache is not populated in time """ - devices: list[dict[str, Any]] = [] - - # Try to use cached websocket device data first - # The DeviceHub websocket sends DeviceListInitialValuesReceived with full device info - if self._websocket_device_cache: - LOG.debug( - "Using cached device list from websocket (%d devices)", - len(self._websocket_device_cache), + if self._device_cache_event.is_set(): + return + LOG.debug("Waiting for device cache from websocket (timeout=%ss)", timeout) + try: + await asyncio.wait_for(self._device_cache_event.wait(), timeout=timeout) + except asyncio.TimeoutError: + LOG.error( + "Timed out waiting for device list from websocket after %ss", + timeout, ) - devices = self._websocket_device_cache.copy() - # Try GraphQL if we have a URN and no websocket cache - elif self.urn: - try: - LOG.debug("Fetching devices via GraphQL for URN: %s", self.urn) - devices = await self.get_devices_graphql(self.urn) - - # WORKAROUND: Fetch REST device IDs for attribute setting - # This is needed because the attribute endpoint still uses numeric IDs - try: - url = self._get_url("Devices", location_id=location_id) - rest_devices = await self.async_request("get", url) - - # Build mapping of identifier -> numeric id - id_mapping = { - d.get("identifier"): d.get("id") - for d in rest_devices - if d.get("identifier") - } - - # Update GraphQL devices with real REST IDs - for device in devices: - identifier = device.get("identifier") - if identifier in id_mapping: - device["id"] = id_mapping[identifier] - LOG.debug( - "Mapped device %s to ID %d", identifier, device["id"] - ) - - except Exception as e: - LOG.warning("Failed to fetch device ID mapping from REST: %s", e) - # Continue without mapping - devices will work read-only - - except Exception as e: - LOG.warning("Fallback GraphQL device fetch failed %s", e) -# TODO: ic-dev21 Remove this commented out block, it for reference only but will no longer work. -# # No URN available, use REST -# LOG.debug("No URN available, using REST endpoint") -# url = self._get_url("Devices", location_id=location_id) -# LOG.debug("Devices URL is %s", url) -# devices = await self.async_request("get", url) - - # Add gateway device (still uses REST endpoint) - devices.append(await self.get_gateway(location_id)) - - # Add devices from external callbacks - for callback in self._get_device_callbacks: - devices.append(callback()) - - return devices - - def cache_websocket_devices(self, device_list: list[dict[str, Any]]) -> None: - """Cache device list received from DeviceHub websocket. - - The DeviceListInitialValuesReceived message contains the full device list - with all the info we need (id, name, identifier, etc.) in REST format. - This eliminates the need to call the deprecated REST endpoint. - - Args: - device_list: List of devices from DeviceListInitialValuesReceived - """ - self._websocket_device_cache = device_list - self._device_cache_ready.set() - LOG.debug("Cached %d devices from websocket", len(device_list)) - - async def wait_for_device_cache(self, timeout: float = 10.0) -> bool: - """Wait for the websocket device cache to be populated. - - This should be called before devices.async_init() to ensure - device names and IDs are available from the websocket. + raise - Args: - timeout: Maximum time to wait in seconds (default: 10.0) + def get_device_cache(self, location_id: int) -> list[dict[str, Any]]: + """Return cached devices from websocket. - Returns: - True if cache was populated, False if timeout occurred + :param location_id: Hilo location id (unused but kept for interface compat) + :return: List of device dicts ready for generate_device() """ - import time + return list(self._device_cache) - start_time = time.time() - LOG.debug("Waiting for websocket device cache (timeout: %.1fs)...", timeout) + def add_to_device_cache(self, devices: list[dict[str, Any]]) -> None: + """Append new devices to the existing cache (e.g. from DeviceAdded). - try: - await asyncio.wait_for(self._device_cache_ready.wait(), timeout=timeout) - elapsed = time.time() - start_time - LOG.debug("Device cache ready after %.2f seconds", elapsed) - return True - except asyncio.TimeoutError: - elapsed = time.time() - start_time - LOG.warning( - "Timeout waiting for websocket device cache after %.2f seconds, will use fallback method", - elapsed, - ) - return False + Converts websocket format and adds to the cache without replacing + existing entries. Skips devices already in cache (by id). + """ + existing_ids = {d.get("id") for d in self._device_cache} + for device in devices: + converted = self._convert_ws_device(device) + if converted.get("id") not in existing_ids: + self._device_cache.append(converted) + LOG.debug("Added device %s to cache", converted.get("id")) async def _set_device_attribute( self, diff --git a/pyhilo/devices.py b/pyhilo/devices.py index e927bb4..80c6530 100644 --- a/pyhilo/devices.py +++ b/pyhilo/devices.py @@ -93,27 +93,78 @@ def generate_device(self, device: dict) -> HiloDevice: return dev async def update(self) -> None: - fresh_devices = await self._api.get_devices(self.location_id) + """Update device list from websocket cache + gateway from REST.""" + # Get devices from websocket cache (already populated by DeviceListInitialValuesReceived) + cached_devices = self._api.get_device_cache(self.location_id) generated_devices = [] - for raw_device in fresh_devices: + for raw_device in cached_devices: LOG.debug("Generating device %s", raw_device) dev = self.generate_device(raw_device) generated_devices.append(dev) if dev not in self.devices: self.devices.append(dev) + + # Append gateway from REST API (still available) + try: + gw = await self._api.get_gateway(self.location_id) + LOG.debug("Generating gateway device %s", gw) + gw_dev = self.generate_device(gw) + generated_devices.append(gw_dev) + if gw_dev not in self.devices: + self.devices.append(gw_dev) + except Exception as err: + LOG.error("Failed to get gateway: %s", err) + + # Now add devices from external sources (e.g. unknown source tracker) + for callback in self._api._get_device_callbacks: + try: + cb_device = callback() + dev = self.generate_device(cb_device) + generated_devices.append(dev) + if dev not in self.devices: + self.devices.append(dev) + except Exception as err: + LOG.error("Failed to generate callback device: %s", err) + for device in self.devices: if device not in generated_devices: LOG.debug("Device unpaired %s", device) # Don't do anything with unpaired device for now. - # self.devices.remove(device) async def update_devicelist_from_signalr( self, values: list[dict[str, Any]] ) -> list[HiloDevice]: - # ic-dev21 not sure if this is dead code? + """Process device list received from SignalR websocket. + + This is called when DeviceListInitialValuesReceived arrives. + It populates the API device cache and generates HiloDevice objects. + """ + # Populate the API cache so future update() calls use this data + self._api.set_device_cache(values) + new_devices = [] - for raw_device in values: - LOG.debug("Generating device %s", raw_device) + for raw_device in self._api.get_device_cache(self.location_id): + LOG.debug("Generating device from SignalR %s", raw_device) + dev = self.generate_device(raw_device) + if dev not in self.devices: + self.devices.append(dev) + new_devices.append(dev) + + return new_devices + + async def add_device_from_signalr( + self, values: list[dict[str, Any]] + ) -> list[HiloDevice]: + """Process individual device additions from SignalR websocket. + + This is called when DeviceAdded arrives. It appends to the existing + cache rather than replacing it. + """ + self._api.add_to_device_cache(values) + + new_devices = [] + for raw_device in self._api.get_device_cache(self.location_id): + LOG.debug("Generating added device from SignalR %s", raw_device) dev = self.generate_device(raw_device) if dev not in self.devices: self.devices.append(dev) @@ -122,9 +173,16 @@ async def update_devicelist_from_signalr( return new_devices async def async_init(self) -> None: - """Initialize the Hilo "manager" class.""" - LOG.info("Initialising after websocket is connected") + """Initialize the Hilo "manager" class. + + Gets location IDs from REST API, then waits for the websocket + to deliver the device list via DeviceListInitialValuesReceived. + The gateway is appended from REST. + """ + LOG.info("Initialising: getting location IDs") location_ids = await self._api.get_location_ids() self.location_id = location_ids[0] self.location_hilo_id = location_ids[1] - await self.update() + # Device list will be populated when DeviceListInitialValuesReceived + # arrives on the websocket. The hilo integration's async_init will + # call wait_for_device_cache() and then update() after subscribing. diff --git a/pyhilo/websocket.py b/pyhilo/websocket.py index 67c9e3f..b6d69c9 100755 --- a/pyhilo/websocket.py +++ b/pyhilo/websocket.py @@ -242,14 +242,6 @@ def _parse_message(self, msg: dict[str, Any]) -> None: self._ready_event.set() LOG.info("Websocket: Ready for data") return - - # Cache device list from DeviceListInitialValuesReceived - if msg.get("target") == "DeviceListInitialValuesReceived": - if "arguments" in msg and len(msg["arguments"]) > 0: - device_list = msg["arguments"][0] - if isinstance(device_list, list) and self._api.api: - self._api.api.cache_websocket_devices(device_list) - event = websocket_event_from_payload(msg) for callback in self._event_callbacks: schedule_callback(callback, event) @@ -444,7 +436,6 @@ class WebsocketConfig: """Configuration for a websocket connection""" endpoint: str - api: API | None = None url: Optional[str] = None token: Optional[str] = None connection_id: Optional[str] = None @@ -462,7 +453,6 @@ def __init__( async_request: Callable[..., Any], state_yaml: str, set_state_callback: Callable[..., Any], - api: API, ) -> None: """Initialize the websocket manager. @@ -471,20 +461,18 @@ def __init__( async_request: The async request method from the API class state_yaml: Path to the state file set_state_callback: Callback to save state - api: The API instance """ self.session = session self.async_request = async_request self._state_yaml = state_yaml self._set_state = set_state_callback - self._api = api self._shared_token: Optional[str] = None # Initialize websocket configurations, more can be added here self.devicehub = WebsocketConfig( - endpoint=AUTOMATION_DEVICEHUB_ENDPOINT, session=session, api=api + endpoint=AUTOMATION_DEVICEHUB_ENDPOINT, session=session ) self.challengehub = WebsocketConfig( - endpoint=AUTOMATION_CHALLENGE_ENDPOINT, session=session, api=api + endpoint=AUTOMATION_CHALLENGE_ENDPOINT, session=session ) async def initialize_websockets(self) -> None: From 6477d327fc2fc65d314912cccb3c6dd572cb004a Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Fri, 13 Mar 2026 16:47:45 -0400 Subject: [PATCH 12/13] Update devices.py Petite passe passe pour enlever le warning au startup pour device 0. --- pyhilo/devices.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/pyhilo/devices.py b/pyhilo/devices.py index 80c6530..ce58585 100644 --- a/pyhilo/devices.py +++ b/pyhilo/devices.py @@ -58,7 +58,13 @@ def _map_readings_to_devices( device_identifier: Union[int, str] = reading.device_id if device_identifier == 0: device_identifier = reading.hilo_id - if device := self.find_device(device_identifier): + device = self.find_device(device_identifier) + # If device_id was 0 and hilo_id lookup failed, this is likely + # a gateway reading that arrives before GatewayValuesReceived + # assigns the real ID. Fall back to the gateway device. + if device is None and reading.device_id == 0: + device = next((d for d in self.devices if d.type == "Gateway"), None) + if device: device.update_readings(reading) LOG.debug("%s Received %s", device, reading) if device not in updated_devices: From e5c393390ce90350d98dc9c72cd9658439886810 Mon Sep 17 00:00:00 2001 From: "Ian C." <108159253+ic-dev21@users.noreply.github.com> Date: Fri, 13 Mar 2026 19:35:22 -0400 Subject: [PATCH 13/13] Bump up versions --- pyhilo/const.py | 2 +- pyproject.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyhilo/const.py b/pyhilo/const.py index 0ea835d..0f7b85f 100755 --- a/pyhilo/const.py +++ b/pyhilo/const.py @@ -11,7 +11,7 @@ LOG: Final = logging.getLogger(__package__) DEFAULT_STATE_FILE: Final = "hilo_state.yaml" REQUEST_RETRY: Final = 9 -PYHILO_VERSION: Final = "2026.3.01" +PYHILO_VERSION: Final = "2026.3.02" # TODO: Find a way to keep previous line in sync with pyproject.toml automatically CONTENT_TYPE_FORM: Final = "application/x-www-form-urlencoded" diff --git a/pyproject.toml b/pyproject.toml index 10ccd20..180c7ae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -40,7 +40,7 @@ exclude = ".venv/.*" [tool.poetry] name = "python-hilo" -version = "2026.3.1" +version = "2026.3.2" description = "A Python3, async interface to the Hilo API" readme = "README.md" authors = ["David Vallee Delisle "]