diff --git a/pyhilo/__init__.py b/pyhilo/__init__.py index f222744..7320aa2 100644 --- a/pyhilo/__init__.py +++ b/pyhilo/__init__.py @@ -1,14 +1,14 @@ """Define the hilo package.""" from pyhilo.api import API -from pyhilo.devices import Devices +from pyhilo.const import UNMONITORED_DEVICES from pyhilo.device import HiloDevice +from pyhilo.device.switch import Switch +from pyhilo.devices import Devices from pyhilo.event import Event from pyhilo.exceptions import HiloError, InvalidCredentialsError, WebsocketError from pyhilo.oauth2 import AuthCodeWithPKCEImplementation from pyhilo.util import from_utc_timestamp, time_diff from pyhilo.websocket import WebsocketEvent -from pyhilo.const import UNMONITORED_DEVICES -from pyhilo.device.switch import Switch __all__ = [ "API", diff --git a/pyhilo/api.py b/pyhilo/api.py index ff26d1b..dac6b3c 100755 --- a/pyhilo/api.py +++ b/pyhilo/api.py @@ -6,12 +6,14 @@ import random import string import sys -from typing import Any, Callable, Union, cast +from typing import Any, Callable, Dict, 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, @@ -43,6 +45,7 @@ ) 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, @@ -492,11 +495,11 @@ async def android_register(self) -> None: }, ) - async def get_location_id(self) -> int: + async def get_location_ids(self) -> tuple[int, str]: url = f"{API_AUTOMATION_ENDPOINT}/Locations" LOG.debug(f"LocationId URL is {url}") req: list[dict[str, Any]] = await self.async_request("get", url) - return int(req[0]["id"]) + return (req[0]["id"], req[0]["locationHiloId"]) async def get_devices(self, location_id: int) -> list[dict[str, Any]]: """Get list of all devices""" @@ -510,6 +513,22 @@ 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 e91a1ff..bbb676b 100644 --- a/pyhilo/device/__init__.py +++ b/pyhilo/device/__init__.py @@ -1,4 +1,5 @@ """Define devices""" + from __future__ import annotations from dataclasses import dataclass, field @@ -36,6 +37,7 @@ def __init__( ) -> None: self._api = api self.id = 0 + self.hilo_id: str = "" self.location_id = 0 self.type = "Unknown" self.name = "Unknown" @@ -59,6 +61,7 @@ def update(self, **kwargs: Dict[str, Union[str, int, Dict]]) -> None: value = val.get("value") reading = { "deviceId": self.id, + "hiloId": self.hilo_id, "locationId": self.location_id, "timeStampUTC": datetime.utcnow().isoformat(), "value": value, @@ -86,7 +89,8 @@ def update(self, **kwargs: Dict[str, Union[str, int, Dict]]) -> None: elif att == "provider": att = "manufacturer" new_val = HILO_PROVIDERS.get( - int(val), f"Unknown ({val})") # type: ignore + int(val), f"Unknown ({val})" + ) # type: ignore else: if att == "serial": att = "identifier" @@ -232,10 +236,12 @@ def __init__(self, **kwargs: Dict[str, Any]): # value_type='%') # } kwargs["timeStamp"] = from_utc_timestamp( - kwargs.pop("timeStampUTC", "")) # type: ignore + kwargs.pop("timeStampUTC", "") + ) # type: ignore self.id = 0 self.value: Union[int, bool, str] = 0 self.device_id = 0 + self.hilo_id: str = "" self.device_attribute: DeviceAttribute self.__dict__.update({camel_to_snake(k): v for k, v in kwargs.items()}) self.unit_of_measurement = ( @@ -244,8 +250,7 @@ def __init__(self, **kwargs: Dict[str, Any]): else "" ) if not self.device_attribute: - LOG.warning( - f"Received invalid reading for {self.device_id}: {kwargs}") + LOG.warning(f"Received invalid reading for {self.device_id}: {kwargs}") def __repr__(self) -> str: return f"" diff --git a/pyhilo/device/climate.py b/pyhilo/device/climate.py index f24476d..c0b4ff1 100644 --- a/pyhilo/device/climate.py +++ b/pyhilo/device/climate.py @@ -1,5 +1,6 @@ """Climate object.""" from __future__ import annotations + from typing import Any, cast from pyhilo import API @@ -15,12 +16,14 @@ class Climate(HiloDevice): devices such as thermostats. """ - def __init__(self, api: API, **kwargs: dict[str, str | int | dict[Any, Any]]) -> None: + def __init__( + self, api: API, **kwargs: dict[str, str | int | dict[Any, Any]] + ) -> None: """Initialize the Climate object. - Args: - api: The Hilo API instance. - **kwargs: Keyword arguments containing device data. + Args: + api: The Hilo API instance. + **kwargs: Keyword arguments containing device data. """ super().__init__(api, **kwargs) LOG.debug("Setting up Climate device: %s", self.name) diff --git a/pyhilo/device/graphql_value_mapper.py b/pyhilo/device/graphql_value_mapper.py new file mode 100644 index 0000000..6a52e79 --- /dev/null +++ b/pyhilo/device/graphql_value_mapper.py @@ -0,0 +1,453 @@ +from datetime import datetime, timezone +from typing import Any, Dict, Generator, Union + +from pyhilo.device import DeviceReading + + +class GraphqlValueMapper: + """ + A class to map GraphQL values to DeviceReading instances. + """ + + def __init__(self, api: Any): + self._api = api + + 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: + 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]: + attributes: list[Dict[str, Any]] = self._map_basic_device(device) + match device["deviceType"]: + case "Tstat": + attributes.extend(self._build_thermostat(device)) + case "CCE": # Water Heater + attributes.extend(self._build_water_heater(device)) + case "CCR": # ChargeController + attributes.extend(self._build_charge_controller(device)) + case "HeatingFloor": + attributes.extend(self._build_floor_thermostat(device)) + # case "LowVoltageTstat": + case "ChargingPoint": + attributes.extend(self._build_charging_point(device)) + case "Meter": + attributes.extend(self._build_smart_meter(device)) + case "Hub": # Gateway + attributes.extend(self._build_gateway(device)) + case "ColorBulb": + attributes.extend(self._build_light(device)) + case "Dimmer": + attributes.extend(self._build_dimmer(device)) + case "Switch": + attributes.extend(self._build_switch(device)) + case _: + pass + return self._map_to_device_reading(attributes) + + def _map_to_device_reading( + self, attributes: list[Dict[str, Any]] + ) -> list[DeviceReading]: + return [DeviceReading(**attr) for attr in attributes] + + def _build_smart_meter(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: + attributes = [] + if device.get("zigbeeChannel") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], "ZigbeeChannel", device["zigBeeChannel"] + ) + ) + attributes.append( + self.build_attribute( + device["hiloId"], "Disconnected", device["connectionStatus"] == 2 + ), + ) + if device.get("power") is not None: + attributes.append(self._map_power(device)) + return attributes + + def _build_gateway(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: + attributes = [] + attributes.append( + self.build_attribute( + device["hiloId"], "LastStatusTime", device["lastConnectionTime"] + ) + ) + attributes.append( + self.build_attribute( + device["hiloId"], "Version", device["controllerSoftwareVersion"] + ) + ) + attributes.append( + self.build_attribute( + device["hiloId"], "Disconnected", device["connectionStatus"] == 2 + ) + ) # Offline + attributes.append( + self.build_attribute( + device["hiloId"], + "ZigbeePairingActivated", + device["zigBeePairingModeEnhanced"], + ) + ) + if device.get("zigBeeChannel") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], "ZigbeeChannel", device["zigBeeChannel"] + ) + ) + attributes.append( + self.build_attribute( + device["hiloId"], + "WillBeConnectedToSmartMeter", + device["willBeConnectedToSmartMeter"], + ) + ) + attributes.append( + self.build_attribute( + device["hiloId"], + "SmartMeterUnpaired", + device["smartMeterPairingStatus"], + ) + ) + return attributes + + def _build_thermostat( + self, device: Dict[str, Any], withDefaultMinMaxTemp: bool = True + ) -> list[Dict[str, Any]]: + attributes = [] + + if device.get("ambientTemperature") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], + "CurrentTemperature", + device["ambientTemperature"]["value"], + ) + ) + + if device.get("ambientTempSetpoint") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], + "TargetTemperature", + device["ambientTempSetpoint"]["value"], + ) + ) + + if device.get("heatDemand") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], "HeatDemand", device["heatDemand"] + ) + ) + attributes.append( + self.build_attribute(device["hiloId"], "Version", device["version"]) + ) + attributes.append( + self.build_attribute( + device["hiloId"], "ZigbeeVersion", device["zigbeeVersion"] + ) + ) + attributes.append( + self.build_attribute( + device["hiloId"], "Humidity", device.get("ambientHumidity") or 0 + ) + ) + if device.get("allowedModes") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], "ThermostatAllowedModes", device["allowedModes"] + ) + ) + + if device.get("power") is not None: + attributes.append(self._map_power(device)) + attributes.append(self._map_heating(device)) + attributes.append( + self.build_attribute( + device["hiloId"], + "ThermostatMode", + self._map_to_thermostat_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: + self.build_attribute( + device["maxAmbientTempSetpoint"], + "MaxTempSetpoint", + device["maxAmbientTempSetpoint"]["value"], + ) + if device.get("minAmbientTempSetpoint") is not None: + 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"], + ) + if device.get("minAmbientTempSetpointLimit") is not None: + self.build_attribute( + device["hiloId"], + "MinTempSetpointLimit", + device["minAmbientTempSetpointLimit"]["value"], + ) + return attributes + + def _build_floor_thermostat(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: + attributes = self._build_thermostat(device) + attributes.append( + self.build_attribute( + device["hiloId"], + "FloorMode", + self._map_to_floor_mode(device["floorMode"]), + ) + ) + if device.get("floorLimit") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], "FloorLimit", device["floorLimit"]["value"] + ) + ) + return attributes + + def _build_water_heater(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: + attributes = self._build_charge_controller(device) + attributes.append( + self.build_attribute( + device["hiloId"], + "AbnormalTemperature", + device.get("alerts") is not None + and "30" in device["alerts"], # AbnormalTemperature alert + ) + ) + if device.get("probeTemp") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], + "CurrentTemperature", + device["probeTemp"]["value"], # AbnormalTemperature alert + ) + ) + attributes.append(self._map_heating(device)) + return attributes + + def _build_charge_controller(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: + attributes = [] + if device.get("power") is not None: + attributes.append(self._map_power(device)) + + attributes.append( + self.build_attribute(device["hiloId"], "Version", device["version"]) + ) + attributes.append( + self.build_attribute( + device["hiloId"], "ZigbeeVersion", device["zigbeeVersion"] + ) + ) + attributes.append(self._map_gd_state(device)) + attributes.append(self._map_drms_state(device)) + if device.get("ccrAllowedModes") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], "CcrAllowedModes", device["ccrAllowedModes"] + ) + ) + attributes.append( + self.build_attribute( + device["hiloId"], "CcrMode", self._map_to_ccr_mode(device["ccrMode"]) + ) + ) + return attributes + + def _build_charging_point(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: + attributes = [] + status = device["status"] + if status in (1, 0): # is available (1) or OutOfService (0) + attributes.append(self.build_attribute(device["hiloId"], "Power", 0)) + elif device.get("power") is not None: + attributes.append((self._map_power(device))) + + attributes.append(self.build_attribute(device["hiloId"], "Status", status)) + return attributes + + def _build_switch(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: + attributes = [] + if device.get("power") is not None: + attributes.append(self._map_power(device)) + attributes.append( + self.build_attribute(device["hiloId"], "Status", device["state"]) + ) + attributes.append( + self.build_attribute(device["hiloId"], "OnOff", device["state"]) + ) + return attributes + + def _build_dimmer(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: + attributes = [] + if device.get("power") is not None: + attributes.append(self._map_power(device)) + if device.get("Level") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], "Intensity", device["Level"]["value"] / 100 + ) + ) + attributes.append( + self.build_attribute(device["hiloId"], "OnOff", device["state"]) + ) + return attributes + + def _build_light(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: + attributes = [] + if device.get("colorTemperature") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], + "ColorTemperature", + device["colorTemperature"]["value"], + ) + ) + if device.get("level") is not None: + attributes.append( + self.build_attribute( + device["hiloId"], "Intensity", device["level"]["value"] / 100 + ) + ) + if device.get("lightType") == 1: # LightType.Color + attributes.append( + self.build_attribute(device["hiloId"], "Hue", device.get("hue") or 0) + ) + attributes.append( + self.build_attribute( + device["hiloId"], "Saturation", device.get("saturation") or 0 + ) + ) + attributes.append( + self.build_attribute(device["hiloId"], "OnOff", device["atate"]) + ) + return attributes + + def _map_basic_device(self, device: Dict[str, Any]) -> list[Dict[str, Any]]: + return [ + self.build_attribute(device["hiloId"], "Unpaired", False), + self.build_attribute( + device["hiloId"], + "Disconnected", + device["connectionStatus"] == "Disconnected", + ), + ] + + def _map_gd_state(self, device: Dict[str, Any]) -> Dict[str, Any]: + return self.build_attribute( + device["hiloId"], "GdState", device["gDState"] == "Active" + ) + + def _map_drms_state(self, device: Dict[str, Any]) -> Dict[str, Any]: + return ( + self.build_attribute( + device["hiloId"], "DrmsState", device["gDState"] == "Active" + ), + ) + + def _map_heating(self, device: Dict[str, Any]) -> Dict[str, Any]: + power = device.get("power") + if ( + power is not None + and device["power"]["value"] is not None + and device["power"]["value"] > 0 + ): + return self.build_attribute(device["hiloId"], "Heating", 1) + + return self.build_attribute(device["hiloId"], "Heating", 0) + + def _map_power(self, device: Dict[str, Any]) -> Dict[str, Any]: + value = device["power"]["value"] if device["power"]["value"] is not None else 0 + return self.build_attribute( + device["hiloId"], + "Power", + self._power_kw_to_w(value, device["power"]["kind"]), + ) + + def _power_kw_to_w(self, power: float, power_kind: int) -> float: + if power_kind == 12: # PowerKind.KW + return power * 1000 + + return power + + def build_attribute( + self, hilo_id: str, device_attribute: str, value: Any + ) -> Dict[str, Any]: + return { + "hilo_id": hilo_id, + "device_attribute": self._api.dev_atts(device_attribute, "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 ff1ca6d..a4e8523 100644 --- a/pyhilo/devices.py +++ b/pyhilo/devices.py @@ -4,6 +4,7 @@ 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 @@ -14,6 +15,7 @@ def __init__(self, api: API): self._api = api self.devices: list[HiloDevice] = [] self.location_id: int = 0 + self.location_hilo_id: str = "" @property def all(self) -> list[HiloDevice]: @@ -49,7 +51,10 @@ def _map_readings_to_devices( ) -> list[HiloDevice]: updated_devices = [] for reading in readings: - if device := self.find_device(reading.device_id): + device_identifier = reading.device_id + if device_identifier == 0: + device_identifier = reading.hilo_id + if device := self.find_device(device_identifier): device.update_readings(reading) LOG.debug(f"{device} Received {reading}") if device not in updated_devices: @@ -60,8 +65,10 @@ def _map_readings_to_devices( ) return updated_devices - def find_device(self, id: int) -> HiloDevice: - return next((d for d in self.devices if d.id == id), None) # type: ignore + def find_device(self, device_identifier: int | str) -> HiloDevice: + if isinstance(device_identifier, int): + return next((d for d in self.devices if d.id == device_identifier), None) + return next((d for d in self.devices if d.hilo_id == device_identifier), None) def generate_device(self, device: dict) -> HiloDevice: device["location_id"] = self.location_id @@ -108,5 +115,12 @@ async def update_devicelist_from_signalr( async def async_init(self) -> None: """Initialize the Hilo "manager" class.""" LOG.info("Initialising after websocket is connected") - self.location_id = await self._api.get_location_id() + location_ids = await self._api.get_location_ids() + 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 new file mode 100644 index 0000000..45b2645 --- /dev/null +++ b/pyhilo/graphql.py @@ -0,0 +1,218 @@ +class GraphQlHelper: + def query_get_location() -> str: + return """query getLocation($locationHiloId: String!) { + getLocation(id:$locationHiloId) { + hiloId + lastUpdate + lastUpdateVersion + devices { + deviceType + hiloId + physicalAddress + connectionStatus + ... on Gateway { + connectionStatus + controllerSoftwareVersion + lastConnectionTime + willBeConnectedToSmartMeter + zigBeeChannel + zigBeePairingModeEnhanced + smartMeterZigBeeChannel + smartMeterPairingStatus + } + ... on BasicSmartMeter { + deviceType + hiloId + physicalAddress + connectionStatus + 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 + 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 + } + } + ... 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 + } + power { + value + kind + } + } + } + } + }"""