diff --git a/pyhilo/api.py b/pyhilo/api.py index dac6b3c..9c03d4f 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -6,14 +6,12 @@ import random import string import sys -from typing import Any, Callable, Dict, Union, cast +from typing import Any, Callable, Union, cast from urllib import parse from aiohttp import ClientSession from aiohttp.client_exceptions import ClientResponseError import backoff -from gql import Client, gql -from gql.transport.aiohttp import AIOHTTPTransport from pyhilo.const import ( ANDROID_CLIENT_ENDPOINT, @@ -45,7 +43,6 @@ ) from pyhilo.device import DeviceAttribute, HiloDevice, get_device_attributes from pyhilo.exceptions import InvalidCredentialsError, RequestError -from pyhilo.graphql import GraphQlHelper from pyhilo.util.state import ( StateDict, WebsocketDict, @@ -513,22 +510,6 @@ async def get_devices(self, location_id: int) -> list[dict[str, Any]]: devices.append(callback()) return devices - async def call_get_location_query(self, location_hilo_id: string) -> Dict[str, Any]: - access_token = await self.async_get_access_token() - transport = AIOHTTPTransport( - url="https://platform.hiloenergie.com/api/digital-twin/v3/graphql", - headers={"Authorization": f"Bearer {access_token}"}, - ) - client = Client(transport=transport, fetch_schema_from_transport=True) - query = gql(GraphQlHelper.query_get_location()) - - async with client as session: - result = await session.execute( - query, variable_values={"locationHiloId": location_hilo_id} - ) - LOG.info(result) - return result - async def _set_device_attribute( self, device: HiloDevice, diff --git a/pyhilo/device/__init__.py b/pyhilo/device/__init__.py index c9435d1..c92095f 100644 --- a/pyhilo/device/__init__.py +++ b/pyhilo/device/__init__.py @@ -87,9 +87,7 @@ def update(self, **kwargs: Dict[str, Union[str, int, Dict]]) -> None: new_val.append(DeviceAttribute("Disconnected", "null")) elif att == "provider": att = "manufacturer" - new_val = HILO_PROVIDERS.get( - int(val), f"Unknown ({val})" - ) # type: ignore + new_val = HILO_PROVIDERS.get(int(val), f"Unknown ({val})") # type: ignore else: if att == "serial": att = "identifier" @@ -234,9 +232,7 @@ def __init__(self, **kwargs: Dict[str, Any]): # attr='intensity', # value_type='%') # } - kwargs["timeStamp"] = from_utc_timestamp( - kwargs.pop("timeStampUTC", "") - ) # type: ignore + kwargs["timeStamp"] = from_utc_timestamp(kwargs.pop("timeStampUTC", "")) # type: ignore self.id = 0 self.value: Union[int, bool, str] = 0 self.device_id = 0 diff --git a/pyhilo/device/graphql_value_mapper.py b/pyhilo/device/graphql_value_mapper.py index 6a52e79..ec107f8 100644 --- a/pyhilo/device/graphql_value_mapper.py +++ b/pyhilo/device/graphql_value_mapper.py @@ -1,5 +1,5 @@ from datetime import datetime, timezone -from typing import Any, Dict, Generator, Union +from typing import Any, Dict from pyhilo.device import DeviceReading @@ -9,45 +9,61 @@ class GraphqlValueMapper: A class to map GraphQL values to DeviceReading instances. """ - def __init__(self, api: Any): - self._api = api + def map_query_values(self, values: Dict[str, Any]) -> list[Dict[str, Any]]: + readings: list[Dict[str, Any]] = [] + for device in values: + if device.get("deviceType") is not None: + reading = self._map_devices_values(device) + readings.extend(reading) + return readings - def map_values(self, values: Dict[str, Any]) -> list[DeviceReading]: - devices_values: list[any] = values["getLocation"]["devices"] - readings: list[DeviceReading] = [] - for device in devices_values: + def map_device_subscription_values( + self, device: list[Dict[str, Any]] + ) -> list[Dict[str, Any]]: + readings: list[Dict[str, Any]] = [] + if device.get("deviceType") is not None: + reading = self._map_devices_values(device) + readings.extend(reading) + return readings + + def map_location_subscription_values( + self, values: Dict[str, Any] + ) -> list[Dict[str, Any]]: + readings: list[Dict[str, Any]] = [] + for device in values: if device.get("deviceType") is not None: reading = self._map_devices_values(device) readings.extend(reading) return readings - def _map_devices_values(self, device: Dict[str, Any]) -> list[DeviceReading]: + def _map_devices_values(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: attributes: list[Dict[str, Any]] = self._map_basic_device(device) - match device["deviceType"]: - case "Tstat": + match device["deviceType"].lower(): + case "tstat": attributes.extend(self._build_thermostat(device)) - case "CCE": # Water Heater + case "cee": # Water Heater attributes.extend(self._build_water_heater(device)) - case "CCR": # ChargeController + case "ccr": # ChargeController attributes.extend(self._build_charge_controller(device)) - case "HeatingFloor": + case "heatingfloor": attributes.extend(self._build_floor_thermostat(device)) - # case "LowVoltageTstat": - case "ChargingPoint": + case "lowvoltagetstat": + attributes.extend(self._build_lowvoltage_thermostat(device)) + case "chargingpoint": attributes.extend(self._build_charging_point(device)) - case "Meter": + case "meter": # Smart Meter attributes.extend(self._build_smart_meter(device)) - case "Hub": # Gateway + case "hub": # Gateway attributes.extend(self._build_gateway(device)) - case "ColorBulb": + case "colorbulb": attributes.extend(self._build_light(device)) - case "Dimmer": + case "dimmer": attributes.extend(self._build_dimmer(device)) - case "Switch": + case "switch": attributes.extend(self._build_switch(device)) case _: pass - return self._map_to_device_reading(attributes) + return attributes def _map_to_device_reading( self, attributes: list[Dict[str, Any]] @@ -173,42 +189,43 @@ def _build_thermostat( self.build_attribute( device["hiloId"], "ThermostatMode", - self._map_to_thermostat_mode(device.get("mode")), + device.get("mode"), ) ) attributes.append(self._map_gd_state(device)) - if withDefaultMinMaxTemp: - attributes.extend( - [ - self.build_attribute(device["hiloId"], "MaxTempSetpoint", 30), - self.build_attribute(device["hiloId"], "MinTempSetpoint", 5), - ] - ) - else: - if device.get("maxAmbientTempSetpoint") is not None: + + if device.get("maxAmbientTempSetpoint") is not None: + attributes.append( self.build_attribute( - device["maxAmbientTempSetpoint"], + device["hiloId"], "MaxTempSetpoint", device["maxAmbientTempSetpoint"]["value"], ) - if device.get("minAmbientTempSetpoint") is not None: + ) + if device.get("minAmbientTempSetpoint") is not None: + attributes.append( self.build_attribute( device["hiloId"], "MinTempSetpoint", device["minAmbientTempSetpoint"]["value"], ) + ) if device.get("maxAmbientTempSetpointLimit") is not None: - self.build_attribute( - device["hiloId"], - "MaxTempSetpointLimit", - device["maxAmbientTempSetpointLimit"]["value"], + attributes.append( + self.build_attribute( + device["hiloId"], + "MaxTempSetpointLimit", + device["maxAmbientTempSetpointLimit"]["value"], + ) ) if device.get("minAmbientTempSetpointLimit") is not None: - self.build_attribute( - device["hiloId"], - "MinTempSetpointLimit", - device["minAmbientTempSetpointLimit"]["value"], + attributes.append( + self.build_attribute( + device["hiloId"], + "MinTempSetpointLimit", + device["minAmbientTempSetpointLimit"]["value"], + ) ) return attributes @@ -218,7 +235,7 @@ def _build_floor_thermostat(self, device: Dict[str, Any]) -> list[Dict[str, Any] self.build_attribute( device["hiloId"], "FloorMode", - self._map_to_floor_mode(device["floorMode"]), + device["floorMode"], ) ) if device.get("floorLimit") is not None: @@ -229,6 +246,68 @@ def _build_floor_thermostat(self, device: Dict[str, Any]) -> list[Dict[str, Any] ) return attributes + def _build_lowvoltage_thermostat( + self, device: Dict[str, Any] + ) -> list[Dict[str, Any]]: + attributes = self._build_thermostat(device) + if device.get("coolTempSetpoint") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], + "CoolTemperatureSet", + device["coolTempSetpoint"]["value"], + ) + ) + if device.get("minAmbientCoolSetPoint") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], + "MinCoolSetpoint", + device["minAmbientCoolSetPoint"]["value"], + ) + ) + if device.get("maxAmbientCoolSetPoint") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], + "MaxCoolSetpoint", + device["maxAmbientCoolSetPoint"]["value"], + ) + ) + attributes.extend( + [ + self.build_attribute( + device["hiloId"], + "Thermostat24VAllowedMode", + device["allowedModes"], + ), + self.build_attribute( + device["hiloId"], + "Thermostat24VAllowedFanMode", + device["fanAllowedModes"], + ), + self.build_attribute( + device["hiloId"], + "FanMode", + device["fanMode"], + ), + self.build_attribute( + device["hiloId"], + "Thermostat24VMode", + device["mode"], + ), + self.build_attribute( + device["hiloId"], + "CurrentState", + device["currentState"], + ), + self.build_attribute( + device["hiloId"], "FanSpeed", device.get("fanSpeed") + ), + ] + ) + return attributes + def _build_water_heater(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: attributes = self._build_charge_controller(device) attributes.append( @@ -271,11 +350,10 @@ def _build_charge_controller(self, device: Dict[str, Any]) -> list[Dict[str, Any device["hiloId"], "CcrAllowedModes", device["ccrAllowedModes"] ) ) - attributes.append( - self.build_attribute( - device["hiloId"], "CcrMode", self._map_to_ccr_mode(device["ccrMode"]) + if device.get("ccrMode") is not None: + attributes.append( + self.build_attribute(device["hiloId"], "CcrMode", device["ccrMode"]) ) - ) return attributes def _build_charging_point(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: @@ -323,16 +401,16 @@ def _build_light(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: self.build_attribute( device["hiloId"], "ColorTemperature", - device["colorTemperature"]["value"], + device["colorTemperature"], ) ) if device.get("level") is not None: attributes.append( self.build_attribute( - device["hiloId"], "Intensity", device["level"]["value"] / 100 + device["hiloId"], "Intensity", device["level"] / 100 ) ) - if device.get("lightType") == 1: # LightType.Color + if device.get("lightType").lower() == "color": attributes.append( self.build_attribute(device["hiloId"], "Hue", device.get("hue") or 0) ) @@ -342,7 +420,7 @@ def _build_light(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: ) ) attributes.append( - self.build_attribute(device["hiloId"], "OnOff", device["atate"]) + self.build_attribute(device["hiloId"], "OnOff", device["state"]) ) return attributes @@ -362,10 +440,8 @@ def _map_gd_state(self, device: Dict[str, Any]) -> Dict[str, Any]: ) def _map_drms_state(self, device: Dict[str, Any]) -> Dict[str, Any]: - return ( - self.build_attribute( - device["hiloId"], "DrmsState", device["gDState"] == "Active" - ), + return self.build_attribute( + device["hiloId"], "DrmsState", device["gDState"] == "Active" ) def _map_heating(self, device: Dict[str, Any]) -> Dict[str, Any]: @@ -398,56 +474,8 @@ def build_attribute( ) -> Dict[str, Any]: return { "hilo_id": hilo_id, - "device_attribute": self._api.dev_atts(device_attribute, "null"), + "attribute": device_attribute, + "valueType": "null", "value": value, "timeStampUTC": datetime.now(timezone.utc).isoformat(), } - - def _map_to_floor_mode(self, floor_mode: int) -> str: - match floor_mode: - case 0: - return "Ambient" - case 1: - return "Floor" - case 2: - return "Hybrid" - - def _map_to_thermostat_mode(self, mode: int) -> str: - match mode: - case 0: - return "Unknown" - case 1: - return "Heat" - case 2: - return "Auto" - case 3: - return "AutoHeat" - case 4: - return "EmergencyHeat" - case 5: - return "Cool" - case 6: - return "AutoCool" - case 7: - return "SouthernAway" - case 8: - return "Off" - case 9: - return "Manual" - case 10: - return "AutoBypass" - case _: - return "" - - def _map_to_ccr_mode(self, ccr_mode: int) -> str: - match ccr_mode: - case 0: - return "Unknown" - case 1: - return "Auto" - case 2: - return "Off" - case 3: - return "Manual" - case _: - return "" diff --git a/pyhilo/devices.py b/pyhilo/devices.py index a4e8523..1187098 100644 --- a/pyhilo/devices.py +++ b/pyhilo/devices.py @@ -4,7 +4,6 @@ from pyhilo.const import HILO_DEVICE_TYPES, LOG from pyhilo.device import DeviceReading, HiloDevice from pyhilo.device.climate import Climate # noqa -from pyhilo.device.graphql_value_mapper import GraphqlValueMapper from pyhilo.device.light import Light # noqa from pyhilo.device.sensor import Sensor # noqa from pyhilo.device.switch import Switch # noqa @@ -119,8 +118,3 @@ async def async_init(self) -> None: self.location_id = location_ids[0] self.location_hilo_id = location_ids[1] await self.update() - # TODO AA - trouver ou placer ça pour que ça fasse du sens, pas sûre que c'est ici - values = await self._api.call_get_location_query(self.location_hilo_id) - mapper = GraphqlValueMapper(self._api) - readings = mapper.map_values(values) - self._map_readings_to_devices(readings) diff --git a/pyhilo/graphql.py b/pyhilo/graphql.py index 45b2645..2cf2aa2 100644 --- a/pyhilo/graphql.py +++ b/pyhilo/graphql.py @@ -1,6 +1,32 @@ +import asyncio +from typing import Any, Dict, List, Optional + +from gql import Client, gql +from gql.transport.aiohttp import AIOHTTPTransport +from gql.transport.websockets import WebsocketsTransport + +from pyhilo import API +from pyhilo.device.graphql_value_mapper import GraphqlValueMapper +from pyhilo.devices import Devices + + class GraphQlHelper: - def query_get_location() -> str: - return """query getLocation($locationHiloId: String!) { + """The GraphQl Helper class.""" + + def __init__(self, api: API, devices: Devices): + self._api = api + self._devices = devices + self.access_token = "" + self.mapper: GraphqlValueMapper = GraphqlValueMapper() + + self.subscriptions: List[Optional[asyncio.Task]] = [None] + + async def async_init(self) -> None: + """Initialize the Hilo "GraphQlHelper" class.""" + self.access_token = await self._api.async_get_access_token() + await self.call_get_location_query(self._devices.location_hilo_id) + + QUERY_GET_LOCATION: str = """query getLocation($locationHiloId: String!) { getLocation(id:$locationHiloId) { hiloId lastUpdate @@ -122,6 +148,8 @@ def query_get_location() -> str: value kind } + ccrMode, + ccrAllowedModes } ... on HeatingFloorThermostat { deviceType @@ -166,7 +194,7 @@ def query_get_location() -> str: connectionStatus gDState version - probeTemp { + probeTemp { value kind } @@ -200,19 +228,380 @@ def query_get_location() -> str: gDState version zigbeeVersion - ambientTemperature { + ambientTemperature { + value + kind + } + ambientTempSetpoint { + value + kind + } + maxAmbientTempSetpoint { + value + kind + } + minAmbientTempSetpoint { value kind } - ambientTempSetpoint { + maxAmbientTempSetpointLimit { value kind } + minAmbientTempSetpointLimit { + value + kind + } + heatDemand power { value kind } + mode + allowedModes } } } - }""" + }""" + + SUBSCRIPTION_DEVICE_UPDATED: str = """subscription onAnyDeviceUpdated($locationHiloId: String!) { + onAnyDeviceUpdated(locationHiloId: $locationHiloId) { + deviceType + locationHiloId + transmissionTime + operationId + status + device { + ... on Gateway { + connectionStatus + controllerSoftwareVersion + lastConnectionTime + willBeConnectedToSmartMeter + zigBeeChannel + zigBeePairingModeEnhanced + smartMeterZigBeeChannel + smartMeterPairingStatus + } + ... on BasicSmartMeter { + deviceType + hiloId + physicalAddress + connectionStatus + zigBeeChannel + power { + value + kind + } + } + ... on LowVoltageThermostat { + deviceType + hiloId + physicalAddress + coolTempSetpoint { + value + } + fanMode + fanSpeed + mode + currentState + power { + value + kind + } + ambientHumidity + gDState + ambientTemperature { + value + kind + } + ambientTempSetpoint { + value + kind + } + version + zigbeeVersion + connectionStatus + maxAmbientCoolSetPoint { + value + kind + } + minAmbientCoolSetPoint { + value + kind + } + maxAmbientTempSetpoint { + value + kind + } + minAmbientTempSetpoint { + value + kind + } + allowedModes + fanAllowedModes + } + ... on BasicSwitch { + deviceType + hiloId + physicalAddress + connectionStatus + state + power { + value + kind + } + } + ... on BasicLight { + deviceType + hiloId + physicalAddress + connectionStatus + state + hue + level + saturation + colorTemperature + lightType + } + ... on BasicEVCharger { + deviceType + hiloId + physicalAddress + connectionStatus + status + power { + value + kind + } + } + ... on BasicChargeController { + deviceType + hiloId + physicalAddress + connectionStatus + gDState + version + zigbeeVersion + state + power { + value + kind + } + ccrMode, + ccrAllowedModes + } + ... on HeatingFloorThermostat { + deviceType + hiloId + physicalAddress + connectionStatus + ambientHumidity + gDState + version + zigbeeVersion + thermostatType + physicalAddress + floorMode + power { + value + kind + } + ambientTemperature { + value + kind + } + ambientTempSetpoint { + value + kind + } + maxAmbientTempSetpoint { + value + kind + } + minAmbientTempSetpoint { + value + kind + } + floorLimit { + value + } + } + ... on WaterHeater { + deviceType + hiloId + physicalAddress + connectionStatus + gDState + version + probeTemp { + value + kind + } + zigbeeVersion + state + ccrType + alerts + power { + value + kind + } + } + ... on BasicDimmer { + deviceType + hiloId + physicalAddress + connectionStatus + state + level + power { + value + kind + } + } + ... on BasicThermostat { + deviceType + hiloId + physicalAddress + connectionStatus + ambientHumidity + gDState + version + zigbeeVersion + ambientTemperature { + value + kind + } + ambientTempSetpoint { + value + kind + } + maxAmbientTempSetpoint { + value + kind + } + minAmbientTempSetpoint { + value + kind + } + maxAmbientTempSetpointLimit { + value + kind + } + minAmbientTempSetpointLimit { + value + kind + } + heatDemand + power { + value + kind + } + mode + allowedModes + } + } + } +}""" + + SUBSCRIPTION_LOCATION_UPDATED: str = """subscription onAnyLocationUpdated($locationHiloId: String!){ + onAnyLocationUpdated(locationHiloId: $locationHiloId) { + locationHiloId + deviceType + transmissionTime + operationId + location { + ...on Container { + hiloId + devices { + deviceType + hiloId + physicalAddress + connectionStatus + ... on BasicChargeController { + connectionStatus + } + ... on LowVoltageThermostat { + connectionStatus + } + } + } + } + } +}""" + + async def call_get_location_query(self, location_hilo_id: str) -> None: + transport = AIOHTTPTransport( + url="https://platform.hiloenergie.com/api/digital-twin/v3/graphql", + headers={"Authorization": f"Bearer {self.access_token}"}, + ) + client = Client(transport=transport, fetch_schema_from_transport=True) + query = gql(self.QUERY_GET_LOCATION) + + async with client as session: + result = await session.execute( + query, variable_values={"locationHiloId": location_hilo_id} + ) + self._handle_query_result(result) + + async def subscribe_to_device_updated( + self, location_hilo_id: str, callback: callable = None + ) -> None: + transport = WebsocketsTransport( + url=f"wss://platform.hiloenergie.com/api/digital-twin/v3/graphql?access_token={self.access_token}" + ) + client = Client(transport=transport, fetch_schema_from_transport=True) + query = gql(self.SUBSCRIPTION_DEVICE_UPDATED) + try: + async with client as session: + async for result in session.subscribe( + query, variable_values={"locationHiloId": location_hilo_id} + ): + print(f"Received subscription result {result}") + device_hilo_id = self._handle_device_subscription_result(result) + callback(device_hilo_id) + except asyncio.CancelledError: + print("Subscription cancelled.") + asyncio.sleep(1) + await self.subscribe_to_device_updated(location_hilo_id) + + async def subscribe_to_location_updated( + self, location_hilo_id: str, callback: callable = None + ) -> None: + transport = WebsocketsTransport( + url=f"wss://platform.hiloenergie.com/api/digital-twin/v3/graphql?access_token={self.access_token}" + ) + client = Client(transport=transport, fetch_schema_from_transport=True) + query = gql(self.SUBSCRIPTION_LOCATION_UPDATED) + try: + async with client as session: + async for result in session.subscribe( + query, variable_values={"locationHiloId": location_hilo_id} + ): + print(f"Received subscription result {result}") + device_hilo_id = self._handle_location_subscription_result(result) + callback(device_hilo_id) + except asyncio.CancelledError: + print("Subscription cancelled.") + asyncio.sleep(1) + await self.subscribe_to_location_updated(location_hilo_id) + + def _handle_query_result(self, result: Dict[str, Any]) -> None: + devices_values: list[any] = result["getLocation"]["devices"] + attributes = self.mapper.map_query_values(devices_values) + self._devices.parse_values_received(attributes) + + def _handle_device_subscription_result(self, result: Dict[str, Any]) -> str: + devices_values: list[any] = result["onAnyDeviceUpdated"]["device"] + attributes = self.mapper.map_device_subscription_values(devices_values) + updated_device = self._devices.parse_values_received(attributes) + # callback to update the device in the UI + print(f"Device updated: {updated_device}") + return devices_values.get("hiloId") + + def _handle_location_subscription_result(self, result: Dict[str, Any]) -> str: + devices_values: list[any] = result["onAnyLocationUpdated"]["location"] + attributes = self.mapper.map_location_subscription_values(devices_values) + updated_device = self._devices.parse_values_received(attributes) + # callback to update the device in the UI + print(f"Device updated: {updated_device}") + return devices_values.get("hiloId")