From b940de25b4c8135d360be6dd3344dd2fe5eb7bcd Mon Sep 17 00:00:00 2001 From: Anders Evenrud Date: Sat, 11 Feb 2023 16:29:18 +0100 Subject: [PATCH] feat: basic legacy api support Adds basic legacy api sensor support. I.e. reading of values. Squashed from the following commits: * refactor: switch to httpx * docs(readme): add legacy support notice * feat: support legacy websocket messages * refactor: parallel legacy node calls * fix: use correct previous key for legacy * fix: stricter node value checks * fix: don't pull energy on legacy * refactor: simplify nexa api calls * docs(readme): update description * refactor: move ws port to const * refactor: clean up ws class constructor * refactor: prepare for legacy energy metering support * refactor: split node value property population * docs(help): update compatiblity * refactor: remove unwanted newline * refactor: update energy model population * chore: auth digest detection for perf * feat: auto discovery for legacy (maybe) --- HELP.md | 2 + README.md | 2 + .../nexa_bridge_x/config_flow.py | 15 +- custom_components/nexa_bridge_x/const.py | 10 + custom_components/nexa_bridge_x/manifest.json | 6 +- custom_components/nexa_bridge_x/nexa.py | 196 ++++++++++++------ custom_components/nexa_bridge_x/sensor.py | 5 +- custom_components/nexa_bridge_x/strings.json | 3 +- .../nexa_bridge_x/translations/en.json | 3 +- 9 files changed, 165 insertions(+), 77 deletions(-) diff --git a/HELP.md b/HELP.md index c28111e..62555e9 100644 --- a/HELP.md +++ b/HELP.md @@ -27,6 +27,8 @@ This project was developed using a bridge with firmware version `2.4.1`. Assuming Nexa uses appropriate versioning, this integration *should* be compatible with any firmware version starting with `2`. +> The legacy "Nexa Bridge" (non-X) with version `1` firmware is supported, but with limited functionality. + ## I can't connect Ensure that you're connecting to the correct IP and with the correct credentials. diff --git a/README.md b/README.md index e7e48d7..f78dca7 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,8 @@ This is a custom Home Assistant integration for the [Nexa Bridge X](https://nexa.se/nexa-bridge-x). +> The legacy "Nexa Bridge" (non-X) is also supported, but with limited functionality. + Makes it possible to view and control devices set up in the Nexa App/Web UI. > This project is a **unofficial** integration and not affiliated with Nexa in any way. diff --git a/custom_components/nexa_bridge_x/config_flow.py b/custom_components/nexa_bridge_x/config_flow.py index d3561b4..5677cdd 100644 --- a/custom_components/nexa_bridge_x/config_flow.py +++ b/custom_components/nexa_bridge_x/config_flow.py @@ -32,6 +32,7 @@ vol.Required("host"): str, vol.Required("username", default=DEFAULT_USERNAME): str, vol.Required("password", default=DEFAULT_PASSWORD): str, + vol.Required("legacy", default=False): bool, } ) @@ -43,7 +44,7 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, """ try: - api = NexaApi(data["host"], data["username"], data["password"]) + api = NexaApi(hass, data["host"], data["username"], data["password"], data["legacy"]) info = await api.test_connection() except Exception: raise InvalidAuth @@ -60,6 +61,7 @@ class NexaBridgeXFlowHandler(ConfigFlow, domain=DOMAIN): _discovered_host: str | None = None _discovered_username: str | None = None _discovered_password: str | None = None + _discovered_legacy: bool = False async def async_step_user( self, user_input: dict[str, Any] | None = None @@ -67,7 +69,8 @@ async def async_step_user( """Handle the initial step.""" if user_input is None: return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA + step_id="user", + data_schema=STEP_USER_DATA_SCHEMA ) errors = {} @@ -102,6 +105,7 @@ async def async_step_zeroconf( host: str = discovery_info.host username: str = DEFAULT_USERNAME password: str = DEFAULT_PASSWORD + is_legacy: bool = "nexabridge2" not in uid await self.async_set_unique_id(uid.upper()) @@ -110,7 +114,7 @@ async def async_step_zeroconf( }) try: - api = NexaApi(host, 'nexa', 'nexa') + api = NexaApi(self.hass, host, 'nexa', 'nexa', is_legacy) info = await api.test_connection() except Exception: # pylint: disable=broad-except return self.async_abort(reason="unknown") @@ -119,13 +123,15 @@ async def async_step_zeroconf( self._discovered_host = host self._discovered_username = username self._discovered_password = password + self._discovered_legacy = is_legacy self._set_confirm_only() self.context["title_placeholders"] = { "host": host, "username": username, - "password": password + "password": password, + "legacy": is_legacy, } return await self.async_step_discovery_confirm() @@ -141,6 +147,7 @@ async def async_step_discovery_confirm( "host": self._discovered_host, "username": self._discovered_username, "password": self._discovered_password, + "legacy": self._discovered_legacy, } if user_input is None: diff --git a/custom_components/nexa_bridge_x/const.py b/custom_components/nexa_bridge_x/const.py index acad514..fd92e29 100644 --- a/custom_components/nexa_bridge_x/const.py +++ b/custom_components/nexa_bridge_x/const.py @@ -32,6 +32,10 @@ DEFAULT_PASSWORD = "nexa" +WS_PORT = 8887 + +HTTP_BASIC_AUTH = False + SWITCH_LEVEL_SENSOR = True SWITCH_BINARY_SENSOR = True @@ -71,6 +75,12 @@ "month_kilowatt_hours" ] +# TODO: Add support for legacy energy metering +LEGACY_ENERGY_ATTRS = [ + #"total_kilowatt_hours", + #"current_wattage", +] + BINARY_MAP = { "notificationContact": { "name": "Contact" diff --git a/custom_components/nexa_bridge_x/manifest.json b/custom_components/nexa_bridge_x/manifest.json index a3a0694..fc8b533 100644 --- a/custom_components/nexa_bridge_x/manifest.json +++ b/custom_components/nexa_bridge_x/manifest.json @@ -10,6 +10,10 @@ { "type": "_ssh._tcp.local.", "name": "nexabridge2" + }, + { + "type": "_ssh._tcp.local.", + "name": "nexabridge" } ], "dependencies": [ @@ -20,4 +24,4 @@ ], "iot_class": "local_polling", "integration_type": "hub" -} \ No newline at end of file +} diff --git a/custom_components/nexa_bridge_x/nexa.py b/custom_components/nexa_bridge_x/nexa.py index ea63f43..407abee 100644 --- a/custom_components/nexa_bridge_x/nexa.py +++ b/custom_components/nexa_bridge_x/nexa.py @@ -9,7 +9,7 @@ from functools import reduce from datetime import timedelta from typing import cast, Any -from aiohttp.web import Response +from homeassistant.helpers.httpx_client import get_async_client from homeassistant.exceptions import ConfigEntryAuthFailed from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant @@ -23,7 +23,9 @@ NODE_MEDIA_CAPABILITIES, POLL_INTERVAL, POLL_TIMEOUT, - RECONNECT_SLEEP + RECONNECT_SLEEP, + WS_PORT, + HTTP_BASIC_AUTH ) import dateutil.parser import asyncio @@ -31,12 +33,14 @@ import json import logging import async_timeout +import httpx _LOGGER = logging.getLogger(__name__) # TODO: Add correct typing NexaNodeValueType = str | int | float | bool NexaEnergyData = Any +NexaLegacyEnergyData = Any NexaEnergyNodeData = Any NexaNodeData = Any NexaInfoData = Any @@ -57,6 +61,25 @@ def is_newer_date(current: str, new: str) -> bool: return new_time >= current_time +def values_from_events(node: NexaNodeData, legacy: bool) -> list[NexaNodeValue]: + """Creates a list of node values based on node data""" + prev_key = legacy and "value" or "prevValue" + keys = (prev_key, "value", "time") + ignores = ("methodCall") + values = [] + + for key, data in node["lastEvents"].items(): + if key not in ignores and all(k in data for k in keys): + values.append(NexaNodeValue( + key, + data["value"], + data[prev_key], + data["time"] + )) + + return values + + class NexaApiError(Exception): """Base error""" @@ -84,10 +107,11 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry): host = entry.data["host"] username = entry.data["username"] password = entry.data["password"] + legacy = entry.data["legacy"] - self.api = NexaApi(host, username, password) - self.coordinator = NexaCoordinator(hass, self.api) - self.ws = NexaWebSocket(host, hass, self.coordinator) + self.api = NexaApi(hass, host, username, password, legacy) + self.coordinator = NexaCoordinator(hass, self.api, legacy) + self.ws = NexaWebSocket(hass, host, self.coordinator) async def destroy(self) -> None: """Destroy all running services""" @@ -110,12 +134,12 @@ class NexaWebSocket: def __init__( self, - host: str, hass: HomeAssistant, + host: str, coordinator: NexaCoordinator ) -> None: - self.host = host self.hass = hass + self.host = host self.coordinator = coordinator async def destroy(self) -> None: @@ -196,7 +220,7 @@ async def connect(self, reconnect: bool = False) -> None: if self.stopping: return - url = f"ws://{self.host}:8887" + url = f"ws://{self.host}:{WS_PORT}" _LOGGER.debug( "%s to websocket: %s", @@ -213,28 +237,38 @@ async def connect(self, reconnect: bool = False) -> None: class NexaApi: """Nexa API""" - def __init__(self, host: str, username: str, password: str) -> None: + def __init__( + self, + hass: HomeAssistant, + host: str, + username: str, + password: str, + legacy: bool + ) -> None: + self.hass = hass self.host = host self.username = username self.password = password + self.legacy = legacy + self._client = get_async_client(hass) - async def handle_response(self, response: Response) -> Any: + async def handle_response(self, method: str, response: httpx.Response) -> Any: """Handles response""" _LOGGER.debug("%s %s: %s", - str.upper(response.method), + str.upper(method), response.url, - response.status) + response.status_code) - if not response.ok: - text = await response.text() - if response.status == 400: - raise NexaApiInvalidBodyError(text) - if response.status == 401: - raise NexaApiAuthorizationError(text) + ok = response.status_code >= 200 and response.status_code < 300 + if not ok: + if response.status_code == 400: + raise NexaApiInvalidBodyError(response.text) + if response.status_code == 401: + raise NexaApiAuthorizationError(response.text) - raise NexaApiGeneralError(text) + raise NexaApiGeneralError(response.text) - return await response.json() + return response.json() async def request( self, @@ -244,27 +278,22 @@ async def request( ) -> Response: """Performs a request""" url = "http://%s/v1/%s" % (self.host, endpoint or "") - auth = aiohttp.BasicAuth(self.username, self.password) - - async with aiohttp.ClientSession() as session: - if method == "post": - headers = { - "accept": "application/json", - "content-type": "application/json" - } - - _LOGGER.debug("POST %s: %s", url, json.dumps(body)) - - async with session.post( - url, - auth=auth, - json=body, - headers=headers - ) as response: - return await self.handle_response(response) - else: - async with session.get(url, auth=auth) as response: - return await self.handle_response(response) + + if HTTP_BASIC_AUTH or not self.legacy: + auth = httpx.BasicAuth(self.username, self.password) + else: + auth = httpx.DigestAuth(self.username, self.password) + + _LOGGER.debug("%s %s: %s", str.upper(method), url, json.dumps(body)) + + response = await self._client.request( + method, + url, + auth=auth, + json=body, + ) + + return await self.handle_response(method, response) async def test_connection(self) -> NexaInfoData: """See if the connection is valid""" @@ -277,11 +306,14 @@ async def test_connection(self) -> NexaInfoData: if key not in result: raise NexaApiNotCompatibleError("Device response invalid") - if result["systemType"] != "Bridge2": + check_type = self.legacy and "Bridge1" or "Bridge2" + check_ver = self.legacy and "1" or "2" + + if result["systemType"] != check_type: raise NexaApiNotCompatibleError("Device system not compatible") # TODO: Add semver check in the future if there are firmware diffs - if not str(result["version"]).startswith("2"): + if not str(result["version"]).startswith(check_ver): raise NexaApiNotCompatibleError("Endpoint not compatible") return result @@ -292,18 +324,28 @@ async def fetch_info(self) -> NexaInfoData: async def fetch_nodes(self) -> list[NexaNodeData]: """Get all configured nodes""" - return await self.request("get", "nodes") + result = await self.request("get", "nodes") + + if self.legacy: + return await asyncio.gather(*[ + self.fetch_node(r["id"]) for r in result + ]) + + return result async def fetch_node(self, node: str) -> NexaNodeData: """Get a confiured node""" return await self.request("get", f"nodes/{node}") - async def fetch_energy(self) -> NexaEnergyData: + async def fetch_energy(self) -> NexaEnergyData | NexaLegacyEnergyData: """Get energy stats""" return await self.request("get", "energy") - async def fetch_energy_nodes(self) -> NexaEnergyNodeData: + async def fetch_energy_nodes(self) -> NexaEnergyNodeData | None: """Get energy node stats""" + if self.legacy: + return None + return await self.request("get", "energy/nodes") async def node_call( @@ -313,7 +355,12 @@ async def node_call( value: any ) -> NexaCallData: """Perform an action on a device""" - body = {"capability": capability, "value": value} + if self.legacy and capability == "switchBinary": + binaryValue = value and "turnOn" or "turnOff" + body = {"cap": capability, "method": binaryValue} + else: + body = {"cap": capability, "value": value} + return await self.request("post", f"nodes/{node}/call", body) @@ -358,8 +405,9 @@ class NexaEnergy: def __init__( self, - data: NexaEnergyData, - node_data: NexaEnergyNodeData + data: NexaEnergyData | NexaLegacyEnergyData, + node_data: NexaEnergyNodeData | None, + legacy: bool ): self.total_kilowatt_hours = None self.current_wattage = None @@ -368,6 +416,26 @@ def __init__( self.yesterday_kilowatt_hours = None self.month_kilowatt_hours = None + if not legacy and data and node_data: + self.populate(data, node_data) + elif legacy and data: + self.populate_legacy(data) + + def populate_legacy( + self, + data: NexaLegacyEnergyData + ): + """Populate legacy energy data from api""" + # FIXME: What even are these values ?! + self.current_wattage = data["kW"] / 1000 + self.total_kilowatt_hours = data["kWh"] + + def populate( + self, + data: NexaEnergyData, + node_data: NexaEnergyNodeData, + ): + """Populate energy data from api""" if node_data["status"] == "OK": if "list" in node_data["data"]: self.total_kilowatt_hours = reduce( @@ -395,21 +463,11 @@ class NexaNode: capabilities: list[str] values: list[NexaNodeValue] - def __init__(self, node: NexaNodeData): - values = [] - for key, data in node["lastEvents"].items(): - nv = NexaNodeValue( - key, - data["value"], - data["prevValue"], - data["time"] - ) - values.append(nv) - + def __init__(self, node: NexaNodeData, legacy: bool): self.id = node["id"] self.name = node["name"] self.capabilities = node["capabilities"] - self.values = values + self.values = values_from_events(node, legacy) def get_binary_capabilities(self) -> list[str]: """Get all capabilities""" @@ -501,7 +559,7 @@ def __init__( class NexaCoordinator(DataUpdateCoordinator): """Coordinates updates between entities""" - def __init__(self, hass: HomeAssistant, api: NexaApi): + def __init__(self, hass: HomeAssistant, api: NexaApi, legacy: bool): super().__init__( hass, _LOGGER, @@ -509,6 +567,7 @@ def __init__(self, hass: HomeAssistant, api: NexaApi): update_interval=timedelta(seconds=POLL_INTERVAL), ) self.api = api + self.legacy = legacy def get_node_by_id(self, node_id: str) -> NexaNode | None: """Gets node by id""" @@ -534,15 +593,16 @@ async def update_node_from_message(self, data: NexaWebsocketData) -> None: _LOGGER.info("Coordinator is not yet ready to update data...") return - for cap in ("capability", "sourceNode", "value"): - if cap not in data: - return + cap_key = self.legacy and "name" or "capability" + keys = (cap_key, "sourceNode", "value", "time") + if not all(k in data for k in keys): + return node_id: str = data["sourceNode"] if node_id and str(node_id) != "-1": value: NexaNodeValueType = data["value"] time: NexaNodeValueType = data["time"] - cap: str = data["capability"] + cap: str = data[cap_key] _LOGGER.debug("Coordinator update message: %s", data) @@ -566,8 +626,8 @@ async def _async_update_data(self) -> None: data = NexaData( NexaInfo(info), - list(map(lambda n: NexaNode(n), nodes)), - NexaEnergy(energy, energy_nodes) + list(map(lambda n: NexaNode(n, self.legacy), nodes)), + NexaEnergy(energy, energy_nodes, self.legacy) ) if self.data: diff --git a/custom_components/nexa_bridge_x/sensor.py b/custom_components/nexa_bridge_x/sensor.py index dfebb88..be99319 100644 --- a/custom_components/nexa_bridge_x/sensor.py +++ b/custom_components/nexa_bridge_x/sensor.py @@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.config_entries import ConfigEntry -from .const import (DOMAIN, ENERGY_ATTRS) +from .const import (DOMAIN, ENERGY_ATTRS, LEGACY_ENERGY_ATTRS) from .entities import ( NexaSensorEntity, NexaEnergyEntity @@ -30,9 +30,10 @@ async def async_setup_entry( coordinator.data.nodes ) + use_attrs = LEGACY_ENERGY_ATTRS if coordinator.legacy else ENERGY_ATTRS energy_entities = ( NexaEnergyEntity(coordinator, attr) - for attr in (ENERGY_ATTRS) + for attr in use_attrs ) sensor_entities = ( diff --git a/custom_components/nexa_bridge_x/strings.json b/custom_components/nexa_bridge_x/strings.json index 81103af..3d5fe79 100644 --- a/custom_components/nexa_bridge_x/strings.json +++ b/custom_components/nexa_bridge_x/strings.json @@ -5,7 +5,8 @@ "data": { "host": "[%key:common::config_flow::data::host%]", "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "password": "[%key:common::config_flow::data::password%]", + "legacy": "[%key:common::config_flow::data::legacy%]" } }, "discovery_confirm": { diff --git a/custom_components/nexa_bridge_x/translations/en.json b/custom_components/nexa_bridge_x/translations/en.json index f582c27..0638332 100644 --- a/custom_components/nexa_bridge_x/translations/en.json +++ b/custom_components/nexa_bridge_x/translations/en.json @@ -13,7 +13,8 @@ "data": { "host": "Host", "password": "Password", - "username": "Username" + "username": "Username", + "legacy": "Legacy bridge" } }, "discovery_confirm": {