From 034882a6f930d64a163fa00ebb3a85b2ed1708f5 Mon Sep 17 00:00:00 2001 From: Greg Brunke Date: Sat, 7 Mar 2026 15:21:22 -0500 Subject: [PATCH 1/8] feat: Adding ethernet CAN-to-Ethernet bridge configuration and discovery --- README.md | 19 +++ custom_components/ha_onecontrol/__init__.py | 9 +- .../ha_onecontrol/binary_sensor.py | 47 ++---- custom_components/ha_onecontrol/button.py | 20 +-- custom_components/ha_onecontrol/climate.py | 11 +- .../ha_onecontrol/config_flow.py | 135 +++++++++++++++- custom_components/ha_onecontrol/const.py | 14 ++ .../ha_onecontrol/coordinator.py | 153 ++++++++++++++++++ custom_components/ha_onecontrol/cover.py | 11 +- .../ha_onecontrol/diagnostics.py | 11 +- .../ha_onecontrol/entity_helpers.py | 24 +++ custom_components/ha_onecontrol/light.py | 20 +-- .../protocol/ethernet_discovery.py | 137 ++++++++++++++++ custom_components/ha_onecontrol/sensor.py | 11 +- custom_components/ha_onecontrol/strings.json | 21 ++- custom_components/ha_onecontrol/switch.py | 20 +-- .../ha_onecontrol/translations/en.json | 21 ++- docs/TECH_SPEC.md | 13 +- tests/conftest.py | 11 +- tests/test_cobs.py | 4 +- tests/test_config_flow_ethernet.py | 52 ++++++ tests/test_crc8.py | 2 +- tests/test_ethernet_discovery.py | 55 +++++++ tests/test_tea.py | 5 +- 24 files changed, 714 insertions(+), 112 deletions(-) create mode 100644 custom_components/ha_onecontrol/entity_helpers.py create mode 100644 custom_components/ha_onecontrol/protocol/ethernet_discovery.py create mode 100644 tests/test_config_flow_ethernet.py create mode 100644 tests/test_ethernet_discovery.py diff --git a/README.md b/README.md index 401bfbc..92ae003 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ During setup, the integration discovers OneControl gateways via BLE advertisemen | - | - | | **Push-to-Pair** | Has a physical "Connect" button on the RV control panel | | **PIN (legacy)** | No Connect button — uses only the 6-digit PIN sticker | +| **CAN-to-Ethernet Bridge (experimental)** | IDS / CAN_TO_ETHERNET_GATEWAY on your local network | ### Push-to-Pair gateways (newer) @@ -77,6 +78,24 @@ This approach is experimental. If you are attempting this, use the `pairing\_te - Flash the pairing helper to the **exact device** that will serve as the proxy — bonds are not transferable between ESP32 units - OTA-flash the production proxy firmware **without erasing flash** after bonding + +## Experimental: CAN-to-Ethernet Bridge + +The integration now includes an experimental Ethernet path for older OneControl +systems that expose CAN traffic through an IDS CAN-to-Ethernet bridge. + +1. In config flow, select **CAN-to-Ethernet bridge** as the connection type. + +2. The integration listens for UDP advertisements and pre-fills host/port when found. + +3. If discovery does not find the bridge, enter host and port manually. + +Default bridge values are commonly `192.168.1.1` and `6969`. + +The current implementation assumes payload framing compatibility with the BLE +COBS protocol path. If your bridge wraps frames differently, bridge-specific +framing support will be needed. + ## Supported Devices - **Switches** — Relay-controlled devices (lights, water pump, water heaters, tank heater) diff --git a/custom_components/ha_onecontrol/__init__.py b/custom_components/ha_onecontrol/__init__.py index f138968..8471edd 100644 --- a/custom_components/ha_onecontrol/__init__.py +++ b/custom_components/ha_onecontrol/__init__.py @@ -4,13 +4,16 @@ import logging import re +from typing import TYPE_CHECKING from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from .const import DOMAIN -from .coordinator import OneControlCoordinator + +if TYPE_CHECKING: + from .coordinator import OneControlCoordinator _LOGGER = logging.getLogger(__name__) @@ -94,6 +97,8 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up OneControl from a config entry.""" + from .coordinator import OneControlCoordinator + hass.data.setdefault(DOMAIN, {}) existing: OneControlCoordinator | None = hass.data[DOMAIN].get(entry.entry_id) @@ -130,6 +135,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload a config entry.""" + from .coordinator import OneControlCoordinator + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) if unload_ok: diff --git a/custom_components/ha_onecontrol/binary_sensor.py b/custom_components/ha_onecontrol/binary_sensor.py index cfb1011..9d00c18 100644 --- a/custom_components/ha_onecontrol/binary_sensor.py +++ b/custom_components/ha_onecontrol/binary_sensor.py @@ -24,12 +24,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EntityCategory from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OneControlCoordinator +from .entity_helpers import build_gateway_device_info from .protocol.events import GeneratorStatus _LOGGER = logging.getLogger(__name__) @@ -87,12 +87,9 @@ def __init__(self, coordinator: OneControlCoordinator, address: str) -> None: super().__init__(coordinator) mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_gateway_connected" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) @property @@ -114,12 +111,9 @@ def __init__(self, coordinator: OneControlCoordinator, address: str) -> None: super().__init__(coordinator) mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_gateway_authenticated" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) @property @@ -146,12 +140,9 @@ def __init__(self, coordinator: OneControlCoordinator, address: str) -> None: super().__init__(coordinator) mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_in_motion_lockout" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) @property @@ -192,12 +183,9 @@ def __init__(self, coordinator: OneControlCoordinator, address: str) -> None: super().__init__(coordinator) mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_data_healthy" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) @property @@ -236,12 +224,9 @@ def __init__( self._key = f"{table_id:02x}:{device_id:02x}" mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_gen_quiet_{device_id:02x}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) self._unsub = coordinator.register_event_callback(self._on_event) diff --git a/custom_components/ha_onecontrol/button.py b/custom_components/ha_onecontrol/button.py index 352d1a9..97457c2 100644 --- a/custom_components/ha_onecontrol/button.py +++ b/custom_components/ha_onecontrol/button.py @@ -14,12 +14,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, EntityCategory from homeassistant.core import HomeAssistant -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OneControlCoordinator +from .entity_helpers import build_gateway_device_info _LOGGER = logging.getLogger(__name__) @@ -57,12 +57,9 @@ def __init__(self, coordinator: OneControlCoordinator, address: str) -> None: super().__init__(coordinator) mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_clear_lockout" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) @property @@ -98,12 +95,9 @@ def __init__(self, coordinator: OneControlCoordinator, address: str) -> None: super().__init__(coordinator) mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_refresh_metadata" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) @property diff --git a/custom_components/ha_onecontrol/climate.py b/custom_components/ha_onecontrol/climate.py index 683138e..b0b8931 100644 --- a/custom_components/ha_onecontrol/climate.py +++ b/custom_components/ha_onecontrol/climate.py @@ -21,7 +21,6 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS, UnitOfTemperature from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity @@ -42,6 +41,7 @@ HVAC_SETPOINT_DEBOUNCE_S, ) from .coordinator import OneControlCoordinator +from .entity_helpers import build_gateway_device_info from .protocol.events import HvacZone _LOGGER = logging.getLogger(__name__) @@ -118,12 +118,9 @@ def __init__( self._key = f"{table_id:02x}:{device_id:02x}" mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_climate_{device_id:02x}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) self._unsub = coordinator.register_event_callback(self._on_event) self._setpoint_debounce_handle: asyncio.TimerHandle | None = None diff --git a/custom_components/ha_onecontrol/config_flow.py b/custom_components/ha_onecontrol/config_flow.py index 6db348d..384dec3 100644 --- a/custom_components/ha_onecontrol/config_flow.py +++ b/custom_components/ha_onecontrol/config_flow.py @@ -2,6 +2,7 @@ from __future__ import annotations +import asyncio import logging from typing import Any @@ -14,9 +15,17 @@ from homeassistant.const import CONF_ADDRESS from .const import ( + CONNECTION_TYPE_BLE, + CONNECTION_TYPE_ETHERNET, + CONF_CONNECTION_TYPE, CONF_BLUETOOTH_PIN, + CONF_ETH_HOST, + CONF_ETH_PORT, CONF_GATEWAY_PIN, CONF_PAIRING_METHOD, + DEFAULT_ETH_HOST, + DEFAULT_ETH_PORT, + ETH_DISCOVERY_LISTEN_SECS, DEFAULT_GATEWAY_PIN, DOMAIN, GATEWAY_NAME_PREFIX, @@ -24,6 +33,7 @@ LIPPERT_MANUFACTURER_ID_ALT, ) from .protocol.advertisement import PairingMethod +from .protocol.ethernet_discovery import discover_can_ethernet_bridges _LOGGER = logging.getLogger(__name__) @@ -39,6 +49,9 @@ def __init__(self) -> None: self._address: str | None = None self._name: str | None = None self._pairing_method: PairingMethod = PairingMethod.UNKNOWN + self._connection_type: str = CONNECTION_TYPE_BLE + self._eth_host: str = DEFAULT_ETH_HOST + self._eth_port: int = DEFAULT_ETH_PORT # ------------------------------------------------------------------ # Bluetooth discovery entry point @@ -72,6 +85,30 @@ async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle a flow initiated by the user.""" + if user_input is not None: + self._connection_type = user_input[CONF_CONNECTION_TYPE] + if self._connection_type == CONNECTION_TYPE_ETHERNET: + return await self.async_step_ethernet() + return await self.async_step_user_ble() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_CONNECTION_TYPE, default=CONNECTION_TYPE_BLE): vol.In( + { + CONNECTION_TYPE_BLE: "Bluetooth gateway", + CONNECTION_TYPE_ETHERNET: "CAN-to-Ethernet bridge", + } + ) + } + ), + ) + + async def async_step_user_ble( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle manual BLE gateway selection.""" if user_input is not None: # User picked a device from the list address = user_input[CONF_ADDRESS] @@ -107,12 +144,107 @@ async def async_step_user( return self.async_abort(reason="no_devices_found") return self.async_show_form( - step_id="user", + step_id="user_ble", data_schema=vol.Schema( {vol.Required(CONF_ADDRESS): vol.In(devices)} ), ) + async def async_step_ethernet( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Configure an IDS CAN-to-Ethernet bridge endpoint.""" + errors: dict[str, str] = {} + + if user_input is None: + discovered = await discover_can_ethernet_bridges(ETH_DISCOVERY_LISTEN_SECS) + if discovered: + self._name = discovered[0].name + self._eth_host = discovered[0].host + self._eth_port = discovered[0].port + _LOGGER.debug( + "Ethernet discovery selected bridge: name=%s host=%s port=%d (candidates=%d)", + self._name, + self._eth_host, + self._eth_port, + len(discovered), + ) + else: + _LOGGER.debug( + "Ethernet discovery found no bridge advertisements; using defaults host=%s port=%d", + self._eth_host, + self._eth_port, + ) + + if user_input is not None: + host = str(user_input[CONF_ETH_HOST]).strip() + port = user_input[CONF_ETH_PORT] + pin = str(user_input.get(CONF_GATEWAY_PIN, DEFAULT_GATEWAY_PIN)).strip() + + if not host: + errors[CONF_ETH_HOST] = "invalid_host" + elif not isinstance(port, int) or port < 1 or port > 65535: + errors[CONF_ETH_PORT] = "invalid_port" + elif pin and (len(pin) != 6 or not pin.isdigit()): + errors[CONF_GATEWAY_PIN] = "invalid_pin" + elif not await self._async_can_connect_ethernet(host, int(port)): + errors["base"] = "cannot_connect" + else: + unique_id = f"eth:{host}:{port}" + await self.async_set_unique_id(unique_id) + self._abort_if_unique_id_configured() + + self._eth_host = host + self._eth_port = int(port) + self._address = host + self._name = self._name or f"OneControl Ethernet {host}" + + return self.async_create_entry( + title=self._name, + data={ + CONF_CONNECTION_TYPE: CONNECTION_TYPE_ETHERNET, + CONF_ETH_HOST: self._eth_host, + CONF_ETH_PORT: self._eth_port, + CONF_ADDRESS: self._eth_host, + CONF_GATEWAY_PIN: pin or DEFAULT_GATEWAY_PIN, + CONF_PAIRING_METHOD: PairingMethod.NONE.value, + }, + ) + + return self.async_show_form( + step_id="ethernet", + data_schema=vol.Schema( + { + vol.Required(CONF_ETH_HOST, default=self._eth_host): str, + vol.Required(CONF_ETH_PORT, default=self._eth_port): int, + vol.Optional(CONF_GATEWAY_PIN, default=DEFAULT_GATEWAY_PIN): str, + } + ), + errors=errors, + description_placeholders={ + "default_host": DEFAULT_ETH_HOST, + "default_port": str(DEFAULT_ETH_PORT), + }, + ) + + async def _async_can_connect_ethernet(self, host: str, port: int) -> bool: + """Return True if the configured Ethernet endpoint is reachable.""" + try: + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host=host, port=port), + timeout=3.0, + ) + except Exception as exc: # noqa: BLE001 + _LOGGER.debug("Ethernet connectivity probe failed for %s:%d: %s", host, port, exc) + return False + + writer.close() + try: + await writer.wait_closed() + except Exception: # noqa: BLE001 + pass + return True + # ------------------------------------------------------------------ # Pairing method selection # ------------------------------------------------------------------ @@ -161,6 +293,7 @@ async def async_step_confirm( else: data = { CONF_ADDRESS: self._address, + CONF_CONNECTION_TYPE: CONNECTION_TYPE_BLE, CONF_GATEWAY_PIN: pin, CONF_PAIRING_METHOD: self._pairing_method.value, } diff --git a/custom_components/ha_onecontrol/const.py b/custom_components/ha_onecontrol/const.py index b925671..4b0d40d 100644 --- a/custom_components/ha_onecontrol/const.py +++ b/custom_components/ha_onecontrol/const.py @@ -2,6 +2,12 @@ DOMAIN = "ha_onecontrol" +# --------------------------------------------------------------------------- +# Connection types +# --------------------------------------------------------------------------- +CONNECTION_TYPE_BLE = "ble" +CONNECTION_TYPE_ETHERNET = "ethernet" + # --------------------------------------------------------------------------- # BLE Service & Characteristic UUIDs # --------------------------------------------------------------------------- @@ -152,3 +158,11 @@ CONF_BLUETOOTH_PIN = "bluetooth_pin" CONF_PAIRING_METHOD = "pairing_method" CONF_BONDED_SOURCE = "bonded_source" +CONF_CONNECTION_TYPE = "connection_type" +CONF_ETH_HOST = "eth_host" +CONF_ETH_PORT = "eth_port" + +DEFAULT_ETH_HOST = "192.168.1.1" +DEFAULT_ETH_PORT = 6969 +ETH_DISCOVERY_LISTEN_SECS = 3.0 +ETH_DISCOVERY_UDP_PORTS = (47664, 6969) diff --git a/custom_components/ha_onecontrol/coordinator.py b/custom_components/ha_onecontrol/coordinator.py index 0724288..e34c173 100644 --- a/custom_components/ha_onecontrol/coordinator.py +++ b/custom_components/ha_onecontrol/coordinator.py @@ -44,8 +44,12 @@ AUTH_SERVICE_UUID, BLE_MTU_SIZE, CAN_WRITE_CHAR_UUID, + CONNECTION_TYPE_ETHERNET, + CONF_CONNECTION_TYPE, CONF_BLUETOOTH_PIN, CONF_BONDED_SOURCE, + CONF_ETH_HOST, + CONF_ETH_PORT, CONF_GATEWAY_PIN, CONF_PAIRING_METHOD, DATA_READ_CHAR_UUID, @@ -176,6 +180,9 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self.entry = entry self.address: str = entry.data[CONF_ADDRESS] self.gateway_pin: str = entry.data.get(CONF_GATEWAY_PIN, DEFAULT_GATEWAY_PIN) + self._connection_type: str = entry.data.get(CONF_CONNECTION_TYPE, "ble") + self._eth_host: str = entry.data.get(CONF_ETH_HOST, "") + self._eth_port: int = int(entry.data.get(CONF_ETH_PORT, 0) or 0) # ── PIN-based pairing (legacy gateways) ────────────────────── self._pairing_method: str = entry.data.get(CONF_PAIRING_METHOD, "push_button") @@ -195,6 +202,9 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._current_connect_source: str | None = None self._client: BleakClient | None = None + self._eth_reader: asyncio.StreamReader | None = None + self._eth_writer: asyncio.StreamWriter | None = None + self._ethernet_reader_task: asyncio.Task | None = None self._decoder = CobsByteDecoder(use_crc=True) self._cmd = CommandBuilder() self._authenticated = False @@ -307,8 +317,15 @@ def instance_tag(self) -> str: @property def connected(self) -> bool: + if self.is_ethernet_gateway: + return self._connected and self._eth_writer is not None return self._connected and self._client is not None + @property + def is_ethernet_gateway(self) -> bool: + """Return True if this entry uses the Ethernet bridge transport.""" + return self._connection_type == CONNECTION_TYPE_ETHERNET + @property def authenticated(self) -> bool: return self._authenticated @@ -348,6 +365,15 @@ def _unsub() -> None: async def async_send_command(self, raw_command: bytes) -> None: """COBS-encode and write a command to the gateway.""" + if self.is_ethernet_gateway: + if not self._eth_writer or not self._connected: + raise ConnectionError("Not connected to Ethernet bridge") + encoded = cobs_encode(raw_command) + _LOGGER.debug("TX Ethernet command (%d bytes raw): %s", len(raw_command), raw_command.hex()) + self._eth_writer.write(encoded) + await self._eth_writer.drain() + return + if not self._client or not self._connected: raise BleakError("Not connected to gateway") encoded = cobs_encode(raw_command) @@ -760,6 +786,10 @@ async def async_clear_lockout(self) -> None: return self._last_lockout_clear = now + if self.is_ethernet_gateway: + _LOGGER.warning("Lockout clear over Ethernet bridge is not implemented") + return + if not self._client or not self._connected: raise BleakError("Not connected to gateway") @@ -832,6 +862,10 @@ async def async_disconnect(self) -> None: self._cancel_reconnect() self._connected = False self._authenticated = False + if self.is_ethernet_gateway: + await self._close_ethernet_transport() + self._decoder.reset() + return if self._client: try: await self._client.disconnect() @@ -842,6 +876,10 @@ async def async_disconnect(self) -> None: async def _do_connect(self) -> None: """Internal connect routine with retry logic.""" + if self.is_ethernet_gateway: + await self._do_connect_ethernet() + return + max_attempts = 3 last_exc: Exception | None = None for attempt in range(1, max_attempts + 1): @@ -956,6 +994,104 @@ async def _do_connect(self) -> None: # All paths exhausted raise last_exc + async def _do_connect_ethernet(self) -> None: + """Connect to an IDS CAN-to-Ethernet bridge with retries.""" + max_attempts = 3 + last_exc: Exception | None = None + for attempt in range(1, max_attempts + 1): + try: + await self._try_connect_ethernet(attempt) + return + except Exception as exc: + last_exc = exc + _LOGGER.warning( + "Ethernet connection attempt %d/%d failed: %s", + attempt, + max_attempts, + exc, + ) + await self._close_ethernet_transport() + self._connected = False + self._authenticated = False + if attempt < max_attempts: + delay = 2 * attempt + _LOGGER.info("Retrying Ethernet in %ds...", delay) + await asyncio.sleep(delay) + + assert last_exc is not None + raise last_exc + + async def _try_connect_ethernet(self, attempt: int) -> None: + """Open TCP connection to Ethernet bridge and start reader task.""" + host = self._eth_host or self.address + port = self._eth_port + if not host or port <= 0: + raise ConnectionError("Ethernet host/port are not configured") + + _LOGGER.info( + "Connecting to OneControl bridge %s:%d (attempt %d)", + host, + port, + attempt, + ) + + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host=host, port=port), + timeout=10.0, + ) + + self._eth_reader = reader + self._eth_writer = writer + self._connected = True + self._authenticated = True + self._decoder.reset() + self.async_set_updated_data(self._build_data()) + + self._ethernet_reader_task = self.hass.async_create_background_task( + self._ethernet_read_loop(), + "ha_onecontrol_ethernet_reader", + ) + self._start_heartbeat() + self.hass.async_create_task(self._send_initial_get_devices()) + + async def _ethernet_read_loop(self) -> None: + """Read Ethernet bytes and decode COBS frames into protocol events.""" + if self._eth_reader is None: + return + + try: + while self._connected and self._eth_reader is not None: + chunk = await self._eth_reader.read(512) + if not chunk: + raise ConnectionError("Ethernet bridge closed connection") + for byte_val in chunk: + frame = self._decoder.decode_byte(byte_val) + if frame is not None: + self._process_frame(frame) + except asyncio.CancelledError: + return + except Exception as exc: # noqa: BLE001 + _LOGGER.warning("Ethernet read loop ended: %s", exc) + finally: + if self._connected: + self._handle_transport_disconnect("ethernet") + + async def _close_ethernet_transport(self) -> None: + """Close active Ethernet socket and reader task.""" + if self._ethernet_reader_task and not self._ethernet_reader_task.done(): + self._ethernet_reader_task.cancel() + self._ethernet_reader_task = None + + if self._eth_writer is not None: + self._eth_writer.close() + try: + await self._eth_writer.wait_closed() + except Exception: # noqa: BLE001 + pass + + self._eth_reader = None + self._eth_writer = None + @property def is_pin_gateway(self) -> bool: """True if this gateway uses PIN-based (legacy) BLE pairing.""" @@ -2074,7 +2210,11 @@ def _build_data(self) -> dict[str, Any]: data: dict[str, Any] = { "connected": self._connected, "authenticated": self._authenticated, + "connection_type": self._connection_type, } + if self.is_ethernet_gateway: + data["eth_host"] = self._eth_host + data["eth_port"] = self._eth_port if self.rv_status: data["voltage"] = self.rv_status.voltage data["temperature"] = self.rv_status.temperature @@ -2090,6 +2230,10 @@ def _build_data(self) -> dict[str, Any]: @callback def _on_disconnect(self, client: BleakClient) -> None: """Handle unexpected BLE disconnect — schedule reconnect with backoff.""" + self._handle_transport_disconnect("ble") + + def _handle_transport_disconnect(self, transport: str) -> None: + """Handle unexpected transport disconnect and schedule reconnect.""" _LOGGER.warning("OneControl %s disconnected (instance=%s)", self.address, self._instance_tag) self._stop_heartbeat() self._connected = False @@ -2118,6 +2262,15 @@ def _on_disconnect(self, client: BleakClient) -> None: self._pin_agent_ctx = None self.hass.async_create_task(ctx.cleanup()) + if transport == "ethernet": + if self._eth_writer is not None: + self._eth_writer.close() + self._eth_reader = None + self._eth_writer = None + if self._ethernet_reader_task and not self._ethernet_reader_task.done(): + self._ethernet_reader_task.cancel() + self._ethernet_reader_task = None + self.async_set_updated_data(self._build_data()) # Schedule automatic reconnection with exponential backoff diff --git a/custom_components/ha_onecontrol/cover.py b/custom_components/ha_onecontrol/cover.py index bd2443c..f06cd94 100644 --- a/custom_components/ha_onecontrol/cover.py +++ b/custom_components/ha_onecontrol/cover.py @@ -25,12 +25,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OneControlCoordinator +from .entity_helpers import build_gateway_device_info from .protocol.events import CoverStatus _LOGGER = logging.getLogger(__name__) @@ -91,12 +91,9 @@ def __init__( self._key = f"{table_id:02x}:{device_id:02x}" mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_cover_{device_id:02x}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) self._unsub = coordinator.register_event_callback(self._on_event) diff --git a/custom_components/ha_onecontrol/diagnostics.py b/custom_components/ha_onecontrol/diagnostics.py index 0f40f40..cb06924 100644 --- a/custom_components/ha_onecontrol/diagnostics.py +++ b/custom_components/ha_onecontrol/diagnostics.py @@ -33,6 +33,9 @@ async def async_get_config_entry_diagnostics( "connected": coordinator.connected, "authenticated": coordinator.authenticated, "data_healthy": coordinator.data_healthy, + "connection_type": getattr(coordinator, "_connection_type", "ble"), + "eth_host": getattr(coordinator, "_eth_host", None), + "eth_port": getattr(coordinator, "_eth_port", None), "last_event_age_seconds": ( round(coordinator.last_event_age, 1) if coordinator.last_event_age is not None @@ -40,7 +43,7 @@ async def async_get_config_entry_diagnostics( ), "pairing_method": coordinator._pairing_method, "is_pin_gateway": coordinator.is_pin_gateway, - "pin_bond_attempted": coordinator._pin_bond_attempted, + "pin_bond_attempted": getattr(coordinator, "_pin_dbus_succeeded", False), "has_can_write": coordinator._has_can_write, "consecutive_reconnect_failures": coordinator._consecutive_failures, "pending_metadata_cmdids": len(coordinator._pending_metadata_cmdids), @@ -107,9 +110,9 @@ async def async_get_config_entry_diagnostics( hvacs[key] = { "heat_mode": zone.heat_mode, "fan_mode": zone.fan_mode, - "current_temp": zone.current_temp, - "low_trip": zone.low_trip, - "high_trip": zone.high_trip, + "current_temp": zone.indoor_temp_f, + "low_trip": zone.low_trip_f, + "high_trip": zone.high_trip_f, "name": coordinator.device_name(zone.table_id, zone.device_id), } diff --git a/custom_components/ha_onecontrol/entity_helpers.py b/custom_components/ha_onecontrol/entity_helpers.py new file mode 100644 index 0000000..ae74f8b --- /dev/null +++ b/custom_components/ha_onecontrol/entity_helpers.py @@ -0,0 +1,24 @@ +"""Shared entity helpers for transport-specific DeviceInfo fields.""" + +from __future__ import annotations + +from homeassistant.helpers.device_registry import DeviceInfo + +from .const import CONNECTION_TYPE_ETHERNET, DOMAIN + + +def build_gateway_device_info(address: str, connection_type: str) -> DeviceInfo: + """Build DeviceInfo with transport-appropriate connection metadata.""" + model = "Ethernet Gateway" if connection_type == CONNECTION_TYPE_ETHERNET else "BLE Gateway" + base = { + "identifiers": {(DOMAIN, address)}, + "name": f"OneControl {address}", + "manufacturer": "Lippert / LCI", + "model": model, + } + if connection_type != CONNECTION_TYPE_ETHERNET: + return DeviceInfo( + **base, + connections={("bluetooth", address)}, + ) + return DeviceInfo(**base) diff --git a/custom_components/ha_onecontrol/light.py b/custom_components/ha_onecontrol/light.py index c7a3393..2b60e85 100644 --- a/custom_components/ha_onecontrol/light.py +++ b/custom_components/ha_onecontrol/light.py @@ -23,12 +23,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OneControlCoordinator +from .entity_helpers import build_gateway_device_info from .protocol.commands import CommandBuilder from .protocol.events import DimmableLight, RgbLight @@ -119,12 +119,9 @@ def __init__( self._key = f"{table_id:02x}:{device_id:02x}" mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_light_{device_id:02x}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) self._unsub = coordinator.register_event_callback(self._on_event) @@ -266,12 +263,9 @@ def __init__( self._key = f"{table_id:02x}:{device_id:02x}" mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_rgb_{device_id:02x}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) self._unsub = coordinator.register_event_callback(self._on_event) diff --git a/custom_components/ha_onecontrol/protocol/ethernet_discovery.py b/custom_components/ha_onecontrol/protocol/ethernet_discovery.py new file mode 100644 index 0000000..fecb64a --- /dev/null +++ b/custom_components/ha_onecontrol/protocol/ethernet_discovery.py @@ -0,0 +1,137 @@ +"""UDP discovery helpers for IDS CAN-to-Ethernet bridges. + +The bridge advertises JSON payloads over UDP containing name/manufacturer/product +fields and a TCP port used for the data channel. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +import socket +from dataclasses import dataclass + +from ..const import DEFAULT_ETH_PORT, ETH_DISCOVERY_UDP_PORTS + +_LOGGER = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class BridgeDiscoveryResult: + """Discovered CAN-to-Ethernet bridge details.""" + + name: str + host: str + port: int + + +def _normalize_payload_keys(payload: dict[str, object]) -> dict[str, object]: + """Return a case-insensitive dict view using lowercase keys.""" + return {str(k).strip().lower(): v for k, v in payload.items()} + + +def _is_supported_bridge(payload: dict[str, object]) -> bool: + normalized = _normalize_payload_keys(payload) + mfg = str(normalized.get("mfg", "")).strip().upper() + product = str(normalized.get("product", "")).strip().upper() + return mfg == "IDS" or product == "CAN_TO_ETHERNET_GATEWAY" + + +def _parse_port(payload: dict[str, object]) -> int: + normalized = _normalize_payload_keys(payload) + raw = normalized.get("port", DEFAULT_ETH_PORT) + if isinstance(raw, bool): + return DEFAULT_ETH_PORT + if isinstance(raw, int): + port = raw + elif isinstance(raw, str): + try: + port = int(raw) + except ValueError: + return DEFAULT_ETH_PORT + else: + return DEFAULT_ETH_PORT + if 1 <= port <= 65535: + return port + return DEFAULT_ETH_PORT + + +async def discover_can_ethernet_bridges( + timeout_s: float, + listen_ports: tuple[int, ...] | None = None, +) -> list[BridgeDiscoveryResult]: + """Listen for UDP bridge advertisements for a limited time window.""" + ports = listen_ports or ETH_DISCOVERY_UDP_PORTS + socks: list[socket.socket] = [] + try: + for port in ports: + sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + sock.bind(("", port)) + sock.setblocking(False) + socks.append(sock) + + loop = asyncio.get_running_loop() + deadline = loop.time() + timeout_s + discovered: dict[tuple[str, int], BridgeDiscoveryResult] = {} + + while True: + remaining = deadline - loop.time() + if remaining <= 0: + break + + task_ports: dict[asyncio.Task[tuple[bytes, tuple[str, int]]], int] = { + loop.create_task(loop.sock_recvfrom(sock, 2048)): port + for sock, port in zip(socks, ports) + } + pending: set[asyncio.Task[tuple[bytes, tuple[str, int]]]] = set() + try: + done, pending = await asyncio.wait( + task_ports.keys(), + timeout=remaining, + return_when=asyncio.FIRST_COMPLETED, + ) + finally: + for task in pending: + task.cancel() + + if not done: + break + + completed_task = next(iter(done)) + listen_port = task_ports.get(completed_task, -1) + try: + data, remote = completed_task.result() + except OSError as exc: + _LOGGER.debug("Ethernet discovery receive error: %s", exc) + continue + + try: + payload = json.loads(data.decode("utf-8", errors="replace")) + except json.JSONDecodeError: + continue + if not isinstance(payload, dict): + continue + if not _is_supported_bridge(payload): + continue + + host = remote[0] + port = _parse_port(payload) + normalized = _normalize_payload_keys(payload) + name = str(normalized.get("name", "CAN Bridge")).strip() or "CAN Bridge" + _LOGGER.debug( + "Bridge discovery packet accepted from %s:%d via UDP listen port %d (name=%s, bridge_port=%d)", + remote[0], + remote[1], + listen_port, + name, + port, + ) + key = (host, port) + discovered[key] = BridgeDiscoveryResult(name=name, host=host, port=port) + + return sorted(discovered.values(), key=lambda item: (item.name.lower(), item.host)) + finally: + for sock in socks: + sock.close() diff --git a/custom_components/ha_onecontrol/sensor.py b/custom_components/ha_onecontrol/sensor.py index 3e3d0ab..fcce907 100644 --- a/custom_components/ha_onecontrol/sensor.py +++ b/custom_components/ha_onecontrol/sensor.py @@ -31,12 +31,12 @@ UnitOfTime, ) from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN from .coordinator import OneControlCoordinator +from .entity_helpers import build_gateway_device_info from .protocol.events import CoverStatus, GeneratorStatus, HourMeter, TankLevel _LOGGER = logging.getLogger(__name__) @@ -142,12 +142,9 @@ def __init__(self, coordinator: OneControlCoordinator, address: str) -> None: super().__init__(coordinator) self._address = address mac = address.replace(":", "").lower() - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) self._mac = mac diff --git a/custom_components/ha_onecontrol/strings.json b/custom_components/ha_onecontrol/strings.json index ddbf2ac..f498e71 100644 --- a/custom_components/ha_onecontrol/strings.json +++ b/custom_components/ha_onecontrol/strings.json @@ -2,12 +2,28 @@ "config": { "step": { "user": { + "title": "Select Connection Type", + "description": "Choose whether you want to connect through Bluetooth or an IDS CAN-to-Ethernet bridge.", + "data": { + "connection_type": "Connection type" + } + }, + "user_ble": { "title": "Select OneControl Gateway", "description": "Choose a discovered OneControl BLE gateway to set up.", "data": { "address": "Gateway" } }, + "ethernet": { + "title": "Configure CAN-to-Ethernet Bridge", + "description": "If the bridge was discovered over UDP, the fields are prefilled. Otherwise enter them manually. Typical defaults are {default_host}:{default_port}.", + "data": { + "eth_host": "Bridge host/IP", + "eth_port": "Bridge port", + "gateway_pin": "Gateway PIN (optional compatibility setting)" + } + }, "pairing_method": { "title": "Gateway Type — {name}", "description": "Does your OneControl gateway have a physical **Connect** button you press to start pairing, or does it use a fixed PIN printed on a sticker?\n\nCheck the label on your gateway if you're unsure.", @@ -32,7 +48,10 @@ } }, "error": { - "invalid_pin": "PIN must be exactly 6 digits" + "invalid_pin": "PIN must be exactly 6 digits", + "invalid_host": "Host is required", + "invalid_port": "Port must be between 1 and 65535", + "cannot_connect": "Failed to connect to the bridge endpoint" }, "abort": { "already_configured": "This gateway is already configured", diff --git a/custom_components/ha_onecontrol/switch.py b/custom_components/ha_onecontrol/switch.py index 0b4b4b3..a51b4b0 100644 --- a/custom_components/ha_onecontrol/switch.py +++ b/custom_components/ha_onecontrol/switch.py @@ -16,12 +16,12 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_ADDRESS from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.update_coordinator import CoordinatorEntity from .const import DOMAIN, SWITCH_STATE_GUARD_S from .coordinator import OneControlCoordinator +from .entity_helpers import build_gateway_device_info from .protocol.dtc_codes import get_name as dtc_get_name, is_fault as dtc_is_fault from .protocol.events import GeneratorStatus, RelayStatus @@ -93,12 +93,9 @@ def __init__( self._key = f"{table_id:02x}:{device_id:02x}" mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_switch_{device_id:02x}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) self._optimistic_is_on: bool | None = None self._optimistic_until: float = 0.0 @@ -219,12 +216,9 @@ def __init__( self._key = f"{table_id:02x}:{device_id:02x}" mac = address.replace(":", "").lower() self._attr_unique_id = f"{mac}_gen_switch_{device_id:02x}" - self._attr_device_info = DeviceInfo( - identifiers={(DOMAIN, address)}, - name=f"OneControl {address}", - manufacturer="Lippert / LCI", - model="BLE Gateway", - connections={("bluetooth", address)}, + self._attr_device_info = build_gateway_device_info( + address, + getattr(coordinator, "_connection_type", "ble"), ) self._unsub = coordinator.register_event_callback(self._on_event) diff --git a/custom_components/ha_onecontrol/translations/en.json b/custom_components/ha_onecontrol/translations/en.json index ddbf2ac..f498e71 100644 --- a/custom_components/ha_onecontrol/translations/en.json +++ b/custom_components/ha_onecontrol/translations/en.json @@ -2,12 +2,28 @@ "config": { "step": { "user": { + "title": "Select Connection Type", + "description": "Choose whether you want to connect through Bluetooth or an IDS CAN-to-Ethernet bridge.", + "data": { + "connection_type": "Connection type" + } + }, + "user_ble": { "title": "Select OneControl Gateway", "description": "Choose a discovered OneControl BLE gateway to set up.", "data": { "address": "Gateway" } }, + "ethernet": { + "title": "Configure CAN-to-Ethernet Bridge", + "description": "If the bridge was discovered over UDP, the fields are prefilled. Otherwise enter them manually. Typical defaults are {default_host}:{default_port}.", + "data": { + "eth_host": "Bridge host/IP", + "eth_port": "Bridge port", + "gateway_pin": "Gateway PIN (optional compatibility setting)" + } + }, "pairing_method": { "title": "Gateway Type — {name}", "description": "Does your OneControl gateway have a physical **Connect** button you press to start pairing, or does it use a fixed PIN printed on a sticker?\n\nCheck the label on your gateway if you're unsure.", @@ -32,7 +48,10 @@ } }, "error": { - "invalid_pin": "PIN must be exactly 6 digits" + "invalid_pin": "PIN must be exactly 6 digits", + "invalid_host": "Host is required", + "invalid_port": "Port must be between 1 and 65535", + "cannot_connect": "Failed to connect to the bridge endpoint" }, "abort": { "already_configured": "This gateway is already configured", diff --git a/docs/TECH_SPEC.md b/docs/TECH_SPEC.md index e99301a..d47d1ea 100644 --- a/docs/TECH_SPEC.md +++ b/docs/TECH_SPEC.md @@ -2,7 +2,7 @@ ## 1. Purpose and Scope -`ha_onecontrol` is a Home Assistant BLE integration for Lippert/LCI OneControl gateways. It provides native entity discovery and control for relays, lights, HVAC zones, covers, tanks, generator telemetry, and diagnostics. +`ha_onecontrol` is a Home Assistant integration for Lippert/LCI OneControl gateways. It provides native entity discovery and control for relays, lights, HVAC zones, covers, tanks, generator telemetry, and diagnostics. This document describes the current HA-native implementation and excludes mobile bridge specifics. @@ -11,24 +11,29 @@ This document describes the current HA-native implementation and excludes mobile - **Domain:** `ha_onecontrol` - **Primary runtime component:** `OneControlCoordinator` - **Platforms:** `binary_sensor`, `button`, `climate`, `light`, `sensor`, `switch` -- **Transport:** BLE GATT via Home Assistant Bluetooth stack +- **Transport:** BLE GATT via Home Assistant Bluetooth stack; experimental IDS CAN-to-Ethernet bridge path - **Coordinator mode:** push/event-driven (`update_interval=None`) ## 3. Configuration and Entry Setup - BLE discovery uses Lippert manufacturer advertisement data. -- Config flow supports two pairing models: +- Ethernet setup can optionally prefill host/port using UDP bridge advertisements (`Mfg=IDS` or `Product=CAN_TO_ETHERNET_GATEWAY`). +- Config flow supports two transport families: + - BLE transport + - Ethernet bridge transport (experimental) +- BLE pairing models include: - push-to-pair gateways - legacy PIN gateways - Core credentials: - `gateway_pin` (required) - `bluetooth_pin` (optional override) + - `eth_host`/`eth_port` (Ethernet mode) ## 4. Runtime Lifecycle 1. Entry setup creates coordinator and forwards platforms. 2. Initial connect runs as background task (non-blocking startup). -3. BLE session authenticates and enables notifications. +3. Transport session is established (BLE authenticate+notify, or Ethernet socket connect). 4. COBS/CRC decode pipeline parses protocol events. 5. Parsed state maps are updated and listener callbacks refresh entities. diff --git a/tests/conftest.py b/tests/conftest.py index 4312e53..ce859ad 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,10 +1,10 @@ """Shared test fixtures. -Stubs out homeassistant and related packages so the protocol-layer tests -can import ``custom_components.onecontrol.protocol.*`` without a full -Home Assistant installation. +Stubs optional dependencies only when they are unavailable in the active +environment so tests can run both with and without Home Assistant installed. """ +import importlib import sys from pathlib import Path from unittest.mock import MagicMock @@ -40,4 +40,7 @@ def __repr__(self) -> str: ] for _name in _STUBS: - sys.modules.setdefault(_name, _StubModule(name=_name)) + try: + importlib.import_module(_name) + except Exception: # noqa: BLE001 + sys.modules.setdefault(_name, _StubModule(name=_name)) diff --git a/tests/test_cobs.py b/tests/test_cobs.py index 40964ff..2c08fef 100644 --- a/tests/test_cobs.py +++ b/tests/test_cobs.py @@ -1,7 +1,7 @@ """Tests for COBS encoder/decoder with CRC8.""" -from custom_components.onecontrol.protocol.cobs import CobsByteDecoder, cobs_encode -from custom_components.onecontrol.protocol.crc8 import crc8 +from custom_components.ha_onecontrol.protocol.cobs import CobsByteDecoder, cobs_encode +from custom_components.ha_onecontrol.protocol.crc8 import crc8 class TestCobsByteDecoder: diff --git a/tests/test_config_flow_ethernet.py b/tests/test_config_flow_ethernet.py new file mode 100644 index 0000000..ee1406b --- /dev/null +++ b/tests/test_config_flow_ethernet.py @@ -0,0 +1,52 @@ +"""Tests for Ethernet-specific config flow behavior.""" + +from __future__ import annotations + +import asyncio + +from custom_components.ha_onecontrol.config_flow import OneControlConfigFlow + + +class _DummyWriter: + def __init__(self) -> None: + self.closed = False + self.wait_closed_called = False + + def close(self) -> None: + self.closed = True + + async def wait_closed(self) -> None: + self.wait_closed_called = True + + +def test_async_can_connect_ethernet_success(monkeypatch) -> None: + """Connectivity helper returns True when TCP connect succeeds.""" + writer = _DummyWriter() + + async def _fake_open_connection(host: str, port: int): + assert host == "192.168.1.1" + assert port == 6969 + return object(), writer + + monkeypatch.setattr(asyncio, "open_connection", _fake_open_connection) + + flow = OneControlConfigFlow() + ok = asyncio.run(flow._async_can_connect_ethernet("192.168.1.1", 6969)) + + assert ok is True + assert writer.closed is True + assert writer.wait_closed_called is True + + +def test_async_can_connect_ethernet_failure(monkeypatch) -> None: + """Connectivity helper returns False when TCP connect fails.""" + + async def _fake_open_connection(host: str, port: int): + raise OSError("connection refused") + + monkeypatch.setattr(asyncio, "open_connection", _fake_open_connection) + + flow = OneControlConfigFlow() + ok = asyncio.run(flow._async_can_connect_ethernet("192.168.1.1", 6969)) + + assert ok is False diff --git a/tests/test_crc8.py b/tests/test_crc8.py index 421a46e..3278601 100644 --- a/tests/test_crc8.py +++ b/tests/test_crc8.py @@ -1,6 +1,6 @@ """Tests for CRC8 (init=0x55, polynomial 0x07).""" -from custom_components.onecontrol.protocol.crc8 import crc8 +from custom_components.ha_onecontrol.protocol.crc8 import crc8 class TestCrc8: diff --git a/tests/test_ethernet_discovery.py b/tests/test_ethernet_discovery.py new file mode 100644 index 0000000..a194fde --- /dev/null +++ b/tests/test_ethernet_discovery.py @@ -0,0 +1,55 @@ +"""Tests for IDS CAN-to-Ethernet UDP discovery helpers.""" + +import asyncio + +from custom_components.ha_onecontrol.const import DEFAULT_ETH_PORT +from custom_components.ha_onecontrol.protocol.ethernet_discovery import ( + _is_supported_bridge, + _parse_port, + discover_can_ethernet_bridges, +) + + +def test_is_supported_bridge_by_mfg() -> None: + payload = {"Mfg": "IDS", "Product": "OTHER"} + assert _is_supported_bridge(payload) + + +def test_is_supported_bridge_by_product() -> None: + payload = {"Mfg": "unknown", "Product": "CAN_TO_ETHERNET_GATEWAY"} + assert _is_supported_bridge(payload) + + +def test_is_supported_bridge_lowercase_keys() -> None: + payload = {"mfg": "IDS", "product": "CAN_TO_ETHERNET_GATEWAY"} + assert _is_supported_bridge(payload) + + +def test_is_supported_bridge_negative() -> None: + payload = {"Mfg": "other", "Product": "other"} + assert not _is_supported_bridge(payload) + + +def test_parse_port_from_int() -> None: + assert _parse_port({"Port": 6969}) == 6969 + + +def test_parse_port_from_string() -> None: + assert _parse_port({"Port": "6970"}) == 6970 + + +def test_parse_port_lowercase_key() -> None: + assert _parse_port({"port": "6969"}) == 6969 + + +def test_parse_port_invalid_fallback() -> None: + assert _parse_port({"Port": "not-a-number"}) == DEFAULT_ETH_PORT + + +def test_parse_port_bool_fallback() -> None: + assert _parse_port({"Port": True}) == DEFAULT_ETH_PORT + + +def test_discover_timeout_empty() -> None: + discovered = asyncio.run(discover_can_ethernet_bridges(timeout_s=0.0)) + assert discovered == [] diff --git a/tests/test_tea.py b/tests/test_tea.py index 3d5c063..a83d103 100644 --- a/tests/test_tea.py +++ b/tests/test_tea.py @@ -1,13 +1,14 @@ """Tests for TEA encryption — verify against known values from the Android app.""" -from custom_components.onecontrol.protocol.tea import ( +from custom_components.ha_onecontrol.protocol.tea import ( MASK32, + STEP1_CIPHER, + STEP2_CIPHER, calculate_step1_key, calculate_step2_key, tea_decrypt, tea_encrypt, ) -from custom_components.onecontrol.const import STEP1_CIPHER, STEP2_CIPHER class TestTeaEncrypt: From e9446baa74dfb3bd9b1189670445ac63fa23e551 Mon Sep 17 00:00:00 2001 From: Greg Brunke Date: Sun, 15 Mar 2026 22:24:48 -0400 Subject: [PATCH 2/8] IDS-CAN Support via CAN-to-Ethernet gateway. Very much wip but light controls work and statuses sync between HA and OneControl. Automatically names devices using a loaded snapshot/manifest from the native app diagnostics dump. --- custom_components/ha_onecontrol/__init__.py | 7 + .../ha_onecontrol/config_flow.py | 97 +- custom_components/ha_onecontrol/const.py | 4 + .../ha_onecontrol/coordinator.py | 661 ++++++--- .../ha_onecontrol/diagnostics.py | 23 + .../ha_onecontrol/name_catalog.py | 157 ++ .../ha_onecontrol/protocol/events.py | 73 +- .../ha_onecontrol/protocol/ids_can_wire.py | 347 +++++ .../ha_onecontrol/runtime/__init__.py | 6 + .../ha_onecontrol/runtime/ids_can_runtime.py | 1304 +++++++++++++++++ .../ha_onecontrol/runtime/myrvlink_runtime.py | 576 ++++++++ custom_components/ha_onecontrol/strings.json | 17 + .../ha_onecontrol/translations/en.json | 17 + tests/conftest.py | 39 + tests/test_config_flow_ethernet.py | 15 +- tests/test_coordinator_ethernet_heartbeat.py | 356 +++++ tests/test_external_name_catalog.py | 116 ++ tests/test_ids_can_runtime.py | 626 ++++++++ tests/test_ids_can_wire.py | 228 +++ tests/test_myrvlink_runtime.py | 380 +++++ 20 files changed, 4881 insertions(+), 168 deletions(-) create mode 100644 custom_components/ha_onecontrol/name_catalog.py create mode 100644 custom_components/ha_onecontrol/protocol/ids_can_wire.py create mode 100644 custom_components/ha_onecontrol/runtime/__init__.py create mode 100644 custom_components/ha_onecontrol/runtime/ids_can_runtime.py create mode 100644 custom_components/ha_onecontrol/runtime/myrvlink_runtime.py create mode 100644 tests/test_coordinator_ethernet_heartbeat.py create mode 100644 tests/test_external_name_catalog.py create mode 100644 tests/test_ids_can_runtime.py create mode 100644 tests/test_ids_can_wire.py create mode 100644 tests/test_myrvlink_runtime.py diff --git a/custom_components/ha_onecontrol/__init__.py b/custom_components/ha_onecontrol/__init__.py index 8471edd..d8bbbb8 100644 --- a/custom_components/ha_onecontrol/__init__.py +++ b/custom_components/ha_onecontrol/__init__.py @@ -115,6 +115,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: coordinator = OneControlCoordinator(hass, entry) + entry.async_on_unload(entry.add_update_listener(_async_update_listener)) + # Store coordinator for platform setup hass.data[DOMAIN][entry.entry_id] = coordinator _LOGGER.info( @@ -149,3 +151,8 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: await coordinator.async_disconnect() return unload_ok + + +async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Reload config entry when options are updated.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/custom_components/ha_onecontrol/config_flow.py b/custom_components/ha_onecontrol/config_flow.py index 384dec3..e7de9ab 100644 --- a/custom_components/ha_onecontrol/config_flow.py +++ b/custom_components/ha_onecontrol/config_flow.py @@ -11,8 +11,9 @@ BluetoothServiceInfoBleak, async_discovered_service_info, ) -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult, OptionsFlow from homeassistant.const import CONF_ADDRESS +from homeassistant.helpers import selector from .const import ( CONNECTION_TYPE_BLE, @@ -22,6 +23,10 @@ CONF_ETH_HOST, CONF_ETH_PORT, CONF_GATEWAY_PIN, + CONF_NAMING_MANIFEST_JSON, + CONF_NAMING_MANIFEST_PATH, + CONF_NAMING_SNAPSHOT_JSON, + CONF_NAMING_SNAPSHOT_PATH, CONF_PAIRING_METHOD, DEFAULT_ETH_HOST, DEFAULT_ETH_PORT, @@ -32,6 +37,7 @@ LIPPERT_MANUFACTURER_ID, LIPPERT_MANUFACTURER_ID_ALT, ) +from .name_catalog import load_external_name_catalog from .protocol.advertisement import PairingMethod from .protocol.ethernet_discovery import discover_can_ethernet_bridges @@ -43,6 +49,11 @@ class OneControlConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 2 + @staticmethod + def async_get_options_flow(config_entry: ConfigEntry) -> OneControlOptionsFlow: + """Return the options flow handler.""" + return OneControlOptionsFlow(config_entry) + def __init__(self) -> None: """Initialise flow state.""" self._discovery_info: BluetoothServiceInfoBleak | None = None @@ -329,3 +340,87 @@ async def async_step_confirm_pin( ) -> ConfigFlowResult: """Handle the PIN-gateway confirmation step (delegates to confirm).""" return await self.async_step_confirm(user_input) + + +class OneControlOptionsFlow(OptionsFlow): + """Handle options for OneControl.""" + + def __init__(self, config_entry: ConfigEntry) -> None: + self._config_entry = config_entry + + async def async_step_init( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Manage options.""" + errors: dict[str, str] = {} + + if user_input is not None: + manifest_path = str(user_input.get(CONF_NAMING_MANIFEST_PATH, "")).strip() + snapshot_path = str(user_input.get(CONF_NAMING_SNAPSHOT_PATH, "")).strip() + manifest_json = str(user_input.get(CONF_NAMING_MANIFEST_JSON, "")).strip() + snapshot_json = str(user_input.get(CONF_NAMING_SNAPSHOT_JSON, "")).strip() + + if manifest_path or snapshot_path or manifest_json or snapshot_json: + try: + await self.hass.async_add_executor_job( + load_external_name_catalog, + manifest_path or None, + snapshot_path or None, + manifest_json or None, + snapshot_json or None, + ) + except Exception: # noqa: BLE001 + errors["base"] = "invalid_naming_file" + + if not errors: + options: dict[str, Any] = dict(self._config_entry.options) + if manifest_path: + options[CONF_NAMING_MANIFEST_PATH] = manifest_path + else: + options.pop(CONF_NAMING_MANIFEST_PATH, None) + + if snapshot_path: + options[CONF_NAMING_SNAPSHOT_PATH] = snapshot_path + else: + options.pop(CONF_NAMING_SNAPSHOT_PATH, None) + + if manifest_json: + options[CONF_NAMING_MANIFEST_JSON] = manifest_json + else: + options.pop(CONF_NAMING_MANIFEST_JSON, None) + + if snapshot_json: + options[CONF_NAMING_SNAPSHOT_JSON] = snapshot_json + else: + options.pop(CONF_NAMING_SNAPSHOT_JSON, None) + + return self.async_create_entry(title="", data=options) + + return self.async_show_form( + step_id="init", + data_schema=vol.Schema( + { + vol.Optional( + CONF_NAMING_MANIFEST_PATH, + default=self._config_entry.options.get(CONF_NAMING_MANIFEST_PATH, ""), + ): str, + vol.Optional( + CONF_NAMING_SNAPSHOT_PATH, + default=self._config_entry.options.get(CONF_NAMING_SNAPSHOT_PATH, ""), + ): str, + vol.Optional( + CONF_NAMING_MANIFEST_JSON, + default=self._config_entry.options.get(CONF_NAMING_MANIFEST_JSON, ""), + ): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + vol.Optional( + CONF_NAMING_SNAPSHOT_JSON, + default=self._config_entry.options.get(CONF_NAMING_SNAPSHOT_JSON, ""), + ): selector.TextSelector( + selector.TextSelectorConfig(multiline=True) + ), + } + ), + errors=errors, + ) diff --git a/custom_components/ha_onecontrol/const.py b/custom_components/ha_onecontrol/const.py index 4b0d40d..c09261d 100644 --- a/custom_components/ha_onecontrol/const.py +++ b/custom_components/ha_onecontrol/const.py @@ -161,6 +161,10 @@ CONF_CONNECTION_TYPE = "connection_type" CONF_ETH_HOST = "eth_host" CONF_ETH_PORT = "eth_port" +CONF_NAMING_MANIFEST_PATH = "naming_manifest_path" +CONF_NAMING_SNAPSHOT_PATH = "naming_snapshot_path" +CONF_NAMING_MANIFEST_JSON = "naming_manifest_json" +CONF_NAMING_SNAPSHOT_JSON = "naming_snapshot_json" DEFAULT_ETH_HOST = "192.168.1.1" DEFAULT_ETH_PORT = 6969 diff --git a/custom_components/ha_onecontrol/coordinator.py b/custom_components/ha_onecontrol/coordinator.py index e34c173..3a0af60 100644 --- a/custom_components/ha_onecontrol/coordinator.py +++ b/custom_components/ha_onecontrol/coordinator.py @@ -22,12 +22,13 @@ from dataclasses import dataclass, replace from typing import Any, Callable -from bleak import BleakClient, BleakGATTCharacteristic, BleakScanner +from bleak import BleakClient, BleakScanner +from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.exc import BleakError from bleak_retry_connector import establish_connection from homeassistant.components import bluetooth from homeassistant.config_entries import ConfigEntry -from homeassistant.const import CONF_ADDRESS +from homeassistant.const import CONF_ADDRESS, EVENT_HOMEASSISTANT_STOP from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.update_coordinator import DataUpdateCoordinator @@ -51,6 +52,10 @@ CONF_ETH_HOST, CONF_ETH_PORT, CONF_GATEWAY_PIN, + CONF_NAMING_MANIFEST_JSON, + CONF_NAMING_MANIFEST_PATH, + CONF_NAMING_SNAPSHOT_JSON, + CONF_NAMING_SNAPSHOT_PATH, CONF_PAIRING_METHOD, DATA_READ_CHAR_UUID, DATA_SERVICE_UUID, @@ -77,12 +82,14 @@ UNLOCK_STATUS_CHAR_UUID, UNLOCK_VERIFY_DELAY, ) +from .name_catalog import ExternalNameCatalog, load_external_name_catalog from .protocol.cobs import CobsByteDecoder, cobs_encode from .protocol.commands import CommandBuilder from .protocol.events import ( CoverStatus, DeviceLock, DeviceMetadata, + DeviceIdentity, DeviceOnline, DimmableLight, GatewayInformation, @@ -96,11 +103,10 @@ SystemLockout, TankLevel, parse_event, - parse_metadata_response, ) from .protocol.dtc_codes import get_name as dtc_get_name, is_fault as dtc_is_fault -from .protocol.function_names import get_friendly_name from .protocol.tea import calculate_step1_key, calculate_step2_key +from .runtime import IdsCanRuntime, MyRvLinkRuntime _LOGGER = logging.getLogger(__name__) @@ -142,6 +148,11 @@ 398, # Computer Fan 399, # Battery Fan }) +_MAX_PENDING_METADATA_CMDIDS = 128 +_MAX_UNKNOWN_COMMAND_IDS = 512 +_CMDID_STALE_TIMEOUT_S = 30.0 +_ETHERNET_HEARTBEAT_INTERVAL_S = 2.0 +_ETHERNET_TRANSPORT_KEEPALIVE_INTERVAL_S = 3.0 def _device_key(table_id: int, device_id: int) -> str: @@ -205,6 +216,16 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._eth_reader: asyncio.StreamReader | None = None self._eth_writer: asyncio.StreamWriter | None = None self._ethernet_reader_task: asyncio.Task | None = None + self._last_ethernet_tx_time: float = 0.0 + self._ethernet_transport_keepalives_sent: int = 0 + self._disconnect_count: int = 0 + self._last_disconnect_reason: str | None = None + self._stop_listener = None + self._naming_manifest_path: str = entry.options.get(CONF_NAMING_MANIFEST_PATH, "") + self._naming_snapshot_path: str = entry.options.get(CONF_NAMING_SNAPSHOT_PATH, "") + self._naming_manifest_json: str = entry.options.get(CONF_NAMING_MANIFEST_JSON, "") + self._naming_snapshot_json: str = entry.options.get(CONF_NAMING_SNAPSHOT_JSON, "") + self._external_name_catalog: ExternalNameCatalog = ExternalNameCatalog() self._decoder = CobsByteDecoder(use_crc=True) self._cmd = CommandBuilder() self._authenticated = False @@ -217,8 +238,10 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: self._metadata_retry_counts: dict[int, int] = {} # table_id → 0x0f retry count self._metadata_retry_pending: set[int] = set() # table_ids with a retry task in flight self._pending_metadata_cmdids: dict[int, int] = {} # cmdId → table_id + self._pending_metadata_sent_at: dict[int, float] = {} # cmdId → monotonic timestamp self._pending_metadata_entries: dict[int, dict[str, DeviceMetadata]] = {} self._pending_get_devices_cmdids: dict[int, int] = {} # cmdId → table_id + self._pending_get_devices_sent_at: dict[int, float] = {} # cmdId → monotonic timestamp self._get_devices_loaded_tables: set[int] = set() self._get_devices_reject_counts: dict[int, int] = {} # table_id → consecutive rejection count self._bootstrap_waiters: dict[tuple[str, int], asyncio.Future[str]] = {} @@ -230,6 +253,7 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: "metadata_success_multi_discarded_get_devices": 0, "metadata_success_multi_discarded_unknown": 0, "metadata_entries_staged": 0, + "metadata_parse_errors": 0, "metadata_commit_success": 0, "metadata_commit_crc_mismatch": 0, "metadata_commit_count_mismatch": 0, @@ -238,7 +262,23 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: "command_error_unknown": 0, "get_devices_rejected": 0, "get_devices_completed": 0, + "get_devices_completed_fallback": 0, + "get_devices_identity_rows": 0, + "get_devices_identity_rows_fallback": 0, + "get_devices_identity_parse_empty": 0, + "ids_command_candidates_seen": 0, + "ids_command_candidates_unmatched": 0, + "external_names_applied": 0, "pending_get_devices_peak": 0, + "frame_parse_errors": 0, + "pending_cmdid_pruned": 0, + "unknown_cmdids_pruned": 0, + } + self._frame_family_stats: dict[str, int] = { + "myrvlink_state": 0, + "myrvlink_command": 0, + "ids_can_like": 0, + "unknown": 0, } # Set once the initial GetDevices command has been sent after connection. # Metadata requests are delayed until this is True to mirror the v2.7.2 @@ -282,6 +322,9 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: # Metadata: friendly names per device key self.device_names: dict[str, str] = {} self._metadata_raw: dict[str, DeviceMetadata] = {} + self._device_identities: dict[str, DeviceIdentity] = {} + + self._load_external_name_catalog() # Last non-zero brightness per dimmable device (persists across off/on cycles). # Mirrors Android lastKnownDimmableBrightness — only updated when brightness > 0. @@ -307,6 +350,17 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: # Entity platform callbacks (typed) self._event_callbacks: list[Callable[[Any], None]] = [] + # Protocol runtimes: keep coordinator as HA-facing facade while + # transport/protocol orchestration is split by backend. + self._ids_runtime = IdsCanRuntime(self) + self._myrvlink_runtime = MyRvLinkRuntime(self, self._ids_runtime) + + # Cancel non-critical reconnect/heartbeat tasks as HA stops. + if hasattr(self.hass, "bus") and hasattr(self.hass.bus, "async_listen_once"): + self._stop_listener = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self._on_hass_stop + ) + @property def instance_tag(self) -> str: return self._instance_tag @@ -349,6 +403,53 @@ def device_name(self, table_id: int, device_id: int) -> str: key = _device_key(table_id, device_id) return self.device_names.get(key, f"Device {key.upper()}") + def _load_external_name_catalog(self) -> None: + """Load optional manifest/snapshot naming catalogs from config entry options.""" + manifest_path = self._naming_manifest_path.strip() or None + snapshot_path = self._naming_snapshot_path.strip() or None + manifest_json = self._naming_manifest_json.strip() or None + snapshot_json = self._naming_snapshot_json.strip() or None + if not manifest_path and not snapshot_path and not manifest_json and not snapshot_json: + self._external_name_catalog = ExternalNameCatalog() + return + + try: + self._external_name_catalog = load_external_name_catalog( + manifest_path, + snapshot_path, + manifest_json, + snapshot_json, + ) + _LOGGER.info( + "Loaded external naming catalog: entries=%d manifest_path=%s snapshot_path=%s manifest_json=%s snapshot_json=%s", + self._external_name_catalog.entries, + manifest_path or "", + snapshot_path or "", + "yes" if manifest_json else "no", + "yes" if snapshot_json else "no", + ) + except Exception as exc: # noqa: BLE001 + _LOGGER.warning("Failed loading external naming catalog: %s", exc) + self._external_name_catalog = ExternalNameCatalog() + + def _apply_external_name(self, key: str, identity: DeviceIdentity) -> None: + """Apply external name when identity matches manifest/snapshot catalog.""" + if self._external_name_catalog.entries == 0: + return + + resolved_name = self._external_name_catalog.lookup( + identity.device_type, + identity.device_instance, + identity.product_id, + identity.product_mac, + ) + if not resolved_name: + return + + if key not in self.device_names: + self.device_names[key] = resolved_name + self._cmd_correlation_stats["external_names_applied"] += 1 + def register_event_callback(self, cb: Callable[[Any], None]) -> Callable[[], None]: """Register a callback for parsed events. Returns unsubscribe callable.""" self._event_callbacks.append(cb) @@ -359,6 +460,16 @@ def _unsub() -> None: return _unsub + def _dispatch_event_update(self, event: Any) -> None: + """Protocol-neutral event fan-out + coordinator state publication.""" + for cb in self._event_callbacks: + try: + cb(event) + except Exception: # noqa: BLE001 + _LOGGER.exception("Error in event callback") + + self.async_set_updated_data(self._build_data()) + # ------------------------------------------------------------------ # Command sending (COBS-encoded writes to DATA_WRITE) # ------------------------------------------------------------------ @@ -369,9 +480,29 @@ async def async_send_command(self, raw_command: bytes) -> None: if not self._eth_writer or not self._connected: raise ConnectionError("Not connected to Ethernet bridge") encoded = cobs_encode(raw_command) - _LOGGER.debug("TX Ethernet command (%d bytes raw): %s", len(raw_command), raw_command.hex()) + cmd_id = int.from_bytes(raw_command[0:2], "little") if len(raw_command) >= 2 else -1 + cmd_type = raw_command[2] if len(raw_command) >= 3 else -1 + cmd_name = { + 0x01: "GetDevices", + 0x02: "GetDevicesMetadata", + 0x40: "ActionSwitch", + 0x41: "ActionHBridge", + 0x42: "ActionGenerator", + 0x43: "ActionDimmable", + 0x44: "ActionRgb", + 0x45: "ActionHvac", + }.get(cmd_type, "Unknown") + _LOGGER.warning( + "PACKET TX ETH cmd_id=0x%04X type=0x%02X(%s) raw_len=%d raw=%s", + cmd_id & 0xFFFF, + cmd_type & 0xFF, + cmd_name, + len(raw_command), + raw_command.hex(), + ) self._eth_writer.write(encoded) await self._eth_writer.drain() + self._last_ethernet_tx_time = time.monotonic() return if not self._client or not self._connected: @@ -384,6 +515,28 @@ async def async_switch( self, table_id: int, device_id: int, state: bool ) -> None: """Send a switch on/off command.""" + if self.is_ethernet_gateway: + used_ids_native = await self._ids_runtime.send_relay_toggle_command( + table_id, + device_id, + state, + ) + if used_ids_native: + _LOGGER.warning( + "PACKET TX IDS relay-toggle accepted table=0x%02X device=0x%02X state=%s (IDS-only mode)", + table_id & 0xFF, + device_id & 0xFF, + state, + ) + return + _LOGGER.warning( + "PACKET TX IDS relay-toggle skipped table=0x%02X device=0x%02X state=%s (IDS-only mode; legacy fallback disabled)", + table_id & 0xFF, + device_id & 0xFF, + state, + ) + return + cmd = self._cmd.build_action_switch(table_id, state, [device_id]) await self.async_send_command(cmd) @@ -391,6 +544,28 @@ async def async_set_dimmable( self, table_id: int, device_id: int, brightness: int ) -> None: """Send a dimmable light brightness command.""" + if self.is_ethernet_gateway: + used_ids_native = await self._ids_runtime.send_light_toggle_command( + table_id, + device_id, + brightness > 0, + ) + if used_ids_native: + _LOGGER.warning( + "PACKET TX IDS light-toggle accepted table=0x%02X device=0x%02X brightness=%d (IDS-only mode)", + table_id & 0xFF, + device_id & 0xFF, + brightness, + ) + return + _LOGGER.warning( + "PACKET TX IDS light-toggle skipped table=0x%02X device=0x%02X brightness=%d (IDS-only mode; legacy fallback disabled)", + table_id & 0xFF, + device_id & 0xFF, + brightness, + ) + return + cmd = self._cmd.build_action_dimmable(table_id, device_id, brightness) await self.async_send_command(cmd) @@ -814,6 +989,12 @@ async def async_clear_lockout(self) -> None: async def async_refresh_metadata(self) -> None: """Re-request device metadata for all known table IDs.""" + if not self._supports_metadata_requests: + _LOGGER.debug( + "Skipping metadata refresh for %s: metadata requests disabled on Ethernet/IDS-CAN", + self.address, + ) + return # Reset per-table state so all tables can be re-requested self._metadata_requested_tables.clear() self._metadata_loaded_tables.clear() @@ -821,8 +1002,10 @@ async def async_refresh_metadata(self) -> None: self._metadata_retry_counts.clear() self._metadata_retry_pending.clear() self._pending_metadata_cmdids.clear() + self._pending_metadata_sent_at.clear() self._pending_metadata_entries.clear() self._pending_get_devices_cmdids.clear() + self._pending_get_devices_sent_at.clear() # Collect all known table IDs: gateway, previously loaded metadata, # and all observed device status tables (covers tables we saw via status @@ -996,107 +1179,169 @@ async def _do_connect(self) -> None: async def _do_connect_ethernet(self) -> None: """Connect to an IDS CAN-to-Ethernet bridge with retries.""" - max_attempts = 3 - last_exc: Exception | None = None - for attempt in range(1, max_attempts + 1): - try: - await self._try_connect_ethernet(attempt) - return - except Exception as exc: - last_exc = exc - _LOGGER.warning( - "Ethernet connection attempt %d/%d failed: %s", - attempt, - max_attempts, - exc, - ) - await self._close_ethernet_transport() - self._connected = False - self._authenticated = False - if attempt < max_attempts: - delay = 2 * attempt - _LOGGER.info("Retrying Ethernet in %ds...", delay) - await asyncio.sleep(delay) - - assert last_exc is not None - raise last_exc + await self._ids_runtime.connect() async def _try_connect_ethernet(self, attempt: int) -> None: """Open TCP connection to Ethernet bridge and start reader task.""" - host = self._eth_host or self.address - port = self._eth_port - if not host or port <= 0: - raise ConnectionError("Ethernet host/port are not configured") - - _LOGGER.info( - "Connecting to OneControl bridge %s:%d (attempt %d)", - host, - port, - attempt, - ) - - reader, writer = await asyncio.wait_for( - asyncio.open_connection(host=host, port=port), - timeout=10.0, - ) - - self._eth_reader = reader - self._eth_writer = writer - self._connected = True - self._authenticated = True - self._decoder.reset() - self.async_set_updated_data(self._build_data()) - - self._ethernet_reader_task = self.hass.async_create_background_task( - self._ethernet_read_loop(), - "ha_onecontrol_ethernet_reader", - ) - self._start_heartbeat() - self.hass.async_create_task(self._send_initial_get_devices()) + await self._ids_runtime._try_connect(attempt) async def _ethernet_read_loop(self) -> None: """Read Ethernet bytes and decode COBS frames into protocol events.""" - if self._eth_reader is None: - return - - try: - while self._connected and self._eth_reader is not None: - chunk = await self._eth_reader.read(512) - if not chunk: - raise ConnectionError("Ethernet bridge closed connection") - for byte_val in chunk: - frame = self._decoder.decode_byte(byte_val) - if frame is not None: - self._process_frame(frame) - except asyncio.CancelledError: - return - except Exception as exc: # noqa: BLE001 - _LOGGER.warning("Ethernet read loop ended: %s", exc) - finally: - if self._connected: - self._handle_transport_disconnect("ethernet") + await self._ids_runtime.read_loop() async def _close_ethernet_transport(self) -> None: """Close active Ethernet socket and reader task.""" - if self._ethernet_reader_task and not self._ethernet_reader_task.done(): - self._ethernet_reader_task.cancel() - self._ethernet_reader_task = None + await self._ids_runtime.close_transport() - if self._eth_writer is not None: - self._eth_writer.close() - try: - await self._eth_writer.wait_closed() - except Exception: # noqa: BLE001 - pass + async def _send_ethernet_transport_keepalive(self) -> None: + """Send a transport-level frame delimiter to prevent idle TCP closes.""" + runtime = getattr(self, "_ids_runtime", None) + if runtime is not None: + await runtime.send_transport_keepalive(_ETHERNET_TRANSPORT_KEEPALIVE_INTERVAL_S) + return - self._eth_reader = None - self._eth_writer = None + # Compatibility fallback for tests that invoke this method on a lightweight + # stand-in object via OneControlCoordinator._send_ethernet_transport_keepalive(...). + if not self.is_ethernet_gateway or not self._connected or self._eth_writer is None: + return + if (time.monotonic() - self._last_ethernet_tx_time) < _ETHERNET_TRANSPORT_KEEPALIVE_INTERVAL_S: + return + self._eth_writer.write(b"\x00") + await self._eth_writer.drain() + self._last_ethernet_tx_time = time.monotonic() + self._ethernet_transport_keepalives_sent += 1 + _LOGGER.debug("TX Ethernet transport keepalive delimiter") @property def is_pin_gateway(self) -> bool: """True if this gateway uses PIN-based (legacy) BLE pairing.""" return self._pairing_method == "pin" + @property + def _supports_metadata_requests(self) -> bool: + """True when metadata command path is supported for current transport.""" + # Legacy IDS-CAN over Ethernet bridges have not shown reliable support + # for GetDevicesMetadata in field testing. + return not self.is_ethernet_gateway + + def _classify_frame_family(self, frame: bytes) -> str: + """Best-effort classifier for mixed protocol captures. + + This is heuristic telemetry for diagnostics, not a strict decoder. + """ + if not frame: + return "unknown" + + event_type = frame[0] & 0xFF + + # Known MyRVLink state/event types currently parsed by this integration. + if event_type in { + 0x01, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, + 0x0A, 0x0B, 0x0C, 0x0D, 0x0E, 0x0F, 0x1A, 0x1B, 0x20, + }: + return "myrvlink_state" + + # MyRVLink command response envelope. + if event_type == 0x02 and len(frame) >= 4 and (frame[3] & 0xFF) in {0x01, 0x02, 0x81, 0x82}: + return "myrvlink_command" + + # IDS-CAN message-type-like values from decompiled references. + if event_type in {0x00, 0x80, 0x81, 0x82, 0x83, 0x84, 0x9B, 0x9D, 0x9F}: + return "ids_can_like" + + return "unknown" + + def _record_pending_get_devices_cmd(self, cmd_id: int, table_id: int) -> None: + """Track an in-flight GetDevices command id with bounded retention.""" + runtime = getattr(self, "_myrvlink_runtime", None) + if runtime is not None: + runtime.record_pending_get_devices_cmd( + cmd_id, + table_id, + max_pending=_MAX_PENDING_GET_DEVICES_CMDIDS, + ) + return + + now = time.monotonic() + self._pending_get_devices_cmdids[cmd_id] = table_id + self._pending_get_devices_sent_at[cmd_id] = now + while len(self._pending_get_devices_cmdids) > _MAX_PENDING_GET_DEVICES_CMDIDS: + oldest_cmd_id = next(iter(self._pending_get_devices_cmdids)) + self._pending_get_devices_cmdids.pop(oldest_cmd_id, None) + self._pending_get_devices_sent_at.pop(oldest_cmd_id, None) + self._cmd_correlation_stats["pending_cmdid_pruned"] += 1 + self._cmd_correlation_stats["pending_get_devices_peak"] = max( + self._cmd_correlation_stats["pending_get_devices_peak"], + len(self._pending_get_devices_cmdids), + ) + + def _record_pending_metadata_cmd(self, cmd_id: int, table_id: int) -> None: + """Track an in-flight metadata command id with bounded retention.""" + runtime = getattr(self, "_myrvlink_runtime", None) + if runtime is not None: + runtime.record_pending_metadata_cmd( + cmd_id, + table_id, + max_pending=_MAX_PENDING_METADATA_CMDIDS, + ) + return + + now = time.monotonic() + self._pending_metadata_cmdids[cmd_id] = table_id + self._pending_metadata_sent_at[cmd_id] = now + while len(self._pending_metadata_cmdids) > _MAX_PENDING_METADATA_CMDIDS: + oldest_cmd_id = next(iter(self._pending_metadata_cmdids)) + self._pending_metadata_cmdids.pop(oldest_cmd_id, None) + self._pending_metadata_sent_at.pop(oldest_cmd_id, None) + self._pending_metadata_entries.pop(oldest_cmd_id, None) + self._cmd_correlation_stats["pending_cmdid_pruned"] += 1 + + def _prune_pending_command_state(self) -> None: + """Drop stale pending cmdIds so late/missing responses do not accumulate forever.""" + runtime = getattr(self, "_myrvlink_runtime", None) + if runtime is not None: + runtime.prune_pending_command_state(_CMDID_STALE_TIMEOUT_S) + return + + cutoff = time.monotonic() - _CMDID_STALE_TIMEOUT_S + + stale_get_devices = [ + cmd_id + for cmd_id, sent_at in self._pending_get_devices_sent_at.items() + if sent_at < cutoff + ] + for cmd_id in stale_get_devices: + self._pending_get_devices_sent_at.pop(cmd_id, None) + self._pending_get_devices_cmdids.pop(cmd_id, None) + self._cmd_correlation_stats["pending_cmdid_pruned"] += 1 + + stale_metadata = [ + cmd_id + for cmd_id, sent_at in self._pending_metadata_sent_at.items() + if sent_at < cutoff + ] + for cmd_id in stale_metadata: + self._pending_metadata_sent_at.pop(cmd_id, None) + self._pending_metadata_cmdids.pop(cmd_id, None) + self._pending_metadata_entries.pop(cmd_id, None) + self._cmd_correlation_stats["pending_cmdid_pruned"] += 1 + + def _bump_unknown_cmd_count(self, cmd_id: int) -> int: + """Increment unknown cmdId counter and bound map size.""" + runtime = getattr(self, "_myrvlink_runtime", None) + if runtime is not None: + return runtime.bump_unknown_cmd_count( + cmd_id, + max_unknown=_MAX_UNKNOWN_COMMAND_IDS, + ) + + count = self._unknown_command_counts.get(cmd_id, 0) + 1 + self._unknown_command_counts[cmd_id] = count + while len(self._unknown_command_counts) > _MAX_UNKNOWN_COMMAND_IDS: + self._unknown_command_counts.pop(next(iter(self._unknown_command_counts))) + self._cmd_correlation_stats["unknown_cmdids_pruned"] += 1 + return count + async def _try_connect(self, attempt: int) -> None: """Single connection attempt — connect, pair, authenticate.""" _LOGGER.info( @@ -1194,11 +1439,6 @@ async def _try_connect(self, attempt: int) -> None: self._push_button_dbus_ok = False if self.is_pin_gateway: - # Register the PIN agent NOW so it is waiting when BlueZ asks - # for the PIN during client.pair() after GATT connect. - # We do NOT call Device1.Pair() here — that is done post-connect, - # matching the Android flow: connectGatt() → createBond() in - # onConnectionStateChange. ctx = await prepare_pin_agent(self.address, self._bluetooth_pin) self._pin_agent_ctx = ctx if ctx and ctx.already_bonded: @@ -1540,18 +1780,7 @@ async def _authenticate_step2(self, seed: bytes) -> None: async def _send_metadata_request(self, table_id: int) -> None: """Send GetDevicesMetadata for a single table ID.""" - cmd = self._cmd.build_get_devices_metadata(table_id) - cmd_id = int.from_bytes(cmd[0:2], "little") - self._pending_metadata_cmdids[cmd_id] = table_id - self._pending_metadata_entries.pop(cmd_id, None) - self._metadata_requested_tables.add(table_id) - try: - await self.async_send_command(cmd) - _LOGGER.info("Sent GetDevicesMetadata for table %d (cmdId=%d)", table_id, cmd_id) - except Exception as exc: - self._pending_metadata_cmdids.pop(cmd_id, None) - self._pending_metadata_entries.pop(cmd_id, None) - _LOGGER.warning("Failed to send metadata request: %s", exc) + await self._myrvlink_runtime.send_metadata_request(table_id) async def _retry_metadata_after_rejection(self, table_id: int) -> None: """Retry GetDevicesMetadata 10s after a rejection. @@ -1609,18 +1838,56 @@ async def _do_send_initial_get_devices(self) -> None: return if not self._connected or not self._authenticated: return - if self.gateway_info is None: + table_id = self._select_get_devices_table_id() + if table_id is None: return try: - cmd_id = await self._send_get_devices_request(self.gateway_info.table_id) + cmd_id = await self._send_get_devices_request(table_id) self._initial_get_devices_sent = True _LOGGER.debug( "Initial GetDevices sent for table %d (cmdId=%d)", - self.gateway_info.table_id, cmd_id, + table_id, cmd_id, ) except Exception as exc: # noqa: BLE001 _LOGGER.warning("Initial GetDevices failed: %s", exc) + def _select_get_devices_table_id(self) -> int | None: + """Pick a table id for GetDevices when gateway info may be unavailable. + + GatewayInfo table id is preferred. On Ethernet bridges that never emit + GatewayInfo, fall back to the most frequently observed non-zero table id + from live device state keys (TT:DD). + """ + if self.gateway_info is not None and self.gateway_info.table_id != 0: + return self.gateway_info.table_id + + table_counts: dict[int, int] = {} + for status_dict in ( + self.relays, + self.dimmable_lights, + self.rgb_lights, + self.covers, + self.hvac_zones, + self.tanks, + self.device_online, + self.device_locks, + self.generators, + self.hour_meters, + ): + for key in status_dict: + try: + table_id = int(key.split(":", 1)[0], 16) + except (ValueError, IndexError): + continue + if table_id == 0: + continue + table_counts[table_id] = table_counts.get(table_id, 0) + 1 + + if not table_counts: + return None + + return max(table_counts, key=lambda tid: table_counts[tid]) + async def _request_metadata_after_delay(self, table_id: int) -> None: """Wait 1500ms then request metadata. @@ -1687,10 +1954,15 @@ def _start_heartbeat(self) -> None: if self._heartbeat_task and not self._heartbeat_task.done(): return self._stop_heartbeat() + interval = ( + _ETHERNET_HEARTBEAT_INTERVAL_S + if self.is_ethernet_gateway + else HEARTBEAT_INTERVAL + ) self._heartbeat_task = self.hass.async_create_background_task( self._heartbeat_loop(), name="ha_onecontrol_heartbeat" ) - _LOGGER.info("Heartbeat started (every %.0fs)", HEARTBEAT_INTERVAL) + _LOGGER.info("Heartbeat started (every %.1fs)", interval) def _stop_heartbeat(self) -> None: """Cancel the heartbeat loop.""" @@ -1699,6 +1971,20 @@ def _stop_heartbeat(self) -> None: self._heartbeat_task = None _LOGGER.debug("Heartbeat stopped") + async def _force_ethernet_reconnect(self, reason: str) -> None: + """Close Ethernet transport and trigger reconnect handling once.""" + runtime = getattr(self, "_ids_runtime", None) + if runtime is not None: + await runtime.force_reconnect(reason) + return + + if not self.is_ethernet_gateway or not self._connected: + return + _LOGGER.debug("Forcing Ethernet reconnect (%s)", reason) + await self._close_ethernet_transport() + if self._connected: + self._handle_transport_disconnect("ethernet", reason) + async def _heartbeat_loop(self) -> None: """Send GetDevices periodically to keep BLE connection alive. @@ -1707,22 +1993,55 @@ async def _heartbeat_loop(self) -> None: Reference: Android HEARTBEAT_INTERVAL_MS = 5000L """ + interval = ( + _ETHERNET_HEARTBEAT_INTERVAL_S + if self.is_ethernet_gateway + else HEARTBEAT_INTERVAL + ) try: while self._connected and self._authenticated: - await asyncio.sleep(HEARTBEAT_INTERVAL) - if not self._connected or not self.gateway_info: + await asyncio.sleep(interval) + if not self._connected: break + if self.is_ethernet_gateway and not self.gateway_info: + runtime = getattr(self, "_ids_runtime", None) + try: + if runtime is not None: + await runtime.heartbeat_pre_gateway_cycle( + _ETHERNET_TRANSPORT_KEEPALIVE_INTERVAL_S + ) + else: + await self._send_ethernet_transport_keepalive() + if getattr(self, "_pending_get_devices_cmdids", {}): + continue + table_id = self._select_get_devices_table_id() + if table_id is not None: + cmd = self._cmd.build_get_devices(table_id) + cmd_id = int.from_bytes(cmd[0:2], "little") + self._record_pending_get_devices_cmd(cmd_id, table_id) + await self.async_send_command(cmd) + except Exception: # noqa: BLE001 + _LOGGER.exception("Ethernet transport keepalive error") + await self._force_ethernet_reconnect("transport keepalive failed") + break + continue + + if not self.gateway_info: + continue + # Stale connection detection if ( self._last_event_time > 0 and (time.monotonic() - self._last_event_time) > STALE_CONNECTION_TIMEOUT ): _LOGGER.warning( - "No events for %.0fs — connection stale, forcing reconnect", + "No events for %.0fs - connection stale, forcing reconnect", STALE_CONNECTION_TIMEOUT, ) - if self._client: + if self.is_ethernet_gateway: + await self._force_ethernet_reconnect("stale heartbeat") + elif self._client: try: await self._client.disconnect() except Exception: @@ -1730,14 +2049,21 @@ async def _heartbeat_loop(self) -> None: break try: - if self._is_startup_bootstrap_active(self.gateway_info.table_id): + table_id = self._select_get_devices_table_id() + if table_id is None: + continue + if self._is_startup_bootstrap_active(table_id): continue - await self._send_get_devices_request(self.gateway_info.table_id) + await self._send_get_devices_request(table_id) except BleakError as exc: _LOGGER.warning("Heartbeat BLE write failed: %s", exc) + if self.is_ethernet_gateway: + await self._force_ethernet_reconnect("heartbeat write failed") break except Exception: # noqa: BLE001 _LOGGER.exception("Heartbeat error") + if self.is_ethernet_gateway: + await self._force_ethernet_reconnect("heartbeat exception") break except asyncio.CancelledError: pass @@ -1763,9 +2089,15 @@ def _process_frame(self, frame: bytes) -> None: # Track data freshness self._last_event_time = time.monotonic() + self._prune_pending_command_state() event_type = frame[0] + family = self._classify_frame_family(frame) + self._frame_family_stats[family] = self._frame_family_stats.get(family, 0) + 1 + runtime = getattr(self, "_ids_runtime", None) + if self.is_ethernet_gateway and runtime is not None and runtime.handle_frame(frame): + return # Detect metadata error/completion responses before full parse. # responseType byte 3: 0x01=SuccessMulti, 0x81=SuccessComplete, 0x02/0x82=Fail # Reference: METADATA_RETRIEVAL.md § Response Format; MyRvLinkCommandGetDevicesMetadata.cs @@ -1963,7 +2295,28 @@ def _process_frame(self, frame: bytes) -> None: self._cmd_correlation_stats["metadata_entries_staged"] += added return - event = parse_event(frame) + myrv_runtime = getattr(self, "_myrvlink_runtime", None) + if ( + not self.is_ethernet_gateway + and myrv_runtime is not None + and myrv_runtime.handle_command_frame(frame) + ): + return + + try: + event = parse_event(frame) + except Exception as exc: # noqa: BLE001 + self._cmd_correlation_stats["frame_parse_errors"] += 1 + count = self._cmd_correlation_stats["frame_parse_errors"] + if count <= 3 or count in (10, 50, 100) or count % 500 == 0: + _LOGGER.warning( + "Frame parse failed (event=0x%02X count=%d): %s frame=%s", + event_type, + count, + exc, + frame.hex(), + ) + return _LOGGER.debug( "Event 0x%02X (%d bytes): %s", event_type, @@ -2032,7 +2385,6 @@ def _process_frame(self, frame: bytes) -> None: elif isinstance(event, RelayStatus): key = _device_key(event.table_id, event.device_id) self.relays[key] = event - self._ensure_metadata_for_table(event.table_id) # Fire HA event for DTC faults (only on change, gas appliances only) # Android behaviour: only publish DTC for devices with "gas" in name prev_dtc = self._last_dtc_codes.get(key, 0) @@ -2068,7 +2420,6 @@ def _process_frame(self, frame: bytes) -> None: self.dimmable_lights[key] = event if event.brightness > 0: self._last_known_dimmable_brightness[key] = event.brightness - self._ensure_metadata_for_table(event.table_id) elif isinstance(event, RgbLight): key = _device_key(event.table_id, event.device_id) @@ -2081,7 +2432,6 @@ def _process_frame(self, frame: bytes) -> None: elif isinstance(event, CoverStatus): key = _device_key(event.table_id, event.device_id) self.covers[key] = event - self._ensure_metadata_for_table(event.table_id) elif isinstance(event, list): # Multi-item events: HvacZone list, TankLevel list, DeviceMetadata list @@ -2091,14 +2441,10 @@ def _process_frame(self, frame: bytes) -> None: elif isinstance(item, TankLevel): key = _device_key(item.table_id, item.device_id) self.tanks[key] = item - self._ensure_metadata_for_table(item.table_id) - elif isinstance(item, DeviceMetadata): - self._process_metadata(item) elif isinstance(event, TankLevel): key = _device_key(event.table_id, event.device_id) self.tanks[key] = event - self._ensure_metadata_for_table(event.table_id) elif isinstance(event, HvacZone): self._handle_hvac_zone(event) @@ -2106,7 +2452,6 @@ def _process_frame(self, frame: bytes) -> None: elif isinstance(event, DeviceOnline): key = _device_key(event.table_id, event.device_id) self.device_online[key] = event - self._ensure_metadata_for_table(event.table_id) elif isinstance(event, SystemLockout): self.system_lockout_level = event.lockout_level @@ -2118,50 +2463,24 @@ def _process_frame(self, frame: bytes) -> None: elif isinstance(event, DeviceLock): key = _device_key(event.table_id, event.device_id) self.device_locks[key] = event - self._ensure_metadata_for_table(event.table_id) elif isinstance(event, GeneratorStatus): key = _device_key(event.table_id, event.device_id) self.generators[key] = event - self._ensure_metadata_for_table(event.table_id) elif isinstance(event, HourMeter): key = _device_key(event.table_id, event.device_id) self.hour_meters[key] = event - self._ensure_metadata_for_table(event.table_id) elif isinstance(event, RealTimeClock): self.rtc = event - # ── Notify entity callbacks ─────────────────────────────────── - for cb in self._event_callbacks: - try: - cb(event) - except Exception: # noqa: BLE001 - _LOGGER.exception("Error in event callback") - - # ── Trigger HA state update ─────────────────────────────────── - self.async_set_updated_data(self._build_data()) + self._myrvlink_runtime.handle_metadata_for_event(event) + self._dispatch_event_update(event) def _process_metadata(self, meta: DeviceMetadata) -> None: """Store metadata and resolve friendly name.""" - key = _device_key(meta.table_id, meta.device_id) - self._metadata_raw[key] = meta - name = get_friendly_name(meta.function_name, meta.function_instance) - self.device_names[key] = name - self._metadata_loaded_tables.add(meta.table_id) - # Record the CRC for the gateway's primary table so reconnects can skip - # re-requesting metadata when the CRC hasn't changed. - if ( - self.gateway_info is not None - and meta.table_id == self.gateway_info.table_id - and self.gateway_info.device_metadata_table_crc != 0 - ): - self._last_metadata_crc = self.gateway_info.device_metadata_table_crc - _LOGGER.info( - "Metadata: %s → func=%d inst=%d → %s", - key.upper(), meta.function_name, meta.function_instance, name, - ) + self._myrvlink_runtime.process_metadata(meta) async def _async_seed_silent_devices(self, table_id: int) -> None: """Seed switch entities for relay-type devices that never emit BLE events. @@ -2230,10 +2549,23 @@ def _build_data(self) -> dict[str, Any]: @callback def _on_disconnect(self, client: BleakClient) -> None: """Handle unexpected BLE disconnect — schedule reconnect with backoff.""" - self._handle_transport_disconnect("ble") + self._handle_transport_disconnect("ble", "ble disconnected callback") + + @callback + def _on_hass_stop(self, event: Any) -> None: + """Stop non-critical background tasks during Home Assistant shutdown.""" + self._cancel_reconnect() + self._stop_heartbeat() + if self._ethernet_reader_task and not self._ethernet_reader_task.done(): + self._ethernet_reader_task.cancel() + self._ethernet_reader_task = None + self._ids_runtime.cleanup_on_disconnect() - def _handle_transport_disconnect(self, transport: str) -> None: + def _handle_transport_disconnect(self, transport: str, reason: str | None = None) -> None: """Handle unexpected transport disconnect and schedule reconnect.""" + reason_text = reason or "unknown" + self._disconnect_count += 1 + self._last_disconnect_reason = f"{transport}:{reason_text}" _LOGGER.warning("OneControl %s disconnected (instance=%s)", self.address, self._instance_tag) self._stop_heartbeat() self._connected = False @@ -2263,17 +2595,13 @@ def _handle_transport_disconnect(self, transport: str) -> None: self.hass.async_create_task(ctx.cleanup()) if transport == "ethernet": - if self._eth_writer is not None: - self._eth_writer.close() - self._eth_reader = None - self._eth_writer = None - if self._ethernet_reader_task and not self._ethernet_reader_task.done(): - self._ethernet_reader_task.cancel() - self._ethernet_reader_task = None + self._ids_runtime.cleanup_on_disconnect() self.async_set_updated_data(self._build_data()) # Schedule automatic reconnection with exponential backoff + if getattr(self.hass, "is_stopping", False): + return self._schedule_reconnect() def _schedule_reconnect(self) -> None: @@ -2284,6 +2612,9 @@ def _schedule_reconnect(self) -> None: prevents multiple concurrent reconnect coroutines from racing each other into BlueZ's "InProgress" error state. """ + if getattr(self.hass, "is_stopping", False): + _LOGGER.debug("Skipping reconnect scheduling because Home Assistant is stopping") + return if self._reconnect_task and not self._reconnect_task.done(): self._reconnect_task.cancel() @@ -2312,6 +2643,8 @@ async def _reconnect_with_delay(self, delay: float, generation: int) -> None: generation, self._reconnect_generation, self._instance_tag, ) return + if getattr(self.hass, "is_stopping", False): + return if self._connected: return # Already reconnected by another path @@ -2355,7 +2688,7 @@ def _cancel_reconnect(self) -> None: async def _async_update_data(self) -> dict[str, Any]: """Called by the coordinator on its polling interval (if set).""" - if not self._connected: + if not self._connected and not getattr(self.hass, "is_stopping", False): try: await self.async_connect() except BleakError as exc: diff --git a/custom_components/ha_onecontrol/diagnostics.py b/custom_components/ha_onecontrol/diagnostics.py index cb06924..e7ee145 100644 --- a/custom_components/ha_onecontrol/diagnostics.py +++ b/custom_components/ha_onecontrol/diagnostics.py @@ -47,7 +47,30 @@ async def async_get_config_entry_diagnostics( "has_can_write": coordinator._has_can_write, "consecutive_reconnect_failures": coordinator._consecutive_failures, "pending_metadata_cmdids": len(coordinator._pending_metadata_cmdids), + "pending_metadata_sent_at": len(coordinator._pending_metadata_sent_at), "pending_get_devices_cmdids": len(coordinator._pending_get_devices_cmdids), + "pending_get_devices_sent_at": len(coordinator._pending_get_devices_sent_at), + "unknown_command_ids": len(coordinator._unknown_command_counts), + "supports_metadata_requests": coordinator._supports_metadata_requests, + "naming_manifest_path": getattr(coordinator, "_naming_manifest_path", ""), + "naming_snapshot_path": getattr(coordinator, "_naming_snapshot_path", ""), + "naming_manifest_json_configured": bool( + getattr(coordinator, "_naming_manifest_json", "").strip() + ), + "naming_snapshot_json_configured": bool( + getattr(coordinator, "_naming_snapshot_json", "").strip() + ), + "external_name_catalog_entries": getattr( + getattr(coordinator, "_external_name_catalog", None), + "entries", + 0, + ), + "identity_rows_cached": len(getattr(coordinator, "_device_identities", {})), + "ethernet_transport_keepalives_sent": getattr( + coordinator, "_ethernet_transport_keepalives_sent", 0 + ), + "disconnect_count": getattr(coordinator, "_disconnect_count", 0), + "last_disconnect_reason": getattr(coordinator, "_last_disconnect_reason", None), "cmd_correlation": dict(coordinator._cmd_correlation_stats), } diff --git a/custom_components/ha_onecontrol/name_catalog.py b/custom_components/ha_onecontrol/name_catalog.py new file mode 100644 index 0000000..2da46a3 --- /dev/null +++ b/custom_components/ha_onecontrol/name_catalog.py @@ -0,0 +1,157 @@ +"""External naming catalog built from OneControl snapshot/manifest JSON files.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +import json +from pathlib import Path +from typing import Any + + +IdentityKey = tuple[int, int, int, str] + + +@dataclass +class ExternalNameCatalog: + """Lookup table for names keyed by stable device identity fields.""" + + names_by_identity: dict[IdentityKey, str] = field(default_factory=dict) + + @property + def entries(self) -> int: + return len(self.names_by_identity) + + def lookup( + self, + device_type: int, + device_instance: int, + product_id: int, + product_mac: str, + ) -> str | None: + key = (device_type, device_instance, product_id, _normalize_mac(product_mac)) + return self.names_by_identity.get(key) + + +def _normalize_mac(value: str | None) -> str: + if not value: + return "" + return "".join(ch for ch in value.upper() if ch in "0123456789ABCDEF") + + +def _extract_snapshot_name(description: str) -> str: + """Prefer the leading friendly segment before hardware details in parentheses.""" + text = (description or "").strip() + if not text: + return "" + idx = text.find(" (") + return text[:idx].strip() if idx > 0 else text + + +def _extract_manifest_name(device: dict[str, Any]) -> str: + function_name = str(device.get("FunctionName") or "").strip() + if function_name and function_name.upper() != "UNKNOWN": + return function_name + + base = str(device.get("Name") or "").strip() or "Device" + instance = device.get("Instance") + if isinstance(instance, int): + return f"{base} {instance}" + return base + + +def _coerce_int(value: Any) -> int: + if isinstance(value, int): + return value + if isinstance(value, str) and value.strip(): + return int(value) + raise ValueError("Expected integer value") + + +def _add_snapshot_entries(catalog: ExternalNameCatalog, data: dict[str, Any]) -> None: + devices = data.get("DeviceSnapshot", {}).get("Devices", []) + if not isinstance(devices, list): + raise ValueError("Snapshot file has invalid DeviceSnapshot.Devices structure") + + for item in devices: + if not isinstance(item, dict): + continue + logical = item.get("LogicalId") + if not isinstance(logical, dict): + continue + + name = _extract_snapshot_name(str(item.get("Description") or "")) + if not name: + continue + + try: + key = ( + _coerce_int(logical.get("DeviceType")), + _coerce_int(logical.get("DeviceInstance")), + _coerce_int(logical.get("ProductId")), + _normalize_mac(str(logical.get("ProductMacAddress") or "")), + ) + except (TypeError, ValueError): + continue + + if key[3]: + # Snapshot has live runtime context and should win over manifest naming. + catalog.names_by_identity[key] = name + + +def _add_manifest_entries(catalog: ExternalNameCatalog, data: dict[str, Any]) -> None: + products = data.get("ProductList", []) + if not isinstance(products, list): + raise ValueError("Manifest file has invalid ProductList structure") + + for product in products: + if not isinstance(product, dict): + continue + + mac = _normalize_mac(str(product.get("UniqueID") or "")) + product_id = product.get("TypeID") + device_list = product.get("DeviceList", []) + if not mac or not isinstance(product_id, int) or not isinstance(device_list, list): + continue + + for device in device_list: + if not isinstance(device, dict): + continue + try: + key = ( + _coerce_int(device.get("TypeID")), + _coerce_int(device.get("Instance")), + _coerce_int(product_id), + mac, + ) + except (TypeError, ValueError): + continue + # Keep manifest only if a richer snapshot name has not been loaded. + catalog.names_by_identity.setdefault(key, _extract_manifest_name(device)) + + +def load_external_name_catalog( + manifest_path: str | None, + snapshot_path: str | None, + manifest_json: str | None = None, + snapshot_json: str | None = None, +) -> ExternalNameCatalog: + """Load and merge naming catalogs from optional manifest/snapshot JSON sources.""" + catalog = ExternalNameCatalog() + + if manifest_json and manifest_json.strip(): + manifest_data = json.loads(manifest_json) + _add_manifest_entries(catalog, manifest_data) + elif manifest_path: + manifest_text = Path(manifest_path).read_text(encoding="utf-8") + manifest_data = json.loads(manifest_text) + _add_manifest_entries(catalog, manifest_data) + + if snapshot_json and snapshot_json.strip(): + snapshot_data = json.loads(snapshot_json) + _add_snapshot_entries(catalog, snapshot_data) + elif snapshot_path: + snapshot_text = Path(snapshot_path).read_text(encoding="utf-8") + snapshot_data = json.loads(snapshot_text) + _add_snapshot_entries(catalog, snapshot_data) + + return catalog diff --git a/custom_components/ha_onecontrol/protocol/events.py b/custom_components/ha_onecontrol/protocol/events.py index 56e5ba0..2c7a691 100644 --- a/custom_components/ha_onecontrol/protocol/events.py +++ b/custom_components/ha_onecontrol/protocol/events.py @@ -266,6 +266,22 @@ class DeviceMetadata: function_instance: int = 0 +@dataclass +class DeviceIdentity: + """Parsed identity from GetDevices SuccessMulti (event 0x02 responseType 0x01). + + Mirrors MyRvLinkDeviceIdsCan/Host payload fields in the decompiled app. + """ + + table_id: int = 0 + device_id: int = 0 + protocol: int = 0 + device_type: int = 0 + device_instance: int = 0 + product_id: int = 0 + product_mac: str = "" + + # ── Parsers ─────────────────────────────────────────────────────────────── @@ -667,7 +683,7 @@ def parse_metadata_response(data: bytes) -> list[DeviceMetadata]: # new gateway variants or firmware versions in the field. device_id = (start_id + index) & 0xFF entry_hex = data[offset : offset + 2 + min(payload_size, 32)].hex(" ").upper() - _LOGGER.warning( + _LOGGER.debug( "Metadata entry[%d]: UNKNOWN combination — table=%d device=0x%02x " "protocol=0x%02x payload_size=%d — skipping. Raw: %s", index, table_id, device_id, protocol, payload_size, entry_hex, @@ -677,7 +693,7 @@ def parse_metadata_response(data: bytes) -> list[DeviceMetadata]: index += 1 if len(results) != count: - _LOGGER.warning( + _LOGGER.debug( "Metadata count mismatch for table=%d: frame declared %d entries, " "decoded %d (skipped %d). This may indicate an unknown protocol variant.", table_id, count, len(results), count - len(results), @@ -686,6 +702,59 @@ def parse_metadata_response(data: bytes) -> list[DeviceMetadata]: return results +def parse_get_devices_response(data: bytes) -> list[DeviceIdentity]: + """Parse GetDevices SuccessMulti payload rows (event 0x02, responseType 0x01). + + Frame layout: + [cmdIdL][cmdIdH][0x02][0x01][tableId][startId][count] + entries... + entry = [protocol][payloadSize][payload...] + + For Host/IdsCan entries with payloadSize==10, payload bytes follow decompiled + MyRvLinkDeviceHost/MyRvLinkDeviceIdsCan fields: + [deviceType][deviceInstance][productIdHi][productIdLo][mac(6 bytes)] + """ + if len(data) < 7: + return [] + + table_id = data[4] & 0xFF + start_id = data[5] & 0xFF + count = data[6] & 0xFF + + results: list[DeviceIdentity] = [] + offset = 7 + index = 0 + + while index < count and offset + 2 <= len(data): + protocol = data[offset] & 0xFF + payload_size = data[offset + 1] & 0xFF + payload_start = offset + 2 + payload_end = payload_start + payload_size + if payload_end > len(data): + break + + if protocol in (METADATA_PROTOCOL_HOST, METADATA_PROTOCOL_IDS_CAN) and payload_size == 10: + payload = data[payload_start:payload_end] + device_id = (start_id + index) & 0xFF + product_id = int.from_bytes(payload[2:4], "big") + product_mac = payload[4:10].hex().upper() + results.append( + DeviceIdentity( + table_id=table_id, + device_id=device_id, + protocol=protocol, + device_type=payload[0] & 0xFF, + device_instance=payload[1] & 0xFF, + product_id=product_id, + product_mac=product_mac, + ) + ) + + offset = payload_end + index += 1 + + return results + + # ── Dispatcher ──────────────────────────────────────────────────────────── diff --git a/custom_components/ha_onecontrol/protocol/ids_can_wire.py b/custom_components/ha_onecontrol/protocol/ids_can_wire.py new file mode 100644 index 0000000..1e1ea32 --- /dev/null +++ b/custom_components/ha_onecontrol/protocol/ids_can_wire.py @@ -0,0 +1,347 @@ +"""IDS-CAN wire-frame parsing helpers. + +These helpers decode the raw CAN adapter frame format seen on IDS-CAN +TCP bridges (per decompiled `CanAdapter.OnPhysicalNetworkReceived`). + +Frame layout: +- Byte 0: payload length (DLC, 0..8) +- Bytes 1..N: CAN ID (2 bytes for 11-bit, 4 bytes for 29-bit with ext flag) +- Remaining: payload bytes (DLC count) +""" + +from __future__ import annotations + +from dataclasses import dataclass + +from .function_names import FUNCTION_NAMES + + +_IDS_CAN_MESSAGE_TYPE_NAMES: dict[int, str] = { + 0x00: "NETWORK", + 0x01: "CIRCUIT_ID", + 0x02: "DEVICE_ID", + 0x03: "DEVICE_STATUS", + 0x06: "PRODUCT_STATUS", + 0x07: "TIME", + 0x80: "REQUEST", + 0x81: "RESPONSE", + 0x82: "COMMAND", + 0x83: "EXT_STATUS", + 0x84: "TEXT_CONSOLE", + 0x85: "GROUP_ID", + 0x9B: "DAQ", + 0x9D: "IOT", + 0x9F: "BULK_XFER", +} + + +@dataclass(frozen=True) +class IdsCanWireFrame: + """Decoded IDS-CAN wire frame.""" + + dlc: int + is_extended: bool + can_id: int + message_type: int + source_address: int + target_address: int | None + message_data: int | None + payload: bytes + + +@dataclass(frozen=True) +class IdsCanDecodedPayload: + """Semantic decode of IDS-CAN payload bytes for known message types.""" + + kind: str + fields: dict[str, int | str | bool] + + +def ids_can_message_type_name(message_type: int) -> str: + """Return IDS-CAN message type name from decompiled enum values.""" + return _IDS_CAN_MESSAGE_TYPE_NAMES.get(message_type & 0xFF, "UNKNOWN") + + +def ids_can_request_name(request_code: int) -> str: + """Return IDS-CAN REQUEST code name from decompiled REQUEST constants.""" + return { + 0x00: "PART_NUMBER_READ", + 0x01: "MUTE_DEVICE", + 0x02: "IN_MOTION_LOCKOUT", + 0x03: "SOFTWARE_UPDATE_AUTHORIZATION", + 0x04: "NOTIFICATION_ALERT", + 0x10: "PID_READ_LIST", + 0x11: "PID_READ_WRITE", + 0x12: "GET_PID_PROPERTIES", + 0x20: "BLOCK_READ_LIST", + 0x21: "BLOCK_READ_PROPERTIES", + 0x22: "BLOCK_READ_DATA", + 0x23: "BEGIN_BLOCK_WRITE", + 0x24: "BEGIN_BLOCK_WRITE_BULK_XFER", + 0x25: "BLOCK_END_BULK_XFER", + 0x26: "END_BLOCK_WRITE", + 0x27: "SET_BLOCK_ADDRESS", + 0x28: "SET_BLOCK_SIZE", + 0x30: "READ_CONTINUOUS_DTCS", + 0x31: "CONTINUOUS_DTC_COMMAND", + 0x40: "SESSION_READ_LIST", + 0x41: "SESSION_READ_STATUS", + 0x42: "SESSION_REQUEST_SEED", + 0x43: "SESSION_TRANSMIT_KEY", + 0x44: "SESSION_HEARTBEAT", + 0x45: "SESSION_END", + 0x51: "IDS_CAN_REQUEST_DAQ_NUM_CHANNELS", + 0x52: "IDS_CAN_REQUEST_DAQ_AUTO_TX_SETTINGS", + 0x53: "IDS_CAN_REQUEST_DAQ_CHANNEL_SETTINGS", + 0x54: "IDS_CAN_REQUEST_DAQ_PID_ADDRESS", + 0x60: "IDS_CAN_REQUEST_LEVELER_TYPE_5_CONTROL", + }.get(request_code & 0xFF, f"UNKNOWN_{request_code & 0xFF:02X}") + + +def ids_can_response_name(response_code: int) -> str: + """Return IDS-CAN RESPONSE enum name from decompiled RESPONSE values.""" + return { + 0x00: "SUCCESS", + 0x01: "REQUEST_NOT_SUPPORTED", + 0x02: "BAD_REQUEST", + 0x03: "VALUE_OUT_OF_RANGE", + 0x04: "UNKNOWN_ID", + 0x05: "VALUE_TOO_LARGE", + 0x06: "INVALID_ADDRESS", + 0x07: "READ_ONLY", + 0x08: "WRITE_ONLY", + 0x09: "CONDITIONS_NOT_CORRECT", + 0x0A: "FEATURE_NOT_SUPPORTED", + 0x0B: "BUSY", + 0x0C: "SEED_NOT_REQUESTED", + 0x0D: "KEY_NOT_CORRECT", + 0x0E: "SESSION_NOT_OPEN", + 0x0F: "TIMEOUT", + 0x10: "REMOTE_REQUEST_NOT_SUPPORTED", + 0x11: "IN_MOTION_LOCKOUT_ACTIVE", + 0x12: "CRC_INVALID", + 0x13: "CANCELLED", + 0x14: "ABORTED", + 0x15: "FAILED", + 0x16: "IN_PROGRESS", + }.get(response_code & 0xFF, f"UNKNOWN_{response_code & 0xFF:02X}") + + +def decode_ids_can_payload(wire: IdsCanWireFrame) -> IdsCanDecodedPayload | None: + """Decode known IDS-CAN message payload formats with decompiled parity.""" + message_type = wire.message_type & 0xFF + payload = wire.payload + + if message_type == 0x00 and len(payload) == 8: + # C# parity: MAC is bytes [2:8], protocol version is byte [1], and + # NETWORK_STATUS bitfields are interpreted from byte [0]. + status = payload[0] & 0xFF + return IdsCanDecodedPayload( + kind="network", + fields={ + "advertised_address": status, + "protocol_version": payload[1] & 0xFF, + "mac": payload[2:8].hex().upper(), + "has_active_dtcs": bool(status & 0x01), + "has_stored_dtcs": bool(status & 0x02), + "has_open_sessions": bool(status & 0x04), + "in_motion_lockout_level": (status >> 3) & 0x03, + "has_extended_cloud_capabilities": bool(status & 0x40), + "is_hazardous_device": bool(status & 0x80), + }, + ) + + if message_type == 0x01 and len(payload) >= 4: + c1, c2, c3, c4 = payload[0], payload[1], payload[2], payload[3] + return IdsCanDecodedPayload( + kind="circuit_id", + fields={ + "circuit_id": int.from_bytes(payload[0:4], "big"), + "circuit_id_text": f"{c1:02X}:{c2:02X}:{c3:02X}:{c4:02X}", + }, + ) + + if message_type == 0x02 and len(payload) in (7, 8): + function_name = int.from_bytes(payload[4:6], "big") + function_instance = payload[6] & 0x0F + fields: dict[str, int | str | bool] = { + "product_id": int.from_bytes(payload[0:2], "big"), + "product_instance": payload[2] & 0xFF, + "device_type": payload[3] & 0xFF, + "device_instance": (payload[6] >> 4) & 0x0F, + "function_name": function_name, + "function_instance": function_instance, + "function_label": FUNCTION_NAMES.get(function_name, f"Function 0x{function_name:04X}"), + } + if len(payload) >= 8: + fields["device_capabilities"] = payload[7] & 0xFF + return IdsCanDecodedPayload(kind="device_id", fields=fields) + + if message_type == 0x03 and len(payload) >= 1: + return IdsCanDecodedPayload( + kind="device_status", + fields={ + "status_length": len(payload), + "status_hex": payload.hex(), + }, + ) + + if message_type == 0x06 and len(payload) >= 1: + return IdsCanDecodedPayload( + kind="product_status", + fields={ + "software_update_state": payload[0] & 0x03, + }, + ) + + if message_type == 0x80 and wire.message_data is not None: + return IdsCanDecodedPayload( + kind="request", + fields={ + "request_code": wire.message_data & 0xFF, + "request_name": ids_can_request_name(wire.message_data), + }, + ) + + if message_type == 0x81 and wire.message_data is not None: + # RESPONSE frame message_data is the echoed request code. + # Any status/response code (if present) lives in payload bytes and varies by request. + fields: dict[str, int | str | bool] = { + "request_code": wire.message_data & 0xFF, + "request_name": ids_can_request_name(wire.message_data), + } + if len(payload) == 1: + fields["status_code"] = payload[0] & 0xFF + fields["status_name"] = ids_can_response_name(payload[0]) + return IdsCanDecodedPayload(kind="response", fields=fields) + + if message_type == 0x82 and wire.message_data is not None: + return IdsCanDecodedPayload( + kind="command", + fields={ + "command_code": wire.message_data & 0xFF, + }, + ) + + if message_type == 0x84: + return IdsCanDecodedPayload( + kind="text_console", + fields={ + "text_ascii": "".join(chr(b) if 32 <= b <= 126 else "." for b in payload), + }, + ) + + return None + + +def format_ids_can_payload(decoded: IdsCanDecodedPayload | None) -> str: + """Format decoded payload fields as compact key=value pairs for logging.""" + if decoded is None: + return "" + + ordered_items: list[tuple[str, int | str | bool]] = [] + for key in sorted(decoded.fields.keys()): + ordered_items.append((key, decoded.fields[key])) + joined = " ".join(f"{k}={v}" for k, v in ordered_items) + return f" semantic={decoded.kind} {joined}".rstrip() + + +def parse_ids_can_wire_frame(frame: bytes) -> IdsCanWireFrame | None: + """Parse one raw IDS-CAN wire frame. + + Returns None when bytes do not match the CAN adapter framing format. + """ + if len(frame) < 3: + return None + + dlc_raw = frame[0] & 0xFF + dlc = dlc_raw + if dlc > 8: + # Some IDS bridge adapters include flags in the upper nibble of the DLC byte. + # Treat lower nibble as the payload length when it is in a valid CAN range. + flagged_dlc = dlc_raw & 0x0F + if flagged_dlc <= 8 and (dlc_raw & 0xF0) != 0: + dlc = flagged_dlc + else: + return None + + # remaining = id bytes + payload bytes + remaining = len(frame) - 1 + id_len = remaining - dlc + if id_len not in (2, 4): + return None + + id_bytes = frame[1 : 1 + id_len] + payload = frame[1 + id_len :] + if len(payload) != dlc: + return None + + if id_len == 2: + can_id = ((id_bytes[0] & 0xFF) << 8) | (id_bytes[1] & 0xFF) + message_type = (can_id >> 8) & 0x07 + source_address = can_id & 0xFF + return IdsCanWireFrame( + dlc=dlc, + is_extended=False, + can_id=can_id, + message_type=message_type, + source_address=source_address, + target_address=None, + message_data=None, + payload=payload, + ) + + id_word = ( + ((id_bytes[0] & 0xFF) << 24) + | ((id_bytes[1] & 0xFF) << 16) + | ((id_bytes[2] & 0xFF) << 8) + | (id_bytes[3] & 0xFF) + ) + is_extended = (id_word & 0x80000000) != 0 + if not is_extended: + return None + + can_id = id_word & 0x7FFFFFFF + message_type = 0x80 | ((can_id >> 24) & 0x1C) | ((can_id >> 16) & 0x03) + source_address = (can_id >> 18) & 0xFF + target_address = (can_id >> 8) & 0xFF + message_data = can_id & 0xFF + + return IdsCanWireFrame( + dlc=dlc, + is_extended=True, + can_id=can_id, + message_type=message_type, + source_address=source_address, + target_address=target_address, + message_data=message_data, + payload=payload, + ) + + +def compose_ids_can_extended_wire_frame( + message_type: int, + source_address: int, + target_address: int, + message_data: int, + payload: bytes, +) -> bytes: + """Compose a raw IDS 29-bit wire frame: [dlc][id(4)][payload]. + + Mirrors the inverse of ``parse_ids_can_wire_frame`` for extended frames. + """ + dlc = len(payload) + if dlc > 8: + raise ValueError("IDS-CAN payload must be 0..8 bytes") + + msg = message_type & 0xFF + src = source_address & 0xFF + dst = target_address & 0xFF + mdata = message_data & 0xFF + + # 29-bit CAN id packing (decompiled parity): + # message_type = 0x80 | ((can_id >> 24) & 0x1C) | ((can_id >> 16) & 0x03) + # Inverse mapping keeps upper 3 type bits at [28:26] and lower 2 bits at [17:16]. + can_id = ((msg & 0x1C) << 24) | (src << 18) | ((msg & 0x03) << 16) | (dst << 8) | mdata + id_word = 0x80000000 | (can_id & 0x7FFFFFFF) + return bytes([dlc]) + id_word.to_bytes(4, "big") + payload diff --git a/custom_components/ha_onecontrol/runtime/__init__.py b/custom_components/ha_onecontrol/runtime/__init__.py new file mode 100644 index 0000000..be246ec --- /dev/null +++ b/custom_components/ha_onecontrol/runtime/__init__.py @@ -0,0 +1,6 @@ +"""Protocol runtime implementations for OneControl.""" + +from .ids_can_runtime import IdsCanRuntime +from .myrvlink_runtime import MyRvLinkRuntime + +__all__ = ["IdsCanRuntime", "MyRvLinkRuntime"] diff --git a/custom_components/ha_onecontrol/runtime/ids_can_runtime.py b/custom_components/ha_onecontrol/runtime/ids_can_runtime.py new file mode 100644 index 0000000..76ba57a --- /dev/null +++ b/custom_components/ha_onecontrol/runtime/ids_can_runtime.py @@ -0,0 +1,1304 @@ +"""IDS-CAN (Ethernet bridge) runtime. + +This runtime owns Ethernet transport/session handling while delegating shared +state updates and frame parsing back to the coordinator. +""" + +from __future__ import annotations + +import asyncio +import logging +import socket +import time +from typing import TYPE_CHECKING + +from ..protocol.ids_can_wire import ( + compose_ids_can_extended_wire_frame, + decode_ids_can_payload, + format_ids_can_payload, + ids_can_message_type_name, + parse_ids_can_wire_frame, +) +from ..protocol.cobs import cobs_encode +from ..protocol.events import ( + CoverStatus, + DeviceIdentity, + DimmableLight, + GeneratorStatus, + HourMeter, + HvacZone, + RelayStatus, + RgbLight, + TankLevel, + parse_get_devices_response, + parse_metadata_response, +) + +if TYPE_CHECKING: + from ..coordinator import OneControlCoordinator + +_LOGGER = logging.getLogger(__name__) + +_IDS_SESSION_REMOTE_CONTROL = 0x0004 +_IDS_SESSION_REMOTE_CONTROL_CYPHER = 0xB16B5E95 +_IDS_COMMAND_SETTLE_WINDOW_S = 1.2 + + +class IdsCanRuntime: + """Runtime for IDS-CAN over Ethernet transport.""" + + def __init__(self, coordinator: OneControlCoordinator) -> None: + self._c = coordinator + self._ids_source_product_mac: dict[int, str] = {} + self._ids_source_identities: dict[int, DeviceIdentity] = {} + # Learned source address used when transmitting IDS extended COMMAND frames. + self._ids_controller_source_address: int = 0x3A + self._ids_recent_command_targets: dict[int, float] = {} + self._ids_session_opened_at: dict[int, float] = {} + self._ids_session_waiters: dict[int, asyncio.Event] = {} + self._ids_session_results: dict[int, bool | None] = {} + self._ids_session_locks: dict[int, asyncio.Lock] = {} + self._ids_session_seed_requested_at: dict[int, float] = {} + self._ids_session_last_status_code: dict[int, int] = {} + self._ids_session_last_heartbeat_at: dict[int, float] = {} + self._ids_active_session_target: int | None = None + self._ids_pending_status_expectations: dict[tuple[int, int], tuple[bool, float]] = {} + + def _record_recent_command_target(self, target_address: int) -> None: + """Track recent IDS command targets for response correlation logs.""" + now = time.monotonic() + self._ids_recent_command_targets[target_address & 0xFF] = now + # Keep map small and recent. + stale_cutoff = now - 5.0 + stale_keys = [k for k, ts in self._ids_recent_command_targets.items() if ts < stale_cutoff] + for key in stale_keys: + self._ids_recent_command_targets.pop(key, None) + + def _set_pending_status_expectation(self, device_type: int, device_id: int, desired_on: bool) -> None: + """Record short-lived desired state to suppress immediate stale status echoes.""" + self._ids_pending_status_expectations[(device_type & 0xFF, device_id & 0xFF)] = ( + desired_on, + time.monotonic() + _IDS_COMMAND_SETTLE_WINDOW_S, + ) + + def _should_accept_status(self, device_type: int, device_id: int, observed_on: bool) -> bool: + """Gate status updates during a small post-command settle window.""" + key = (device_type & 0xFF, device_id & 0xFF) + expectation = self._ids_pending_status_expectations.get(key) + if expectation is None: + return True + + desired_on, expires_at = expectation + now = time.monotonic() + if now >= expires_at: + self._ids_pending_status_expectations.pop(key, None) + return True + + if observed_on == desired_on: + self._ids_pending_status_expectations.pop(key, None) + return True + + _LOGGER.debug( + "PACKET RX IDS status suppressed device_type=%d device=0x%02X observed_on=%s desired_on=%s remaining_ms=%d", + device_type & 0xFF, + device_id & 0xFF, + observed_on, + desired_on, + int((expires_at - now) * 1000), + ) + return False + + def _ids_encrypt_session_seed(self, seed: int) -> int: + """Encrypt IDS session seed using REMOTE_CONTROL session cypher parity.""" + s = seed & 0xFFFFFFFF + c = _IDS_SESSION_REMOTE_CONTROL_CYPHER & 0xFFFFFFFF + rounds = 32 + delta = 0x9E3779B9 + summation = delta + while True: + s = (s + (((c << 4) + 1131376761) ^ (c + summation) ^ ((c >> 5) + 1919510376))) & 0xFFFFFFFF + rounds -= 1 + if rounds <= 0: + break + c = (c + (((s << 4) + 1948272964) ^ (s + summation) ^ ((s >> 5) + 1400073827))) & 0xFFFFFFFF + summation = (summation + delta) & 0xFFFFFFFF + return s + + async def _send_ids_request(self, target_address: int, request_code: int, payload: bytes) -> bool: + """Send one IDS REQUEST(0x80) frame to target over Ethernet.""" + if not self._c.is_ethernet_gateway or not self._c._connected or self._c._eth_writer is None: + return False + raw = compose_ids_can_extended_wire_frame( + message_type=0x80, + source_address=self._ids_controller_source_address, + target_address=target_address & 0xFF, + message_data=request_code & 0xFF, + payload=payload, + ) + self._c._eth_writer.write(cobs_encode(raw)) + await self._c._eth_writer.drain() + self._c._last_ethernet_tx_time = time.monotonic() + _LOGGER.warning( + "PACKET TX IDS request src=0x%02X dst=0x%02X req=0x%02X payload=%s raw=%s", + self._ids_controller_source_address & 0xFF, + target_address & 0xFF, + request_code & 0xFF, + payload.hex(), + raw.hex(), + ) + return True + + async def ensure_remote_control_session(self, target_address: int) -> bool: + """Best-effort IDS REMOTE_CONTROL session open for command-capable devices.""" + target = target_address & 0xFF + lock = self._ids_session_locks.get(target) + if lock is None: + lock = asyncio.Lock() + self._ids_session_locks[target] = lock + + async with lock: + opened_at = self._ids_session_opened_at.get(target) + now = time.monotonic() + if opened_at is not None and (now - opened_at) <= 45.0: + self._ids_active_session_target = target + last_heartbeat_at = self._ids_session_last_heartbeat_at.get(target, 0.0) + if (now - last_heartbeat_at) >= 1.0: + heartbeat_payload = _IDS_SESSION_REMOTE_CONTROL.to_bytes(2, "big") + sent_heartbeat = await self._send_ids_request(target, 0x44, heartbeat_payload) + if sent_heartbeat: + self._ids_session_last_heartbeat_at[target] = now + return True + + previous_target = self._ids_active_session_target + if previous_target is not None and previous_target != target: + previous_opened_at = self._ids_session_opened_at.get(previous_target) + if previous_opened_at is not None and (now - previous_opened_at) <= 180.0: + end_payload = _IDS_SESSION_REMOTE_CONTROL.to_bytes(2, "big") + sent_end = await self._send_ids_request(previous_target, 0x45, end_payload) + if sent_end: + _LOGGER.warning( + "PACKET TX IDS session-end src=0x%02X dst=0x%02X session=0x%04X", + self._ids_controller_source_address & 0xFF, + previous_target & 0xFF, + _IDS_SESSION_REMOTE_CONTROL, + ) + self._ids_session_opened_at.pop(previous_target, None) + self._ids_session_last_heartbeat_at.pop(previous_target, None) + + max_attempts = 2 + for attempt in range(1, max_attempts + 1): + event = self._ids_session_waiters.get(target) + if event is None: + event = asyncio.Event() + self._ids_session_waiters[target] = event + else: + event.clear() + + self._ids_session_results[target] = None + self._ids_session_seed_requested_at[target] = time.monotonic() + + seed_req_payload = _IDS_SESSION_REMOTE_CONTROL.to_bytes(2, "big") + sent = await self._send_ids_request(target, 0x42, seed_req_payload) + if not sent: + self._ids_session_seed_requested_at.pop(target, None) + self._ids_session_results.pop(target, None) + return False + + try: + await asyncio.wait_for(event.wait(), timeout=1.2) + except TimeoutError: + _LOGGER.warning( + "PACKET TX IDS session-open timeout dst=0x%02X attempt=%d/%d; skipping IDS command send", + target, + attempt, + max_attempts, + ) + self._ids_session_seed_requested_at.pop(target, None) + self._ids_session_results.pop(target, None) + return False + + result = self._ids_session_results.get(target) + if result is True: + self._ids_active_session_target = target + return True + + status_code = self._ids_session_last_status_code.get(target) + if status_code == 0x0B and attempt < max_attempts: + _LOGGER.warning( + "PACKET TX IDS session-open busy dst=0x%02X; retrying attempt=%d/%d", + target, + attempt + 1, + max_attempts, + ) + await asyncio.sleep(0.15) + continue + + # Some devices report BUSY for seed requests while still honoring + # commands on an already-open remote-control session. If we have a + # recent successful open, keep using it instead of hard-failing. + if status_code == 0x0B: + recent_opened_at = self._ids_session_opened_at.get(target) + if recent_opened_at is not None and (time.monotonic() - recent_opened_at) <= 180.0: + _LOGGER.warning( + "PACKET TX IDS session-open busy dst=0x%02X; reusing recent session-open age=%.1fs", + target, + time.monotonic() - recent_opened_at, + ) + return True + + _LOGGER.warning( + "PACKET TX IDS session-open failed dst=0x%02X attempt=%d/%d; skipping IDS command send", + target, + attempt, + max_attempts, + ) + return False + + return False + + def _ids_default_table_id(self) -> int: + """Best-effort table id for IDS-only Ethernet traffic.""" + if self._c.gateway_info is not None and self._c.gateway_info.table_id != 0: + return self._c.gateway_info.table_id & 0xFF + inferred = self._c._select_get_devices_table_id() + if inferred is not None and inferred != 0: + return inferred & 0xFF + return 0x03 + + def _resolve_ids_identity( + self, + table_id: int, + device_id: int, + expected_device_type: int, + ) -> tuple[str, DeviceIdentity] | None: + """Resolve IDS identity by exact key first, then by device-id fallback. + + Some gateways report table ids inconsistently between discovery and control + paths, so we allow a controlled fallback on matching device_id/type. + """ + key = f"{table_id & 0xFF:02x}:{device_id & 0xFF:02x}" + identity = self._c._device_identities.get(key) + if identity is not None: + return key, identity + + candidates: list[tuple[str, DeviceIdentity]] = [] + for identity_key, candidate in self._c._device_identities.items(): + if ( + candidate.protocol == 2 + and (candidate.device_id & 0xFF) == (device_id & 0xFF) + and (candidate.device_type & 0xFF) == (expected_device_type & 0xFF) + ): + candidates.append((identity_key, candidate)) + + if len(candidates) == 1: + matched_key, matched_identity = candidates[0] + _LOGGER.warning( + "PACKET TX IDS identity fallback requested=%s matched=%s device_type=%d", + key, + matched_key, + matched_identity.device_type, + ) + return matched_key, matched_identity + + if len(candidates) > 1: + _LOGGER.warning( + "PACKET TX IDS identity ambiguous requested=%s matches=%d device_type=%d", + key, + len(candidates), + expected_device_type, + ) + return None + + def _ensure_event_store_maps(self) -> None: + """Ensure coordinator state maps exist for lightweight test doubles.""" + for attr in ( + "relays", + "dimmable_lights", + "rgb_lights", + "covers", + "hvac_zones", + "tanks", + "generators", + "hour_meters", + "_device_identities", + ): + if not hasattr(self._c, attr): + setattr(self._c, attr, {}) + + def _dispatch_state_event(self, event: object) -> None: + """Publish translated IDS state updates through normal coordinator fan-out.""" + dispatch = getattr(self._c, "_dispatch_event_update", None) + if callable(dispatch): + dispatch(event) + return + if hasattr(self._c, "async_set_updated_data") and hasattr(self._c, "_build_data"): + self._c.async_set_updated_data(self._c._build_data()) + + def _bootstrap_entity_from_identity(self, identity: DeviceIdentity) -> None: + """Seed coordinator state dicts from IDS DEVICE_ID payloads.""" + self._ensure_event_store_maps() + key = f"{identity.table_id:02x}:{identity.device_id:02x}" + emitted_event: object | None = None + + if identity.device_type == 30 and key not in self._c.relays: + event = RelayStatus( + table_id=identity.table_id, + device_id=identity.device_id, + is_on=False, + status_byte=0, + dtc_code=0, + ) + self._c.relays[key] = event + emitted_event = event + elif identity.device_type == 20 and key not in self._c.dimmable_lights: + event = DimmableLight( + table_id=identity.table_id, + device_id=identity.device_id, + brightness=0, + mode=0, + ) + self._c.dimmable_lights[key] = event + emitted_event = event + elif identity.device_type == 13 and key not in self._c.rgb_lights: + event = RgbLight( + table_id=identity.table_id, + device_id=identity.device_id, + mode=0, + red=0, + green=0, + blue=0, + brightness=255, + ) + self._c.rgb_lights[key] = event + emitted_event = event + elif identity.device_type == 33 and key not in self._c.covers: + event = CoverStatus( + table_id=identity.table_id, + device_id=identity.device_id, + status=0xC0, + position=None, + ) + self._c.covers[key] = event + emitted_event = event + elif identity.device_type == 16 and key not in self._c.hvac_zones: + event = HvacZone( + table_id=identity.table_id, + device_id=identity.device_id, + heat_mode=0, + heat_source=0, + fan_mode=0, + low_trip_f=68, + high_trip_f=72, + zone_status=0, + indoor_temp_f=None, + outdoor_temp_f=None, + dtc_code=0, + ) + self._c.hvac_zones[key] = event + emitted_event = event + elif identity.device_type == 10 and key not in self._c.tanks: + event = TankLevel( + table_id=identity.table_id, + device_id=identity.device_id, + level=0, + ) + self._c.tanks[key] = event + emitted_event = event + elif identity.device_type == 24 and key not in self._c.generators: + event = GeneratorStatus( + table_id=identity.table_id, + device_id=identity.device_id, + state=0, + battery_voltage=0.0, + temperature_c=None, + quiet_hours=False, + ) + self._c.generators[key] = event + emitted_event = event + elif identity.device_type == 12 and key not in self._c.hour_meters: + event = HourMeter( + table_id=identity.table_id, + device_id=identity.device_id, + hours=0.0, + maintenance_due=False, + maintenance_past_due=False, + error=False, + ) + self._c.hour_meters[key] = event + emitted_event = event + + if emitted_event is not None: + self._dispatch_state_event(emitted_event) + + def _handle_ids_device_id(self, source_address: int, semantic_fields: dict[str, int | str | bool]) -> None: + """Map IDS DEVICE_ID frames into coordinator identity/name state.""" + table_id = self._ids_default_table_id() + device_id = source_address & 0xFF + product_id = int(semantic_fields.get("product_id", 0)) + device_type = int(semantic_fields.get("device_type", 0)) + device_instance = int(semantic_fields.get("device_instance", 0)) + product_mac = self._ids_source_product_mac.get(device_id, "") + + identity = DeviceIdentity( + table_id=table_id, + device_id=device_id, + protocol=2, + device_type=device_type, + device_instance=device_instance, + product_id=product_id, + product_mac=product_mac, + ) + key = f"{table_id:02x}:{device_id:02x}" + self._ids_source_identities[device_id] = identity + self._c._device_identities[key] = identity + self._c._apply_external_name(key, identity) + self._bootstrap_entity_from_identity(identity) + + def _handle_ids_device_status(self, source_address: int, payload: bytes) -> None: + """Translate core IDS DEVICE_STATUS payloads into coordinator state events.""" + if not payload: + return + + identity = self._ids_source_identities.get(source_address & 0xFF) + if identity is None: + return + + self._ensure_event_store_maps() + table_id = identity.table_id + device_id = identity.device_id + key = f"{table_id:02x}:{device_id:02x}" + status0 = payload[0] & 0xFF + on_state = (status0 & 0x01) != 0 + + if identity.device_type in {20, 30} and not self._should_accept_status( + identity.device_type, + device_id, + on_state, + ): + return + + emitted_event: object | None = None + if identity.device_type == 30: + event = RelayStatus( + table_id=table_id, + device_id=device_id, + is_on=on_state, + status_byte=status0, + dtc_code=0, + ) + self._c.relays[key] = event + emitted_event = event + elif identity.device_type == 20: + brightness = payload[3] & 0xFF if len(payload) >= 4 else (255 if on_state else 0) + event = DimmableLight( + table_id=table_id, + device_id=device_id, + brightness=brightness, + mode=1 if on_state else 0, + ) + self._c.dimmable_lights[key] = event + emitted_event = event + elif identity.device_type == 13: + current = self._c.rgb_lights.get(key) + event = RgbLight( + table_id=table_id, + device_id=device_id, + mode=1 if on_state else 0, + red=current.red if current else 0, + green=current.green if current else 0, + blue=current.blue if current else 0, + brightness=current.brightness if current else 255, + ) + self._c.rgb_lights[key] = event + emitted_event = event + elif identity.device_type == 33: + position = payload[1] & 0xFF if len(payload) >= 2 and payload[1] != 0xFF else None + event = CoverStatus( + table_id=table_id, + device_id=device_id, + status=status0, + position=position, + ) + self._c.covers[key] = event + emitted_event = event + elif identity.device_type == 10: + event = TankLevel( + table_id=table_id, + device_id=device_id, + level=payload[0] & 0xFF, + ) + self._c.tanks[key] = event + emitted_event = event + elif identity.device_type == 24: + event = GeneratorStatus( + table_id=table_id, + device_id=device_id, + state=status0 & 0x07, + battery_voltage=0.0, + temperature_c=None, + quiet_hours=bool(status0 & 0x80), + ) + self._c.generators[key] = event + emitted_event = event + elif identity.device_type == 12 and len(payload) >= 4: + operating_seconds = int.from_bytes(payload[0:4], "big") + event = HourMeter( + table_id=table_id, + device_id=device_id, + hours=operating_seconds / 3600.0, + maintenance_due=False, + maintenance_past_due=False, + error=False, + ) + self._c.hour_meters[key] = event + emitted_event = event + + if emitted_event is not None: + self._dispatch_state_event(emitted_event) + + async def connect(self) -> None: + """Connect to an IDS CAN-to-Ethernet bridge with retries.""" + max_attempts = 3 + last_exc: Exception | None = None + for attempt in range(1, max_attempts + 1): + try: + await self._try_connect(attempt) + return + except Exception as exc: + last_exc = exc + _LOGGER.warning( + "Ethernet connection attempt %d/%d failed: %s", + attempt, + max_attempts, + exc, + ) + await self.close_transport() + self._c._connected = False + self._c._authenticated = False + if attempt < max_attempts: + delay = 2 * attempt + _LOGGER.info("Retrying Ethernet in %ds...", delay) + await asyncio.sleep(delay) + + assert last_exc is not None + raise last_exc + + async def _try_connect(self, attempt: int) -> None: + """Open TCP connection to Ethernet bridge and start reader task.""" + host = self._c._eth_host or self._c.address + port = self._c._eth_port + if not host or port <= 0: + raise ConnectionError("Ethernet host/port are not configured") + + _LOGGER.info( + "Connecting to OneControl bridge %s:%d (attempt %d)", + host, + port, + attempt, + ) + + reader, writer = await asyncio.wait_for( + asyncio.open_connection(host=host, port=port), + timeout=10.0, + ) + + sock = writer.get_extra_info("socket") + if sock is not None: + try: + sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1) + if hasattr(socket, "TCP_KEEPIDLE"): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPIDLE, 30) + if hasattr(socket, "TCP_KEEPINTVL"): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPINTVL, 10) + if hasattr(socket, "TCP_KEEPCNT"): + sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_KEEPCNT, 3) + except Exception: # noqa: BLE001 + _LOGGER.debug("Unable to apply TCP keepalive socket options", exc_info=True) + + self._c._eth_reader = reader + self._c._eth_writer = writer + self._c._last_ethernet_tx_time = time.monotonic() + self._c._connected = True + self._c._authenticated = True + self._c._decoder.reset() + self._c.async_set_updated_data(self._c._build_data()) + + self._c._ethernet_reader_task = self._c.hass.async_create_background_task( + self.read_loop(), + "ha_onecontrol_ethernet_reader", + ) + self._c._start_heartbeat() + self._c.hass.async_create_task(self._c._send_initial_get_devices()) + + async def read_loop(self) -> None: + """Read Ethernet bytes and decode COBS frames into protocol events.""" + if self._c._eth_reader is None: + return + + try: + while self._c._connected and self._c._eth_reader is not None: + chunk = await self._c._eth_reader.read(512) + if not chunk: + raise ConnectionError("Ethernet bridge closed connection") + for byte_val in chunk: + frame = self._c._decoder.decode_byte(byte_val) + if frame is not None: + looks_like_myrvlink_cmd = ( + len(frame) >= 4 + and (frame[0] & 0xFF) == 0x02 + and (frame[3] & 0xFF) in {0x01, 0x02, 0x81, 0x82} + ) + wire = parse_ids_can_wire_frame(frame) + if wire is not None and not looks_like_myrvlink_cmd: + semantic = decode_ids_can_payload(wire) + semantic_suffix = format_ids_can_payload(semantic) + log_level = logging.WARNING + if semantic is not None: + log_level = logging.DEBUG + + if _LOGGER.isEnabledFor(log_level): + if wire.is_extended: + _LOGGER.log( + log_level, + "PACKET RX IDS dlc=%d id=0x%08X ext=1 type=0x%02X(%s) src=0x%02X dst=0x%02X msg=0x%02X payload=%s%s", + wire.dlc, + wire.can_id, + wire.message_type, + ids_can_message_type_name(wire.message_type), + wire.source_address, + wire.target_address if wire.target_address is not None else 0, + wire.message_data if wire.message_data is not None else 0, + wire.payload.hex(), + semantic_suffix, + ) + else: + _LOGGER.log( + log_level, + "PACKET RX IDS dlc=%d id=0x%03X ext=0 type=0x%02X(%s) src=0x%02X payload=%s%s", + wire.dlc, + wire.can_id, + wire.message_type, + ids_can_message_type_name(wire.message_type), + wire.source_address, + wire.payload.hex(), + semantic_suffix, + ) + else: + _LOGGER.warning( + "PACKET RX ETH frame len=%d raw=%s", + len(frame), + frame.hex(), + ) + self._c._process_frame(frame) + except asyncio.CancelledError: + return + except Exception as exc: # noqa: BLE001 + _LOGGER.warning("Ethernet read loop ended: %s", exc) + finally: + if self._c._connected: + self._c._handle_transport_disconnect("ethernet", "read loop ended") + + async def close_transport(self) -> None: + """Close active Ethernet socket and reader task.""" + if self._c._ethernet_reader_task and not self._c._ethernet_reader_task.done(): + self._c._ethernet_reader_task.cancel() + self._c._ethernet_reader_task = None + + if self._c._eth_writer is not None: + self._c._eth_writer.close() + try: + await self._c._eth_writer.wait_closed() + except Exception: # noqa: BLE001 + pass + + self._c._eth_reader = None + self._c._eth_writer = None + self._c._last_ethernet_tx_time = 0.0 + + async def send_transport_keepalive(self, interval_s: float) -> None: + """Send a transport-level delimiter to prevent idle TCP closes.""" + if not self._c.is_ethernet_gateway or not self._c._connected or self._c._eth_writer is None: + return + if (time.monotonic() - self._c._last_ethernet_tx_time) < interval_s: + return + self._c._eth_writer.write(b"\x00") + await self._c._eth_writer.drain() + self._c._last_ethernet_tx_time = time.monotonic() + self._c._ethernet_transport_keepalives_sent += 1 + _LOGGER.debug("TX Ethernet transport keepalive delimiter") + + async def force_reconnect(self, reason: str) -> None: + """Close Ethernet transport and trigger reconnect handling once.""" + if not self._c.is_ethernet_gateway or not self._c._connected: + return + _LOGGER.debug("Forcing Ethernet reconnect (%s)", reason) + await self.close_transport() + if self._c._connected: + self._c._handle_transport_disconnect("ethernet", reason) + + async def heartbeat_pre_gateway_cycle(self, keepalive_interval_s: float) -> None: + """Keep transport alive and optionally probe table IDs before GatewayInfo arrives.""" + if not self._c.is_ethernet_gateway or not self._c._connected: + return + if self._c.gateway_info is not None: + return + + await self.send_transport_keepalive(keepalive_interval_s) + if self._c._pending_get_devices_cmdids: + return + table_id = self._c._select_get_devices_table_id() + if table_id is None: + return + + cmd = self._c._cmd.build_get_devices(table_id) + cmd_id = int.from_bytes(cmd[0:2], "little") + self._c._record_pending_get_devices_cmd(cmd_id, table_id) + await self._c.async_send_command(cmd) + + async def send_light_toggle_command(self, table_id: int, device_id: int, turn_on: bool) -> bool: + """Send an IDS native COMMAND(0x82) frame for dimmable light on/off. + + Returns True when IDS-native command path was used and written. + Returns False when prerequisites are missing so callers can fallback. + """ + if not self._c.is_ethernet_gateway or not self._c._connected or self._c._eth_writer is None: + _LOGGER.warning( + "PACKET TX IDS light-toggle skipped table=0x%02X device=0x%02X reason=transport-not-ready", + table_id & 0xFF, + device_id & 0xFF, + ) + return False + + resolved = self._resolve_ids_identity(table_id, device_id, expected_device_type=20) + if resolved is None: + _LOGGER.warning( + "PACKET TX IDS light-toggle skipped table=0x%02X device=0x%02X reason=identity-not-found", + table_id & 0xFF, + device_id & 0xFF, + ) + return False + + key, identity = resolved + if identity.protocol != 2 or identity.device_type != 20: + _LOGGER.warning( + "PACKET TX IDS light-toggle skipped key=%s reason=identity-mismatch protocol=%d device_type=%d", + key, + identity.protocol, + identity.device_type, + ) + return False + + session_ok = await self.ensure_remote_control_session(device_id & 0xFF) + if not session_ok: + return False + + # Dimmable light command packet parity: + # commandByte=0x00, data[0]=mode(0=Off,1=On), fixed 8-byte payload. + if turn_on: + payload = bytes([0x01, 0xFF, 0x00, 0x00, 0xDC, 0x00, 0xDC, 0x00]) + else: + payload = bytes([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + + raw = compose_ids_can_extended_wire_frame( + message_type=0x82, + source_address=self._ids_controller_source_address, + target_address=device_id & 0xFF, + message_data=0x00, + payload=payload, + ) + self._record_recent_command_target(device_id & 0xFF) + self._c._eth_writer.write(cobs_encode(raw)) + await self._c._eth_writer.drain() + self._c._last_ethernet_tx_time = time.monotonic() + + # Optimistic state mirrors BLE behavior and is corrected by incoming status. + self._ensure_event_store_maps() + brightness = 255 if turn_on else 0 + event = DimmableLight( + table_id=table_id & 0xFF, + device_id=device_id & 0xFF, + brightness=brightness, + mode=1 if turn_on else 0, + ) + self._c.dimmable_lights[key] = event + self._dispatch_state_event(event) + self._set_pending_status_expectation(identity.device_type, device_id, turn_on) + + _LOGGER.warning( + "PACKET TX IDS light-toggle src=0x%02X dst=0x%02X cmd=0x00 on=%s payload=%s raw=%s", + self._ids_controller_source_address & 0xFF, + device_id & 0xFF, + turn_on, + payload.hex(), + raw.hex(), + ) + return True + + async def send_relay_toggle_command(self, table_id: int, device_id: int, turn_on: bool) -> bool: + """Send an IDS native COMMAND(0x82) frame for relay on/off. + + Returns True when IDS-native command path was used and written. + Returns False when prerequisites are missing so callers can fallback. + """ + if not self._c.is_ethernet_gateway or not self._c._connected or self._c._eth_writer is None: + _LOGGER.warning( + "PACKET TX IDS relay-toggle skipped table=0x%02X device=0x%02X reason=transport-not-ready", + table_id & 0xFF, + device_id & 0xFF, + ) + return False + + resolved = self._resolve_ids_identity(table_id, device_id, expected_device_type=30) + if resolved is None: + _LOGGER.warning( + "PACKET TX IDS relay-toggle skipped table=0x%02X device=0x%02X reason=identity-not-found", + table_id & 0xFF, + device_id & 0xFF, + ) + return False + + key, identity = resolved + if identity.protocol != 2 or identity.device_type != 30: + _LOGGER.warning( + "PACKET TX IDS relay-toggle skipped key=%s reason=identity-mismatch protocol=%d device_type=%d", + key, + identity.protocol, + identity.device_type, + ) + return False + + session_ok = await self.ensure_remote_control_session(device_id & 0xFF) + if not session_ok: + return False + + # RelayBasicLatching Type2 parity: + # Off=commandByte 0x00, On=commandByte 0x01, empty data payload. + raw = compose_ids_can_extended_wire_frame( + message_type=0x82, + source_address=self._ids_controller_source_address, + target_address=device_id & 0xFF, + message_data=0x01 if turn_on else 0x00, + payload=b"", + ) + self._record_recent_command_target(device_id & 0xFF) + self._c._eth_writer.write(cobs_encode(raw)) + await self._c._eth_writer.drain() + self._c._last_ethernet_tx_time = time.monotonic() + + # Optimistic state mirrors BLE behavior and is corrected by incoming status. + self._ensure_event_store_maps() + event = RelayStatus( + table_id=table_id & 0xFF, + device_id=device_id & 0xFF, + is_on=turn_on, + status_byte=0x01 if turn_on else 0x00, + dtc_code=0, + ) + self._c.relays[key] = event + self._dispatch_state_event(event) + self._set_pending_status_expectation(identity.device_type, device_id, turn_on) + + _LOGGER.warning( + "PACKET TX IDS relay-toggle src=0x%02X dst=0x%02X cmd=0x%02X on=%s raw=%s", + self._ids_controller_source_address & 0xFF, + device_id & 0xFF, + 0x01 if turn_on else 0x00, + turn_on, + raw.hex(), + ) + return True + + def cleanup_on_disconnect(self) -> None: + """Synchronous cleanup used by disconnect callback path.""" + if self._c._eth_writer is not None: + self._c._eth_writer.close() + self._c._eth_reader = None + self._c._eth_writer = None + if self._c._ethernet_reader_task and not self._c._ethernet_reader_task.done(): + self._c._ethernet_reader_task.cancel() + self._c._ethernet_reader_task = None + self._ids_pending_status_expectations.clear() + + def handle_frame(self, frame: bytes) -> bool: + """Handle IDS-CAN/Ethernet command envelopes and cmdId correlation. + + Returns True when the frame is fully consumed and should not be parsed + further by the coordinator. + """ + if not self._c.is_ethernet_gateway: + return False + + if not frame: + return True + + event_type = frame[0] & 0xFF + response_types = {0x01, 0x02, 0x81, 0x82} + looks_like_myrvlink_cmd = ( + len(frame) >= 4 + and event_type == 0x02 + and (frame[3] & 0xFF) in response_types + ) + + wire = parse_ids_can_wire_frame(frame) + if wire is not None and not looks_like_myrvlink_cmd: + self._c._cmd_correlation_stats["ids_can_wire_frames_seen"] = ( + self._c._cmd_correlation_stats.get("ids_can_wire_frames_seen", 0) + 1 + ) + key = f"ids_can_type_{wire.message_type:02x}" + self._c._cmd_correlation_stats[key] = self._c._cmd_correlation_stats.get(key, 0) + 1 + semantic = decode_ids_can_payload(wire) + if semantic is not None: + if wire.is_extended and semantic.kind in {"request", "command"}: + self._ids_controller_source_address = wire.source_address & 0xFF + semantic_key = f"ids_can_semantic_{semantic.kind}" + self._c._cmd_correlation_stats[semantic_key] = ( + self._c._cmd_correlation_stats.get(semantic_key, 0) + 1 + ) + if semantic.kind == "network": + mac = semantic.fields.get("mac") + if isinstance(mac, str) and mac: + self._ids_source_product_mac[wire.source_address & 0xFF] = mac + elif semantic.kind == "response": + if wire.is_extended and wire.message_data == 0x42 and len(wire.payload) == 6: + session_id = int.from_bytes(wire.payload[0:2], "big") + if session_id == _IDS_SESSION_REMOTE_CONTROL: + target = wire.source_address & 0xFF + requested_at = self._ids_session_seed_requested_at.get(target) + # Ignore stale seeds if no matching in-flight request exists. + if requested_at is None or (time.monotonic() - requested_at) > 2.0: + return True + self._ids_session_seed_requested_at.pop(target, None) + seed = int.from_bytes(wire.payload[2:6], "big") + key = self._ids_encrypt_session_seed(seed) + key_payload = ( + _IDS_SESSION_REMOTE_CONTROL.to_bytes(2, "big") + + key.to_bytes(4, "big") + ) + self._c.hass.async_create_task( + self._send_ids_request(target, 0x43, key_payload) + ) + elif wire.is_extended and wire.message_data == 0x43 and len(wire.payload) == 2: + session_id = int.from_bytes(wire.payload[0:2], "big") + if session_id == _IDS_SESSION_REMOTE_CONTROL: + target = wire.source_address & 0xFF + self._ids_session_opened_at[target] = time.monotonic() + self._ids_session_results[target] = True + self._ids_session_last_heartbeat_at[target] = time.monotonic() + self._ids_active_session_target = target + waiter = self._ids_session_waiters.get(target) + if waiter is not None: + waiter.set() + _LOGGER.warning( + "PACKET RX IDS session-opened src=0x%02X session=0x%04X", + target, + session_id, + ) + elif wire.is_extended and wire.message_data in {0x44, 0x45} and len(wire.payload) == 3: + session_id = int.from_bytes(wire.payload[0:2], "big") + if session_id == _IDS_SESSION_REMOTE_CONTROL: + target = wire.source_address & 0xFF + reason = wire.payload[2] & 0xFF + self._ids_session_opened_at.pop(target, None) + self._ids_session_last_heartbeat_at.pop(target, None) + self._ids_session_results[target] = False + if self._ids_active_session_target == target: + self._ids_active_session_target = None + _LOGGER.warning( + "PACKET RX IDS session-closed src=0x%02X req=0x%02X reason=0x%02X", + target, + wire.message_data & 0xFF, + reason, + ) + waiter = self._ids_session_waiters.get(target) + if waiter is not None: + waiter.set() + status_code = semantic.fields.get("status_code") + if isinstance(status_code, int) and status_code != 0x00: + status_name = str(semantic.fields.get("status_name", "UNKNOWN")) + request_code = int(semantic.fields.get("request_code", 0)) + request_name = str(semantic.fields.get("request_name", "UNKNOWN")) + src_addr = wire.source_address & 0xFF + now = time.monotonic() + target_seen_at = self._ids_recent_command_targets.get(src_addr) + likely_related_to_recent_tx = ( + target_seen_at is not None and (now - target_seen_at) <= 2.5 + ) + log_level = logging.WARNING if likely_related_to_recent_tx else logging.DEBUG + _LOGGER.log( + log_level, + "PACKET RX IDS response-status src=0x%02X dst=0x%02X req=0x%02X(%s) status=0x%02X(%s) related_to_recent_tx=%s payload=%s", + src_addr, + wire.target_address if wire.target_address is not None else 0, + request_code & 0xFF, + request_name, + status_code & 0xFF, + status_name, + likely_related_to_recent_tx, + wire.payload.hex(), + ) + if request_code in {0x42, 0x43}: + src_addr = wire.source_address & 0xFF + self._ids_session_last_status_code[src_addr] = status_code & 0xFF + # Only fail an in-flight open attempt. Ignore late status errors + # that arrive after we already marked session-open success. + if self._ids_session_results.get(src_addr) is None: + self._ids_session_opened_at.pop(src_addr, None) + self._ids_session_last_heartbeat_at.pop(src_addr, None) + self._ids_session_results[src_addr] = False + waiter = self._ids_session_waiters.get(src_addr) + if waiter is not None: + waiter.set() + if status_code == 0x0E: + _LOGGER.warning( + "PACKET RX IDS SESSION_NOT_OPEN observed; command path may require IDS session open/auth before COMMAND(0x82)." + ) + elif semantic.kind == "device_id": + self._handle_ids_device_id(wire.source_address, semantic.fields) + elif semantic.kind == "device_status": + self._handle_ids_device_status(wire.source_address, wire.payload) + return True + + def _resolve_pending_cmd_id(raw_cmd_id: int | None) -> int | None: + """Match cmdId against pending maps with LE/BE tolerance.""" + if raw_cmd_id is None: + return None + if ( + raw_cmd_id in self._c._pending_get_devices_cmdids + or raw_cmd_id in self._c._pending_metadata_cmdids + ): + return raw_cmd_id + swapped = ((raw_cmd_id & 0xFF) << 8) | ((raw_cmd_id >> 8) & 0xFF) + if ( + swapped in self._c._pending_get_devices_cmdids + or swapped in self._c._pending_metadata_cmdids + ): + return swapped + return raw_cmd_id + + markerless_cmd_id = ( + (frame[0] & 0xFF) | ((frame[1] & 0xFF) << 8) + if len(frame) >= 3 + else None + ) + markerless_cmd_id = _resolve_pending_cmd_id(markerless_cmd_id) + pending_cmd_ids = set(self._c._pending_get_devices_cmdids) | set(self._c._pending_metadata_cmdids) + is_markerless_command_frame = ( + markerless_cmd_id is not None + and len(frame) >= 3 + and (frame[2] & 0xFF) in response_types + and markerless_cmd_id in pending_cmd_ids + ) + is_standard_command_frame = event_type == 0x02 + is_command_frame = is_standard_command_frame or is_markerless_command_frame + if is_command_frame: + self._c._cmd_correlation_stats["ids_command_candidates_seen"] = ( + self._c._cmd_correlation_stats.get("ids_command_candidates_seen", 0) + 1 + ) + _LOGGER.warning( + "PACKET RX ETH cmd-candidate standard=%s markerless=%s pending_get=%d pending_meta=%d raw=%s", + is_standard_command_frame, + is_markerless_command_frame, + len(self._c._pending_get_devices_cmdids), + len(self._c._pending_metadata_cmdids), + frame.hex(), + ) + elif event_type == 0x02 and len(frame) >= 4: + self._c._cmd_correlation_stats["ids_command_candidates_unmatched"] = ( + self._c._cmd_correlation_stats.get("ids_command_candidates_unmatched", 0) + 1 + ) + _LOGGER.warning("PACKET RX ETH unmatched evt=0x02 frame=%s", frame.hex()) + + family = self._c._classify_frame_family(frame) + self._c._frame_family_stats[family] = self._c._frame_family_stats.get(family, 0) + 1 + + if not is_command_frame: + # Keep old behavior: event 0x02 frames that are not explicit command + # responses are ignored on Ethernet. + return event_type == 0x02 + + cmd_id: int + response_type: int | None + command_frame = frame + if is_standard_command_frame: + cmd_id = (frame[1] & 0xFF) | ((frame[2] & 0xFF) << 8) + cmd_id = _resolve_pending_cmd_id(cmd_id) or cmd_id + response_type = frame[3] & 0xFF if len(frame) >= 4 else None + else: + cmd_id = markerless_cmd_id if markerless_cmd_id is not None else 0 + response_type = frame[2] & 0xFF + command_frame = bytes([frame[0], frame[1], 0x02, frame[2]]) + frame[3:] + + if response_type is None: + return True + + _LOGGER.warning( + "PACKET RX ETH cmd_id=0x%04X response_type=0x%02X frame=%s", + cmd_id & 0xFFFF, + response_type & 0xFF, + command_frame.hex(), + ) + + if response_type == 0x81: + completed_get_devices_table = self._c._pending_get_devices_cmdids.pop(cmd_id, None) + self._c._pending_get_devices_sent_at.pop(cmd_id, None) + if completed_get_devices_table is not None: + self._c._cmd_correlation_stats["get_devices_completed"] += 1 + self._c._get_devices_loaded_tables.add(completed_get_devices_table) + _LOGGER.warning( + "PACKET GetDevices completed cmd_id=0x%04X table=%d", + cmd_id & 0xFFFF, + completed_get_devices_table, + ) + if ( + self._c._supports_metadata_requests + and completed_get_devices_table not in self._c._metadata_loaded_tables + and completed_get_devices_table not in self._c._metadata_requested_tables + ): + self._c.hass.async_create_task( + self._c._send_metadata_request(completed_get_devices_table) + ) + return True + + completed_table = self._c._pending_metadata_cmdids.pop(cmd_id, None) + self._c._pending_metadata_sent_at.pop(cmd_id, None) + if completed_table is not None and len(command_frame) >= 8: + response_crc = int.from_bytes(command_frame[4:8], "big") + response_count = command_frame[8] & 0xFF if len(command_frame) >= 9 else None + staged_entries = self._c._pending_metadata_entries.pop(cmd_id, {}) + staged_count = len(staged_entries) + expected_crc = ( + self._c.gateway_info.device_metadata_table_crc + if self._c.gateway_info is not None + else 0 + ) + if expected_crc != 0 and response_crc != expected_crc: + self._c._cmd_correlation_stats["metadata_commit_crc_mismatch"] += 1 + self._c._metadata_loaded_tables.discard(completed_table) + self._c._metadata_requested_tables.discard(completed_table) + self._c._last_metadata_crc = None + elif response_count is not None and response_count != staged_count: + self._c._cmd_correlation_stats["metadata_commit_count_mismatch"] += 1 + self._c._metadata_loaded_tables.discard(completed_table) + self._c._metadata_requested_tables.discard(completed_table) + self._c._last_metadata_crc = None + else: + for meta in staged_entries.values(): + self._c._process_metadata(meta) + self._c._metadata_loaded_tables.add(completed_table) + self._c._metadata_rejected_tables.discard(completed_table) + self._c._last_metadata_crc = response_crc + self._c._cmd_correlation_stats["metadata_commit_success"] += 1 + return True + + if response_type == 0x82: + rejected_table = self._c._pending_metadata_cmdids.pop(cmd_id, None) + self._c._pending_metadata_sent_at.pop(cmd_id, None) + self._c._pending_metadata_entries.pop(cmd_id, None) + if rejected_table is not None: + error_code = command_frame[4] & 0xFF if len(command_frame) >= 5 else -1 + if error_code == 0x0F: + self._c._metadata_rejected_tables.discard(rejected_table) + retry_count = self._c._metadata_retry_counts.get(rejected_table, 0) + 1 + self._c._metadata_retry_counts[rejected_table] = retry_count + self._c._cmd_correlation_stats["metadata_retry_scheduled"] += 1 + self._c.hass.async_create_task( + self._c._retry_metadata_after_rejection(rejected_table) + ) + else: + self._c._metadata_requested_tables.discard(rejected_table) + self._c._metadata_rejected_tables.add(rejected_table) + else: + gd_table = self._c._pending_get_devices_cmdids.pop(cmd_id, None) + self._c._pending_get_devices_sent_at.pop(cmd_id, None) + if gd_table is not None: + self._c._cmd_correlation_stats["get_devices_rejected"] += 1 + self._c._get_devices_loaded_tables.discard(gd_table) + else: + self._c._cmd_correlation_stats["command_error_unknown"] += 1 + self._c._bump_unknown_cmd_count(cmd_id) + return True + + if response_type == 0x01 and len(frame) >= 3: + identities = parse_get_devices_response(command_frame) + if identities: + self._c._cmd_correlation_stats["get_devices_identity_rows"] += len(identities) + _LOGGER.warning( + "PACKET GetDevices identities parsed rows=%d cmd_id=0x%04X", + len(identities), + cmd_id & 0xFFFF, + ) + for identity in identities: + key = f"{identity.table_id:02x}:{identity.device_id:02x}" + self._c._device_identities[key] = identity + self._c._apply_external_name(key, identity) + + if cmd_id in self._c._pending_get_devices_cmdids: + return True + + # IDS-CAN Ethernet gateways may rewrite/omit client cmdId values + # in command responses. If payload decodes as valid identity rows, + # trust the payload and advance pending state using table-id evidence. + self._c._cmd_correlation_stats["get_devices_identity_rows_fallback"] = ( + self._c._cmd_correlation_stats.get("get_devices_identity_rows_fallback", 0) + + len(identities) + ) + inferred_table_id = identities[0].table_id & 0xFF + matched_cmd_id = next( + ( + pending_cmd_id + for pending_cmd_id, pending_table_id in self._c._pending_get_devices_cmdids.items() + if pending_table_id == inferred_table_id + ), + None, + ) + if matched_cmd_id is None and len(self._c._pending_get_devices_cmdids) == 1: + matched_cmd_id = next(iter(self._c._pending_get_devices_cmdids)) + + if matched_cmd_id is not None: + self._c._pending_get_devices_cmdids.pop(matched_cmd_id, None) + self._c._pending_get_devices_sent_at.pop(matched_cmd_id, None) + self._c._get_devices_loaded_tables.add(inferred_table_id) + self._c._cmd_correlation_stats["get_devices_completed_fallback"] = ( + self._c._cmd_correlation_stats.get("get_devices_completed_fallback", 0) + + 1 + ) + _LOGGER.warning( + "PACKET GetDevices fallback completion inferred_table=%d matched_pending_cmd_id=0x%04X", + inferred_table_id, + matched_cmd_id & 0xFFFF, + ) + return True + self._c._cmd_correlation_stats["get_devices_identity_parse_empty"] = ( + self._c._cmd_correlation_stats.get("get_devices_identity_parse_empty", 0) + 1 + ) + _LOGGER.warning( + "PACKET GetDevices identity parse empty cmd_id=0x%04X len=%d frame=%s", + cmd_id & 0xFFFF, + len(command_frame), + command_frame.hex(), + ) + + if cmd_id not in self._c._pending_metadata_cmdids: + self._c._cmd_correlation_stats["metadata_success_multi_discarded_unknown"] += 1 + self._c._bump_unknown_cmd_count(cmd_id) + return True + + self._c._cmd_correlation_stats["metadata_success_multi_accepted"] += 1 + staged = self._c._pending_metadata_entries.setdefault(cmd_id, {}) + added = 0 + try: + parsed_metadata = parse_metadata_response(command_frame) + except Exception: + self._c._cmd_correlation_stats["metadata_parse_errors"] += 1 + return True + + for meta in parsed_metadata: + key = f"{meta.table_id:02x}:{meta.device_id:02x}" + if key not in staged: + added += 1 + staged[key] = meta + if added: + self._c._cmd_correlation_stats["metadata_entries_staged"] += added + return True + + return True diff --git a/custom_components/ha_onecontrol/runtime/myrvlink_runtime.py b/custom_components/ha_onecontrol/runtime/myrvlink_runtime.py new file mode 100644 index 0000000..918cf9a --- /dev/null +++ b/custom_components/ha_onecontrol/runtime/myrvlink_runtime.py @@ -0,0 +1,576 @@ +"""MyRvLink/BLE runtime orchestration. + +This runtime owns BLE connect/auth retry sequencing while preserving the +coordinator's public API and existing parsing/state semantics. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from typing import TYPE_CHECKING + +from .ids_can_runtime import IdsCanRuntime +from ..ble_agent import remove_bond +from ..protocol.function_names import get_friendly_name +from ..protocol.events import ( + CoverStatus, + DeviceLock, + GatewayInformation, + DeviceMetadata, + DeviceOnline, + DimmableLight, + GeneratorStatus, + HourMeter, + RelayStatus, + RgbLight, + TankLevel, + parse_get_devices_response, + parse_metadata_response, +) + +if TYPE_CHECKING: + from ..coordinator import OneControlCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class MyRvLinkRuntime: + """Runtime for BLE/MyRvLink connection orchestration.""" + + def __init__(self, coordinator: OneControlCoordinator, ids_runtime: IdsCanRuntime) -> None: + self._c = coordinator + self._ids_runtime = ids_runtime + + async def send_metadata_request(self, table_id: int) -> None: + """Send GetDevicesMetadata for a single table ID.""" + if not self._c._supports_metadata_requests: + return + cmd = self._c._cmd.build_get_devices_metadata(table_id) + cmd_id = int.from_bytes(cmd[0:2], "little") + self.record_pending_metadata_cmd(cmd_id, table_id, max_pending=128) + self._c._pending_metadata_entries.pop(cmd_id, None) + self._c._metadata_rejected_tables.discard(table_id) + self._c._metadata_requested_tables.add(table_id) + try: + await self._c.async_send_command(cmd) + _LOGGER.info("Sent GetDevicesMetadata for table %d (cmdId=%d)", table_id, cmd_id) + except Exception as exc: + self._c._pending_metadata_cmdids.pop(cmd_id, None) + self._c._pending_metadata_sent_at.pop(cmd_id, None) + self._c._pending_metadata_entries.pop(cmd_id, None) + self._c._metadata_requested_tables.discard(table_id) + _LOGGER.warning("Failed to send metadata request: %s", exc) + + async def retry_metadata_after_rejection(self, table_id: int) -> None: + """Retry GetDevicesMetadata 10s after a rejection.""" + await asyncio.sleep(10.0) + if not self._c._supports_metadata_requests: + return + if not self._c._connected: + return + if table_id in self._c._metadata_loaded_tables: + return + _LOGGER.debug("Retrying metadata for table_id=%d after 0x0f rejection", table_id) + self._c._metadata_requested_tables.discard(table_id) + if table_id not in self._c._get_devices_loaded_tables: + self._c._cmd_correlation_stats["metadata_waiting_get_devices"] += 1 + _LOGGER.debug( + "Retry for metadata table %d deferred - waiting for GetDevices completion", + table_id, + ) + return + await self.send_metadata_request(table_id) + + async def send_initial_get_devices(self) -> None: + """Delay then send GetDevices wake-up command.""" + await asyncio.sleep(0.5) + await self.do_send_initial_get_devices() + + async def do_send_initial_get_devices(self) -> None: + """Send initial GetDevices if connection/auth state allows.""" + if self._c._initial_get_devices_sent: + return + if not self._c._connected or not self._c._authenticated: + return + table_id = self._c._select_get_devices_table_id() + if table_id is None: + return + try: + cmd = self._c._cmd.build_get_devices(table_id) + cmd_id = int.from_bytes(cmd[0:2], "little") + self.record_pending_get_devices_cmd(cmd_id, table_id, max_pending=128) + await self._c.async_send_command(cmd) + self._c._initial_get_devices_sent = True + _LOGGER.debug( + "Initial GetDevices sent for table %d (cmdId=%d)", + table_id, + cmd_id, + ) + except Exception as exc: # noqa: BLE001 + _LOGGER.warning("Initial GetDevices failed: %s", exc) + + async def request_metadata_after_delay(self, table_id: int) -> None: + """Wait then request metadata, gated by completion/rejection state.""" + if not self._c._supports_metadata_requests: + return + await asyncio.sleep(1.5) + if table_id in self._c._metadata_loaded_tables: + return + if table_id in self._c._metadata_rejected_tables: + return + if table_id in self._c._metadata_requested_tables: + return + if table_id not in self._c._get_devices_loaded_tables: + self._c._cmd_correlation_stats["metadata_waiting_get_devices"] += 1 + _LOGGER.debug( + "Metadata request deferred for table %d - waiting for GetDevices completion", + table_id, + ) + return + await self.send_metadata_request(table_id) + + def ensure_metadata_for_table(self, table_id: int) -> None: + """Schedule metadata request for newly observed table IDs.""" + if not self._c._supports_metadata_requests: + return + if table_id == 0: + return + if ( + table_id in self._c._metadata_loaded_tables + or table_id in self._c._metadata_rejected_tables + or table_id in self._c._metadata_requested_tables + ): + return + if table_id not in self._c._get_devices_loaded_tables: + self._c._cmd_correlation_stats["metadata_waiting_get_devices"] += 1 + _LOGGER.debug( + "Observed table_id=%d but delaying metadata until GetDevices completes", + table_id, + ) + return + _LOGGER.info("Requesting metadata for observed table_id=%d", table_id) + self._c.hass.async_create_task(self.send_metadata_request(table_id)) + + def handle_gateway_information(self, event: GatewayInformation) -> None: + """Apply GatewayInformation and manage metadata CRC/scheduling behavior.""" + _LOGGER.debug( + "GatewayInfo: table_id=%d, devices=%d, table_crc=0x%08x, metadata_crc=0x%08x", + event.table_id, + event.device_count, + event.device_table_crc, + event.device_metadata_table_crc, + ) + + crc = event.device_metadata_table_crc + if crc != 0 and crc == self._c._last_metadata_crc: + self._c._metadata_loaded_tables.add(event.table_id) + _LOGGER.debug( + "Metadata CRC unchanged (0x%08x), skipping re-request for table %d", + crc, + event.table_id, + ) + elif ( + self._c._last_metadata_crc is not None + and crc != self._c._last_metadata_crc + and event.table_id in self._c._metadata_loaded_tables + ): + _LOGGER.info( + "Metadata CRC changed (0x%08x -> 0x%08x), invalidating table %d", + self._c._last_metadata_crc, + crc, + event.table_id, + ) + self._c._last_metadata_crc = None + prefix = f"{event.table_id:02x}:" + for key in list(self._c._metadata_raw): + if key.startswith(prefix): + del self._c._metadata_raw[key] + self._c.device_names.pop(key, None) + self._c._metadata_requested_tables.discard(event.table_id) + self._c._metadata_loaded_tables.discard(event.table_id) + self._c._metadata_rejected_tables.discard(event.table_id) + + self._c.gateway_info = event + + if not self._c._initial_get_devices_sent: + self._c.hass.async_create_task(self.do_send_initial_get_devices()) + + if ( + self._c._supports_metadata_requests + and event.table_id not in self._c._metadata_loaded_tables + and event.table_id not in self._c._metadata_requested_tables + ): + if event.table_id in self._c._get_devices_loaded_tables: + self._c.hass.async_create_task(self.request_metadata_after_delay(event.table_id)) + else: + self._c._cmd_correlation_stats["metadata_waiting_get_devices"] += 1 + _LOGGER.debug( + "GatewayInfo table %d waiting for GetDevices completion before metadata request", + event.table_id, + ) + + def process_metadata(self, meta: DeviceMetadata) -> None: + """Store metadata rows and resolve canonical friendly names.""" + key = f"{meta.table_id:02x}:{meta.device_id:02x}" + self._c._metadata_raw[key] = meta + name = get_friendly_name(meta.function_name, meta.function_instance) + self._c.device_names[key] = name + self._c._metadata_loaded_tables.add(meta.table_id) + if ( + self._c.gateway_info is not None + and meta.table_id == self._c.gateway_info.table_id + and self._c.gateway_info.device_metadata_table_crc != 0 + ): + self._c._last_metadata_crc = self._c.gateway_info.device_metadata_table_crc + _LOGGER.info( + "Metadata: %s -> func=%d inst=%d -> %s", + key.upper(), + meta.function_name, + meta.function_instance, + name, + ) + + def reset_protocol_tracking_state(self) -> None: + """Clear protocol-level command/metadata tracking on disconnect.""" + self._c._metadata_requested_tables.clear() + self._c._metadata_loaded_tables.clear() + self._c._metadata_rejected_tables.clear() + self._c._metadata_retry_counts.clear() + self._c._pending_metadata_cmdids.clear() + self._c._pending_metadata_sent_at.clear() + self._c._pending_metadata_entries.clear() + self._c._pending_get_devices_cmdids.clear() + self._c._pending_get_devices_sent_at.clear() + self._c._get_devices_loaded_tables.clear() + self._c._unknown_command_counts.clear() + self._c._initial_get_devices_sent = False + + def record_pending_get_devices_cmd(self, cmd_id: int, table_id: int, max_pending: int) -> None: + """Track in-flight GetDevices command IDs with bounded retention.""" + now = time.monotonic() + self._c._pending_get_devices_cmdids[cmd_id] = table_id + self._c._pending_get_devices_sent_at[cmd_id] = now + while len(self._c._pending_get_devices_cmdids) > max_pending: + oldest_cmd_id = next(iter(self._c._pending_get_devices_cmdids)) + self._c._pending_get_devices_cmdids.pop(oldest_cmd_id, None) + self._c._pending_get_devices_sent_at.pop(oldest_cmd_id, None) + self._c._cmd_correlation_stats["pending_cmdid_pruned"] += 1 + self._c._cmd_correlation_stats["pending_get_devices_peak"] = max( + self._c._cmd_correlation_stats["pending_get_devices_peak"], + len(self._c._pending_get_devices_cmdids), + ) + + def record_pending_metadata_cmd(self, cmd_id: int, table_id: int, max_pending: int) -> None: + """Track in-flight metadata command IDs with bounded retention.""" + now = time.monotonic() + self._c._pending_metadata_cmdids[cmd_id] = table_id + self._c._pending_metadata_sent_at[cmd_id] = now + while len(self._c._pending_metadata_cmdids) > max_pending: + oldest_cmd_id = next(iter(self._c._pending_metadata_cmdids)) + self._c._pending_metadata_cmdids.pop(oldest_cmd_id, None) + self._c._pending_metadata_sent_at.pop(oldest_cmd_id, None) + self._c._pending_metadata_entries.pop(oldest_cmd_id, None) + self._c._cmd_correlation_stats["pending_cmdid_pruned"] += 1 + + def prune_pending_command_state(self, stale_timeout_s: float) -> None: + """Drop stale pending cmdIds so late/missing responses do not accumulate.""" + cutoff = time.monotonic() - stale_timeout_s + + stale_get_devices = [ + cmd_id + for cmd_id, sent_at in self._c._pending_get_devices_sent_at.items() + if sent_at < cutoff + ] + for cmd_id in stale_get_devices: + self._c._pending_get_devices_sent_at.pop(cmd_id, None) + self._c._pending_get_devices_cmdids.pop(cmd_id, None) + self._c._cmd_correlation_stats["pending_cmdid_pruned"] += 1 + + stale_metadata = [ + cmd_id + for cmd_id, sent_at in self._c._pending_metadata_sent_at.items() + if sent_at < cutoff + ] + for cmd_id in stale_metadata: + self._c._pending_metadata_sent_at.pop(cmd_id, None) + self._c._pending_metadata_cmdids.pop(cmd_id, None) + self._c._pending_metadata_entries.pop(cmd_id, None) + self._c._cmd_correlation_stats["pending_cmdid_pruned"] += 1 + + def bump_unknown_cmd_count(self, cmd_id: int, max_unknown: int) -> int: + """Increment unknown cmdId counter and bound map size.""" + count = self._c._unknown_command_counts.get(cmd_id, 0) + 1 + self._c._unknown_command_counts[cmd_id] = count + while len(self._c._unknown_command_counts) > max_unknown: + self._c._unknown_command_counts.pop(next(iter(self._c._unknown_command_counts))) + self._c._cmd_correlation_stats["unknown_cmdids_pruned"] += 1 + return count + + def handle_metadata_for_event(self, event: object) -> None: + """Route metadata processing/requests for event payload types.""" + if isinstance(event, list): + for item in event: + self.handle_metadata_for_event(item) + return + + if isinstance(event, DeviceMetadata): + self.process_metadata(event) + return + + if isinstance( + event, + ( + RelayStatus, + DimmableLight, + RgbLight, + CoverStatus, + TankLevel, + DeviceOnline, + DeviceLock, + GeneratorStatus, + HourMeter, + ), + ): + self.ensure_metadata_for_table(event.table_id) + + def handle_command_frame(self, frame: bytes) -> bool: + """Handle standard MyRvLink command envelopes. + + Returns True when the frame is consumed and should not be parsed as a + regular state/event payload. + """ + if not frame or frame[0] != 0x02: + return False + + if len(frame) < 4: + return True + + def _resolve_pending_cmd_id(raw_cmd_id: int) -> int: + if ( + raw_cmd_id in self._c._pending_get_devices_cmdids + or raw_cmd_id in self._c._pending_metadata_cmdids + ): + return raw_cmd_id + swapped = ((raw_cmd_id & 0xFF) << 8) | ((raw_cmd_id >> 8) & 0xFF) + if ( + swapped in self._c._pending_get_devices_cmdids + or swapped in self._c._pending_metadata_cmdids + ): + return swapped + return raw_cmd_id + + cmd_id = (frame[1] & 0xFF) | ((frame[2] & 0xFF) << 8) + cmd_id = _resolve_pending_cmd_id(cmd_id) + response_type = frame[3] & 0xFF + + if response_type == 0x81: + completed_get_devices_table = self._c._pending_get_devices_cmdids.pop(cmd_id, None) + self._c._pending_get_devices_sent_at.pop(cmd_id, None) + if completed_get_devices_table is not None: + self._c._cmd_correlation_stats["get_devices_completed"] += 1 + self._c._get_devices_loaded_tables.add(completed_get_devices_table) + if ( + self._c._supports_metadata_requests + and completed_get_devices_table not in self._c._metadata_loaded_tables + and completed_get_devices_table not in self._c._metadata_requested_tables + ): + self._c.hass.async_create_task( + self.send_metadata_request(completed_get_devices_table) + ) + return True + + completed_table = self._c._pending_metadata_cmdids.pop(cmd_id, None) + self._c._pending_metadata_sent_at.pop(cmd_id, None) + if completed_table is not None and len(frame) >= 8: + response_crc = int.from_bytes(frame[4:8], "big") + response_count = frame[8] & 0xFF if len(frame) >= 9 else None + staged_entries = self._c._pending_metadata_entries.pop(cmd_id, {}) + staged_count = len(staged_entries) + expected_crc = ( + self._c.gateway_info.device_metadata_table_crc + if self._c.gateway_info is not None + else 0 + ) + if expected_crc != 0 and response_crc != expected_crc: + self._c._cmd_correlation_stats["metadata_commit_crc_mismatch"] += 1 + self._c._metadata_loaded_tables.discard(completed_table) + self._c._metadata_requested_tables.discard(completed_table) + self._c._last_metadata_crc = None + elif response_count is not None and response_count != staged_count: + self._c._cmd_correlation_stats["metadata_commit_count_mismatch"] += 1 + self._c._metadata_loaded_tables.discard(completed_table) + self._c._metadata_requested_tables.discard(completed_table) + self._c._last_metadata_crc = None + else: + for meta in staged_entries.values(): + self._c._process_metadata(meta) + self._c._metadata_loaded_tables.add(completed_table) + self._c._metadata_rejected_tables.discard(completed_table) + self._c._last_metadata_crc = response_crc + self._c._cmd_correlation_stats["metadata_commit_success"] += 1 + return True + + if response_type == 0x82: + rejected_table = self._c._pending_metadata_cmdids.pop(cmd_id, None) + self._c._pending_metadata_sent_at.pop(cmd_id, None) + self._c._pending_metadata_entries.pop(cmd_id, None) + if rejected_table is not None: + error_code = frame[4] & 0xFF if len(frame) >= 5 else -1 + if error_code == 0x0F: + self._c._metadata_rejected_tables.discard(rejected_table) + retry_count = self._c._metadata_retry_counts.get(rejected_table, 0) + 1 + self._c._metadata_retry_counts[rejected_table] = retry_count + self._c._cmd_correlation_stats["metadata_retry_scheduled"] += 1 + self._c.hass.async_create_task( + self.retry_metadata_after_rejection(rejected_table) + ) + else: + self._c._metadata_requested_tables.discard(rejected_table) + self._c._metadata_rejected_tables.add(rejected_table) + else: + gd_table = self._c._pending_get_devices_cmdids.pop(cmd_id, None) + self._c._pending_get_devices_sent_at.pop(cmd_id, None) + if gd_table is not None: + self._c._cmd_correlation_stats["get_devices_rejected"] += 1 + self._c._get_devices_loaded_tables.discard(gd_table) + else: + self._c._cmd_correlation_stats["command_error_unknown"] += 1 + self.bump_unknown_cmd_count(cmd_id, max_unknown=512) + return True + + if response_type == 0x01: + if cmd_id in self._c._pending_get_devices_cmdids: + identities = parse_get_devices_response(frame) + if identities: + self._c._cmd_correlation_stats["get_devices_identity_rows"] += len(identities) + for identity in identities: + key = f"{identity.table_id:02x}:{identity.device_id:02x}" + self._c._device_identities[key] = identity + self._c._apply_external_name(key, identity) + return True + + if cmd_id not in self._c._pending_metadata_cmdids: + self._c._cmd_correlation_stats["metadata_success_multi_discarded_unknown"] += 1 + self.bump_unknown_cmd_count(cmd_id, max_unknown=512) + return True + + self._c._cmd_correlation_stats["metadata_success_multi_accepted"] += 1 + staged = self._c._pending_metadata_entries.setdefault(cmd_id, {}) + added = 0 + try: + parsed_metadata = parse_metadata_response(frame) + except Exception: + self._c._cmd_correlation_stats["metadata_parse_errors"] += 1 + return True + + for meta in parsed_metadata: + key = f"{meta.table_id:02x}:{meta.device_id:02x}" + if key not in staged: + added += 1 + staged[key] = meta + if added: + self._c._cmd_correlation_stats["metadata_entries_staged"] += added + return True + + return True + + async def connect(self) -> None: + """Internal connect routine with retry logic.""" + if self._c.is_ethernet_gateway: + await self._ids_runtime.connect() + return + + max_attempts = 3 + last_exc: Exception | None = None + for attempt in range(1, max_attempts + 1): + try: + await self._c._try_connect(attempt) + return + except Exception as exc: + last_exc = exc + _LOGGER.warning( + "Connection attempt %d/%d failed: %s", + attempt, + max_attempts, + exc, + ) + if self._c._client: + try: + await self._c._client.disconnect() + except Exception: + pass + self._c._client = None + self._c._connected = False + self._c._authenticated = False + + if attempt < max_attempts: + delay = 3 * attempt + _LOGGER.info("Retrying in %ds...", delay) + await asyncio.sleep(delay) + + assert last_exc is not None + + if self._c.is_pin_gateway and self._c._pin_already_bonded: + _LOGGER.warning( + "PIN gateway %s: BlueZ bond present but all connection attempts failed " + "- removing stale bond and retrying with fresh PIN pairing", + self._c.address, + ) + removed = await remove_bond(self._c.address) + if removed: + _LOGGER.info( + "Stale bond removed for %s - attempting fresh PIN pairing", + self._c.address, + ) + self._c._pin_dbus_succeeded = False + self._c._pin_already_bonded = False + try: + await self._c._try_connect(max_attempts + 1) + return + except Exception as stale_exc: + last_exc = stale_exc + _LOGGER.warning( + "Re-pair attempt after stale bond removal failed for %s: %s", + self._c.address, + stale_exc, + ) + + if self._c.is_pin_gateway and not self._c._pin_dbus_succeeded: + _LOGGER.warning( + "PIN gateway %s: D-Bus bonding did not succeed - skipping " + "direct adapter fallback. Ensure the gateway PIN is correct " + "and the device is powered on and in pairing mode.", + self._c.address, + ) + raise last_exc + + _LOGGER.warning( + "All %d HA-routed connection attempts failed for %s; " + "trying direct HCI adapter fallback", + max_attempts, + self._c.address, + ) + for adapter in ("hci0", "hci1", "hci2", "hci3"): + _LOGGER.info("Direct BLE connect to %s via %s", self._c.address, adapter) + try: + await self._c._try_connect_direct(adapter) + _LOGGER.info( + "Direct connect succeeded via %s for %s", + adapter, + self._c.address, + ) + return + except Exception as exc: + _LOGGER.debug("Direct connect via %s failed: %s", adapter, exc) + if self._c._client: + try: + await self._c._client.disconnect() + except Exception: + pass + self._c._client = None + self._c._connected = False + self._c._authenticated = False + + raise last_exc diff --git a/custom_components/ha_onecontrol/strings.json b/custom_components/ha_onecontrol/strings.json index f498e71..be26665 100644 --- a/custom_components/ha_onecontrol/strings.json +++ b/custom_components/ha_onecontrol/strings.json @@ -57,5 +57,22 @@ "already_configured": "This gateway is already configured", "no_devices_found": "No OneControl gateways found. Make sure Bluetooth is enabled and the gateway is powered on." } + }, + "options": { + "step": { + "init": { + "title": "External Device Naming", + "description": "Optional: provide OneControl manifest/snapshot JSON content (paste from diagnostics export) or local file paths to improve naming when gateway metadata is unavailable. Pasted JSON takes priority over path input.", + "data": { + "naming_manifest_path": "Manifest JSON path", + "naming_snapshot_path": "Snapshot JSON path", + "naming_manifest_json": "Manifest JSON content", + "naming_snapshot_json": "Snapshot JSON content" + } + } + }, + "error": { + "invalid_naming_file": "Unable to read or parse the supplied manifest/snapshot file" + } } } diff --git a/custom_components/ha_onecontrol/translations/en.json b/custom_components/ha_onecontrol/translations/en.json index f498e71..be26665 100644 --- a/custom_components/ha_onecontrol/translations/en.json +++ b/custom_components/ha_onecontrol/translations/en.json @@ -57,5 +57,22 @@ "already_configured": "This gateway is already configured", "no_devices_found": "No OneControl gateways found. Make sure Bluetooth is enabled and the gateway is powered on." } + }, + "options": { + "step": { + "init": { + "title": "External Device Naming", + "description": "Optional: provide OneControl manifest/snapshot JSON content (paste from diagnostics export) or local file paths to improve naming when gateway metadata is unavailable. Pasted JSON takes priority over path input.", + "data": { + "naming_manifest_path": "Manifest JSON path", + "naming_snapshot_path": "Snapshot JSON path", + "naming_manifest_json": "Manifest JSON content", + "naming_snapshot_json": "Snapshot JSON content" + } + } + }, + "error": { + "invalid_naming_file": "Unable to read or parse the supplied manifest/snapshot file" + } } } diff --git a/tests/conftest.py b/tests/conftest.py index ce859ad..835d248 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -36,6 +36,8 @@ def __repr__(self) -> str: "homeassistant.components.sensor", "voluptuous", "bleak", + "bleak.backends", + "bleak.backends.characteristic", "bleak.exc", ] @@ -44,3 +46,40 @@ def __repr__(self) -> str: importlib.import_module(_name) except Exception: # noqa: BLE001 sys.modules.setdefault(_name, _StubModule(name=_name)) + + +# Provide minimal concrete symbols required by coordinator imports when +# Home Assistant is not installed and modules are stubbed. +_ha_core = sys.modules.get("homeassistant.core") +if isinstance(_ha_core, _StubModule): + class _HomeAssistant: # pragma: no cover - test shim + pass + + def _callback(func): # pragma: no cover - test shim + return func + + _ha_core.HomeAssistant = _HomeAssistant + _ha_core.callback = _callback + +_ha_cfg = sys.modules.get("homeassistant.config_entries") +if isinstance(_ha_cfg, _StubModule): + class _ConfigEntry: # pragma: no cover - test shim + pass + + _ha_cfg.ConfigEntry = _ConfigEntry + +_ha_uc = sys.modules.get("homeassistant.helpers.update_coordinator") +if isinstance(_ha_uc, _StubModule): + class _DataUpdateCoordinator: # pragma: no cover - test shim + def __init__(self, hass, logger, name=None, update_interval=None, always_update=False): + self.hass = hass + self.logger = logger + self.name = name + self.update_interval = update_interval + self.always_update = always_update + self.data = {} + + def async_set_updated_data(self, data): + self.data = data + + _ha_uc.DataUpdateCoordinator = _DataUpdateCoordinator diff --git a/tests/test_config_flow_ethernet.py b/tests/test_config_flow_ethernet.py index ee1406b..46b9262 100644 --- a/tests/test_config_flow_ethernet.py +++ b/tests/test_config_flow_ethernet.py @@ -3,8 +3,12 @@ from __future__ import annotations import asyncio +from types import SimpleNamespace -from custom_components.ha_onecontrol.config_flow import OneControlConfigFlow +from custom_components.ha_onecontrol.config_flow import ( + OneControlConfigFlow, + OneControlOptionsFlow, +) class _DummyWriter: @@ -50,3 +54,12 @@ async def _fake_open_connection(host: str, port: int): ok = asyncio.run(flow._async_can_connect_ethernet("192.168.1.1", 6969)) assert ok is False + + +def test_options_flow_init_stores_entry_on_private_attr() -> None: + """Options flow init should not write to read-only base config_entry property.""" + entry = SimpleNamespace(options={}) + + flow = OneControlOptionsFlow(entry) # type: ignore[arg-type] + + assert flow._config_entry is entry diff --git a/tests/test_coordinator_ethernet_heartbeat.py b/tests/test_coordinator_ethernet_heartbeat.py new file mode 100644 index 0000000..5860049 --- /dev/null +++ b/tests/test_coordinator_ethernet_heartbeat.py @@ -0,0 +1,356 @@ +"""Unit tests for Ethernet heartbeat/keepalive behavior in coordinator.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Any, cast + +from custom_components.ha_onecontrol import coordinator as coordinator_module +from custom_components.ha_onecontrol.coordinator import OneControlCoordinator +from custom_components.ha_onecontrol.runtime.ids_can_runtime import IdsCanRuntime + + +class _DummyWriter: + def __init__(self) -> None: + self.writes: list[bytes] = [] + + def write(self, data: bytes) -> None: + self.writes.append(bytes(data)) + + async def drain(self) -> None: + return + + +def _fake_eth_state() -> SimpleNamespace: + return SimpleNamespace( + is_ethernet_gateway=True, + _connected=True, + _eth_writer=_DummyWriter(), + _last_ethernet_tx_time=0.0, + _ethernet_transport_keepalives_sent=0, + ) + + +def test_send_ethernet_transport_keepalive_only_when_idle(monkeypatch) -> None: + """Transport keepalive writes delimiter only when idle threshold is met.""" + state = _fake_eth_state() + + now = 100.0 + + def _monotonic() -> float: + return now + + monkeypatch.setattr(coordinator_module.time, "monotonic", _monotonic) + + # First call should send one delimiter and bump counter. + asyncio.run(OneControlCoordinator._send_ethernet_transport_keepalive(cast(Any, state))) + assert state._eth_writer.writes == [b"\x00"] + assert state._ethernet_transport_keepalives_sent == 1 + + # Simulate a recent TX less than keepalive interval; no new write expected. + state._last_ethernet_tx_time = now - 1.0 + asyncio.run(OneControlCoordinator._send_ethernet_transport_keepalive(cast(Any, state))) + assert state._eth_writer.writes == [b"\x00"] + assert state._ethernet_transport_keepalives_sent == 1 + + +def test_heartbeat_loop_sends_transport_keepalive_without_gateway_info(monkeypatch) -> None: + """Ethernet heartbeat must keep socket alive even before gateway info is known.""" + sent: list[str] = [] + + async def _sleep(_: float) -> None: + return + + async def _send_keepalive() -> None: + sent.append("keepalive") + state._connected = False # exit loop after one keepalive cycle + + async def _force_reconnect(_: str) -> None: + raise AssertionError("Should not reconnect on healthy keepalive") + + state = SimpleNamespace( + is_ethernet_gateway=True, + _connected=True, + _authenticated=True, + gateway_info=None, + _last_event_time=0.0, + _select_get_devices_table_id=lambda: None, + _send_ethernet_transport_keepalive=_send_keepalive, + _force_ethernet_reconnect=_force_reconnect, + ) + + monkeypatch.setattr(asyncio, "sleep", _sleep) + asyncio.run(OneControlCoordinator._heartbeat_loop(cast(Any, state))) + + assert sent == ["keepalive"] + + +def test_heartbeat_loop_reconnects_when_keepalive_raises(monkeypatch) -> None: + """Ethernet heartbeat should force reconnect when transport keepalive fails.""" + reasons: list[str] = [] + + async def _sleep(_: float) -> None: + return + + async def _send_keepalive() -> None: + raise RuntimeError("socket write failed") + + async def _force_reconnect(reason: str) -> None: + reasons.append(reason) + state._connected = False + + state = SimpleNamespace( + is_ethernet_gateway=True, + _connected=True, + _authenticated=True, + gateway_info=None, + _last_event_time=0.0, + _select_get_devices_table_id=lambda: None, + _send_ethernet_transport_keepalive=_send_keepalive, + _force_ethernet_reconnect=_force_reconnect, + ) + + monkeypatch.setattr(asyncio, "sleep", _sleep) + asyncio.run(OneControlCoordinator._heartbeat_loop(cast(Any, state))) + + assert reasons == ["transport keepalive failed"] + + +def test_select_get_devices_table_id_falls_back_to_observed_tables() -> None: + """Fallback GetDevices table should come from observed TT:DD state keys.""" + state = SimpleNamespace( + gateway_info=None, + relays={"03:d6": object(), "03:da": object(), "81:d5": object()}, + dimmable_lights={"03:7d": object(), "02:cd": object()}, + rgb_lights={}, + covers={}, + hvac_zones={}, + tanks={}, + device_online={}, + device_locks={}, + generators={}, + hour_meters={}, + ) + + selected = OneControlCoordinator._select_get_devices_table_id(cast(Any, state)) + + assert selected == 0x03 + + +def test_process_frame_ignores_alt_cmdid_envelope_for_get_devices() -> None: + """Unsupported [cmdL][cmdH][0x02][resp] envelope must not be treated as command response.""" + cmd_id = 0x1234 + stats = { + "get_devices_identity_rows": 0, + "metadata_success_multi_discarded_get_devices": 0, + "metadata_success_multi_discarded_unknown": 0, + "metadata_success_multi_accepted": 0, + "metadata_entries_staged": 0, + "metadata_parse_errors": 0, + "metadata_commit_success": 0, + "metadata_commit_crc_mismatch": 0, + "metadata_commit_count_mismatch": 0, + "metadata_waiting_get_devices": 0, + "metadata_retry_scheduled": 0, + "command_error_unknown": 0, + "get_devices_rejected": 0, + "get_devices_completed": 0, + "pending_get_devices_peak": 0, + "frame_parse_errors": 0, + "pending_cmdid_pruned": 0, + "unknown_cmdids_pruned": 0, + "external_names_applied": 0, + } + state = SimpleNamespace( + is_ethernet_gateway=True, + _last_event_time=0.0, + _prune_pending_command_state=lambda: None, + _classify_frame_family=lambda _: "myrvlink_command", + _frame_family_stats={"myrvlink_command": 0}, + _cmd_correlation_stats=stats, + _pending_get_devices_cmdids={cmd_id: 0x03}, + _pending_get_devices_sent_at={cmd_id: 1.0}, + _pending_metadata_cmdids={}, + _pending_metadata_entries={}, + _unknown_command_counts={}, + _device_identities={}, + _apply_external_name=lambda *_: None, + _bump_unknown_cmd_count=lambda _: 1, + ) + state._ids_runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + # [cmdL][cmdH][0x02][0x01][table][start][count][protocol][size][payload(10)] + frame = bytes( + [ + 0x34, + 0x12, + 0x02, + 0x01, + 0x03, + 0x00, + 0x01, + 0x01, + 0x0A, + 0x14, + 0x01, + 0x00, + 0x67, + 0x00, + 0x00, + 0x00, + 0x08, + 0xE9, + 0xBC, + ] + ) + + OneControlCoordinator._process_frame(cast(Any, state), frame) + + assert stats["get_devices_identity_rows"] == 0 + assert "03:00" not in state._device_identities + + +def test_process_frame_accepts_markerless_cmdid_envelope_for_get_devices() -> None: + """Ethernet bridge can emit [cmdL][cmdH][resp] envelopes without explicit 0x02 marker.""" + cmd_id = 0x1234 + stats = { + "get_devices_identity_rows": 0, + "metadata_success_multi_discarded_get_devices": 0, + "metadata_success_multi_discarded_unknown": 0, + "metadata_success_multi_accepted": 0, + "metadata_entries_staged": 0, + "metadata_parse_errors": 0, + "metadata_commit_success": 0, + "metadata_commit_crc_mismatch": 0, + "metadata_commit_count_mismatch": 0, + "metadata_waiting_get_devices": 0, + "metadata_retry_scheduled": 0, + "command_error_unknown": 0, + "get_devices_rejected": 0, + "get_devices_completed": 0, + "pending_get_devices_peak": 0, + "frame_parse_errors": 0, + "pending_cmdid_pruned": 0, + "unknown_cmdids_pruned": 0, + "external_names_applied": 0, + } + state = SimpleNamespace( + is_ethernet_gateway=True, + _last_event_time=0.0, + _prune_pending_command_state=lambda: None, + _classify_frame_family=lambda _: "myrvlink_command", + _frame_family_stats={"myrvlink_command": 0}, + _cmd_correlation_stats=stats, + _pending_get_devices_cmdids={cmd_id: 0x03}, + _pending_get_devices_sent_at={cmd_id: 1.0}, + _pending_metadata_cmdids={}, + _pending_metadata_entries={}, + _unknown_command_counts={}, + _device_identities={}, + _apply_external_name=lambda *_: None, + _bump_unknown_cmd_count=lambda _: 1, + ) + state._ids_runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + # [cmdL][cmdH][0x01][table][start][count][protocol][size][payload(10)] + frame = bytes( + [ + 0x34, + 0x12, + 0x01, + 0x03, + 0x00, + 0x01, + 0x01, + 0x0A, + 0x14, + 0x01, + 0x00, + 0x67, + 0x00, + 0x00, + 0x00, + 0x08, + 0xE9, + 0xBC, + ] + ) + + OneControlCoordinator._process_frame(cast(Any, state), frame) + + assert stats["get_devices_identity_rows"] == 1 + assert "03:00" in state._device_identities + + +def test_process_frame_ignores_typed_markerless_cmdid_envelope_for_get_devices() -> None: + """Unsupported [cmdL][cmdH][cmdType][resp] envelope must not be treated as command response.""" + cmd_id = 0x1234 + stats = { + "get_devices_identity_rows": 0, + "metadata_success_multi_discarded_get_devices": 0, + "metadata_success_multi_discarded_unknown": 0, + "metadata_success_multi_accepted": 0, + "metadata_entries_staged": 0, + "metadata_parse_errors": 0, + "metadata_commit_success": 0, + "metadata_commit_crc_mismatch": 0, + "metadata_commit_count_mismatch": 0, + "metadata_waiting_get_devices": 0, + "metadata_retry_scheduled": 0, + "command_error_unknown": 0, + "get_devices_rejected": 0, + "get_devices_completed": 0, + "pending_get_devices_peak": 0, + "frame_parse_errors": 0, + "pending_cmdid_pruned": 0, + "unknown_cmdids_pruned": 0, + "external_names_applied": 0, + } + state = SimpleNamespace( + is_ethernet_gateway=True, + _last_event_time=0.0, + _prune_pending_command_state=lambda: None, + _classify_frame_family=lambda _: "myrvlink_command", + _frame_family_stats={"myrvlink_command": 0}, + _cmd_correlation_stats=stats, + _pending_get_devices_cmdids={cmd_id: 0x03}, + _pending_get_devices_sent_at={cmd_id: 1.0}, + _pending_metadata_cmdids={}, + _pending_metadata_entries={}, + _unknown_command_counts={}, + _device_identities={}, + _apply_external_name=lambda *_: None, + _bump_unknown_cmd_count=lambda _: 1, + ) + state._ids_runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + # [cmdL][cmdH][0x01(cmdType)][0x01(resp)][table][start][count][protocol][size][payload(10)] + frame = bytes( + [ + 0x34, + 0x12, + 0x01, + 0x01, + 0x03, + 0x00, + 0x01, + 0x01, + 0x0A, + 0x14, + 0x01, + 0x00, + 0x67, + 0x00, + 0x00, + 0x00, + 0x08, + 0xE9, + 0xBC, + ] + ) + + OneControlCoordinator._process_frame(cast(Any, state), frame) + + assert stats["get_devices_identity_rows"] == 0 + assert "03:00" not in state._device_identities diff --git a/tests/test_external_name_catalog.py b/tests/test_external_name_catalog.py new file mode 100644 index 0000000..34b043d --- /dev/null +++ b/tests/test_external_name_catalog.py @@ -0,0 +1,116 @@ +"""Tests for external naming catalog and GetDevices identity parsing.""" + +from __future__ import annotations + +import json + +from custom_components.ha_onecontrol.name_catalog import load_external_name_catalog +from custom_components.ha_onecontrol.protocol.events import parse_get_devices_response + + +def test_parse_get_devices_identity_row() -> None: + """GetDevices SuccessMulti row should decode identity fields correctly.""" + # [cmdL cmdH 0x02 0x01][table=0x03][start=0xE7][count=1] + # entry: [protocol=2][payloadSize=10] + # payload: [type=20][instance=4][productId=0x0067][mac=00 00 00 08 E9 BC] + frame = bytes.fromhex( + "34 12 02 01 03 E7 01 02 0A 14 04 00 67 00 00 00 08 E9 BC" + ) + + rows = parse_get_devices_response(frame) + assert len(rows) == 1 + row = rows[0] + assert row.table_id == 0x03 + assert row.device_id == 0xE7 + assert row.protocol == 2 + assert row.device_type == 20 + assert row.device_instance == 4 + assert row.product_id == 103 + assert row.product_mac == "00000008E9BC" + + +def test_external_name_catalog_manifest_and_snapshot(tmp_path) -> None: + """Snapshot/manifest should build a lookup keyed by stable identity fields.""" + manifest = { + "ProductList": [ + { + "UniqueID": "00:00:00:08:E9:BC", + "TypeID": 103, + "DeviceList": [ + { + "TypeID": 20, + "Instance": 4, + "FunctionName": "Kitchen Chandelier Light", + } + ], + } + ] + } + snapshot = { + "DeviceSnapshot": { + "Devices": [ + { + "Description": "Kitchen Chandelier Light (Dimmable Light, Lighting Control)", + "LogicalId": { + "DeviceType": 20, + "DeviceInstance": 4, + "ProductId": 103, + "ProductMacAddress": "00000008E9BC", + }, + } + ] + } + } + + manifest_path = tmp_path / "manifest.json" + snapshot_path = tmp_path / "snapshot.json" + manifest_path.write_text(json.dumps(manifest), encoding="utf-8") + snapshot_path.write_text(json.dumps(snapshot), encoding="utf-8") + + catalog = load_external_name_catalog(str(manifest_path), str(snapshot_path)) + assert catalog.entries == 1 + assert catalog.lookup(20, 4, 103, "00:00:00:08:E9:BC") == "Kitchen Chandelier Light" + + +def test_external_name_catalog_from_raw_json_text() -> None: + """Catalog should accept raw manifest/snapshot JSON text without file paths.""" + manifest = { + "ProductList": [ + { + "UniqueID": "00:00:00:08:E9:BC", + "TypeID": 103, + "DeviceList": [ + { + "TypeID": 20, + "Instance": 4, + "FunctionName": "Kitchen Chandelier Light", + } + ], + } + ] + } + snapshot = { + "DeviceSnapshot": { + "Devices": [ + { + "Description": "Kitchen Chandelier Light (Dimmable Light, Lighting Control)", + "LogicalId": { + "DeviceType": 20, + "DeviceInstance": 4, + "ProductId": 103, + "ProductMacAddress": "00000008E9BC", + }, + } + ] + } + } + + catalog = load_external_name_catalog( + None, + None, + json.dumps(manifest), + json.dumps(snapshot), + ) + + assert catalog.entries == 1 + assert catalog.lookup(20, 4, 103, "00:00:00:08:E9:BC") == "Kitchen Chandelier Light" diff --git a/tests/test_ids_can_runtime.py b/tests/test_ids_can_runtime.py new file mode 100644 index 0000000..ead8564 --- /dev/null +++ b/tests/test_ids_can_runtime.py @@ -0,0 +1,626 @@ +"""Runtime-focused tests for IDS-CAN command-frame handling.""" + +from __future__ import annotations + +from types import SimpleNamespace + +from custom_components.ha_onecontrol.protocol.cobs import CobsByteDecoder +from custom_components.ha_onecontrol.protocol.events import DeviceMetadata +from custom_components.ha_onecontrol.protocol.ids_can_wire import parse_ids_can_wire_frame +from custom_components.ha_onecontrol.runtime.ids_can_runtime import IdsCanRuntime + + +class _FakeHass: + def __init__(self) -> None: + self.tasks = [] + + def async_create_task(self, coro): + self.tasks.append(coro) + # These tests don't need task execution; avoid un-awaited coroutine warnings. + try: + coro.close() + except Exception: + pass + return None + + +class _FakeEthWriter: + def __init__(self) -> None: + self.writes: list[bytes] = [] + self.drains = 0 + + def write(self, data: bytes) -> None: + self.writes.append(bytes(data)) + + async def drain(self) -> None: + self.drains += 1 + + +def _base_state() -> SimpleNamespace: + stats = { + "get_devices_identity_rows": 0, + "metadata_success_multi_discarded_get_devices": 0, + "metadata_success_multi_discarded_unknown": 0, + "metadata_success_multi_accepted": 0, + "metadata_entries_staged": 0, + "metadata_parse_errors": 0, + "metadata_commit_success": 0, + "metadata_commit_crc_mismatch": 0, + "metadata_commit_count_mismatch": 0, + "metadata_waiting_get_devices": 0, + "metadata_retry_scheduled": 0, + "command_error_unknown": 0, + "get_devices_rejected": 0, + "get_devices_completed": 0, + "get_devices_completed_fallback": 0, + "get_devices_identity_rows_fallback": 0, + "pending_get_devices_peak": 0, + "frame_parse_errors": 0, + "pending_cmdid_pruned": 0, + "unknown_cmdids_pruned": 0, + "external_names_applied": 0, + } + + return SimpleNamespace( + is_ethernet_gateway=True, + _connected=True, + _eth_writer=_FakeEthWriter(), + _last_ethernet_tx_time=0.0, + _classify_frame_family=lambda _: "myrvlink_command", + _frame_family_stats={"myrvlink_command": 0}, + _pending_get_devices_cmdids={0x1234: 0x03}, + _pending_get_devices_sent_at={0x1234: 1.0}, + _pending_metadata_cmdids={}, + _pending_metadata_sent_at={}, + _pending_metadata_entries={}, + _metadata_loaded_tables=set(), + _metadata_requested_tables=set(), + _metadata_rejected_tables=set(), + _metadata_retry_counts={}, + _get_devices_loaded_tables=set(), + _cmd_correlation_stats=stats, + _unknown_command_counts={}, + _device_identities={}, + _supports_metadata_requests=False, + _process_metadata=lambda *_: None, + _apply_external_name=lambda *_: None, + _bump_unknown_cmd_count=lambda _: 1, + _last_metadata_crc=None, + gateway_info=None, + hass=_FakeHass(), + _send_metadata_request=lambda *_: None, + _retry_metadata_after_rejection=lambda *_: None, + relays={}, + dimmable_lights={}, + rgb_lights={}, + covers={}, + hvac_zones={}, + tanks={}, + generators={}, + hour_meters={}, + _select_get_devices_table_id=lambda: 0x03, + _dispatch_event_update=lambda *_: None, + ) + + +def test_ids_runtime_accepts_markerless_get_devices_successmulti() -> None: + """Markerless [cmdL cmdH resp] envelopes are accepted for pending cmdIds.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + # [cmdL][cmdH][0x01(resp)][table][start][count][protocol][size][payload(10)] + frame = bytes( + [ + 0x34, + 0x12, + 0x01, + 0x03, + 0x00, + 0x01, + 0x01, + 0x0A, + 0x14, + 0x01, + 0x00, + 0x67, + 0x00, + 0x00, + 0x00, + 0x08, + 0xE9, + 0xBC, + ] + ) + + consumed = runtime.handle_frame(frame) + + assert consumed is True + assert state._cmd_correlation_stats["get_devices_identity_rows"] == 1 + assert "03:00" in state._device_identities + + +def test_ids_runtime_ignores_unknown_event02_non_command() -> None: + """Ethernet event 0x02 frames without valid command envelope are consumed/ignored.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + # event=0x02 but no valid responseType/cmd correlation + frame = bytes([0x02, 0xAA, 0xBB, 0xCC, 0xDD]) + + consumed = runtime.handle_frame(frame) + + assert consumed is True + assert state._cmd_correlation_stats["get_devices_identity_rows"] == 0 + + +def test_ids_runtime_get_devices_successcomplete_marks_table_loaded() -> None: + """0x81 completion for pending GetDevices should mark table loaded.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + frame = bytes([0x02, 0x34, 0x12, 0x81, 0x00, 0x00, 0x00, 0x00]) + consumed = runtime.handle_frame(frame) + + assert consumed is True + assert state._cmd_correlation_stats["get_devices_completed"] == 1 + assert 0x03 in state._get_devices_loaded_tables + assert 0x1234 not in state._pending_get_devices_cmdids + + +def test_ids_runtime_get_devices_successcomplete_matches_swapped_cmdid() -> None: + """0x81 completion with BE cmdId bytes should still match pending LE cmdId.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + # Pending cmdId is 0x1234, but frame reports bytes as 12 34 (raw cmd=0x3412). + frame = bytes([0x02, 0x12, 0x34, 0x81, 0x00, 0x00, 0x00, 0x00]) + consumed = runtime.handle_frame(frame) + + assert consumed is True + assert state._cmd_correlation_stats["get_devices_completed"] == 1 + assert 0x03 in state._get_devices_loaded_tables + assert 0x1234 not in state._pending_get_devices_cmdids + + +def test_ids_runtime_get_devices_successmulti_fallbacks_when_cmdid_unmatched() -> None: + """Valid identity rows should be accepted even when gateway rewrites cmdId.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + # cmdId bytes (0x5678) do not match pending (0x1234), but payload is valid GetDevices row. + frame = bytes( + [ + 0x02, + 0x78, + 0x56, + 0x01, + 0x03, + 0x00, + 0x01, + 0x01, + 0x0A, + 0x14, + 0x01, + 0x00, + 0x67, + 0x00, + 0x00, + 0x00, + 0x08, + 0xE9, + 0xBC, + ] + ) + + consumed = runtime.handle_frame(frame) + + assert consumed is True + assert state._cmd_correlation_stats["get_devices_identity_rows"] == 1 + assert state._cmd_correlation_stats["get_devices_identity_rows_fallback"] == 1 + assert state._cmd_correlation_stats["get_devices_completed_fallback"] == 1 + assert "03:00" in state._device_identities + assert 0x03 in state._get_devices_loaded_tables + assert 0x1234 not in state._pending_get_devices_cmdids + + +def test_ids_runtime_metadata_completion_commits_on_crc_and_count_match() -> None: + """Metadata 0x81 completion commits staged entries when CRC/count match.""" + state = _base_state() + state._pending_get_devices_cmdids = {} + state._pending_get_devices_sent_at = {} + state._pending_metadata_cmdids = {0x2222: 0x03} + state._pending_metadata_sent_at = {0x2222: 1.0} + state._pending_metadata_entries = { + 0x2222: { + "03:10": DeviceMetadata(table_id=0x03, device_id=0x10, function_name=1, function_instance=1) + } + } + processed = [] + state._process_metadata = processed.append + state.gateway_info = SimpleNamespace(device_metadata_table_crc=0x11223344) + + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + frame = bytes([0x02, 0x22, 0x22, 0x81, 0x11, 0x22, 0x33, 0x44, 0x01]) + consumed = runtime.handle_frame(frame) + + assert consumed is True + assert state._cmd_correlation_stats["metadata_commit_success"] == 1 + assert len(processed) == 1 + assert 0x03 in state._metadata_loaded_tables + assert state._last_metadata_crc == 0x11223344 + + +def test_ids_runtime_metadata_completion_rejects_crc_mismatch() -> None: + """Metadata 0x81 completion with CRC mismatch should be discarded.""" + state = _base_state() + state._pending_get_devices_cmdids = {} + state._pending_get_devices_sent_at = {} + state._pending_metadata_cmdids = {0x3333: 0x03} + state._pending_metadata_sent_at = {0x3333: 1.0} + state._pending_metadata_entries = { + 0x3333: { + "03:11": DeviceMetadata(table_id=0x03, device_id=0x11, function_name=1, function_instance=1) + } + } + processed = [] + state._process_metadata = processed.append + state._metadata_loaded_tables = {0x03} + state._metadata_requested_tables = {0x03} + state.gateway_info = SimpleNamespace(device_metadata_table_crc=0xDEADBEEF) + + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + frame = bytes([0x02, 0x33, 0x33, 0x81, 0x11, 0x22, 0x33, 0x44, 0x01]) + consumed = runtime.handle_frame(frame) + + assert consumed is True + assert state._cmd_correlation_stats["metadata_commit_crc_mismatch"] == 1 + assert state._cmd_correlation_stats["metadata_commit_success"] == 0 + assert len(processed) == 0 + assert 0x03 not in state._metadata_loaded_tables + + +def test_ids_runtime_metadata_rejection_0x0f_schedules_retry() -> None: + """Metadata 0x82 with errorCode 0x0F should schedule a retry like native code.""" + state = _base_state() + state._pending_get_devices_cmdids = {} + state._pending_get_devices_sent_at = {} + state._pending_metadata_cmdids = {0x4444: 0x05} + state._pending_metadata_sent_at = {0x4444: 1.0} + state._pending_metadata_entries = {0x4444: {}} + scheduled = [] + + def _retry_metadata_after_rejection(table_id: int): + scheduled.append(table_id) + + async def _noop(): + return None + + return _noop() + + state._retry_metadata_after_rejection = _retry_metadata_after_rejection + + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + frame = bytes([0x02, 0x44, 0x44, 0x82, 0x0F]) + consumed = runtime.handle_frame(frame) + + assert consumed is True + assert state._cmd_correlation_stats["metadata_retry_scheduled"] == 1 + assert state._metadata_retry_counts.get(0x05) == 1 + assert scheduled == [0x05] + + +def test_ids_runtime_force_reconnect_closes_and_dispatches_disconnect() -> None: + """force_reconnect should close transport and notify disconnect handler.""" + state = _base_state() + close_calls: list[str] = [] + disconnect_calls: list[tuple[str, str]] = [] + + async def _close() -> None: + close_calls.append("closed") + + def _handle_disconnect(transport: str, reason: str) -> None: + disconnect_calls.append((transport, reason)) + + state._connected = True + state._handle_transport_disconnect = _handle_disconnect + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + runtime.close_transport = _close # type: ignore[method-assign] + + import asyncio + + asyncio.run(runtime.force_reconnect("stale heartbeat")) + + assert close_calls == ["closed"] + assert disconnect_calls == [("ethernet", "stale heartbeat")] + + +def test_ids_runtime_pre_gateway_heartbeat_sends_keepalive_and_get_devices() -> None: + """pre-gateway heartbeat should keep transport alive and send GetDevices when table is known.""" + state = _base_state() + state._pending_get_devices_cmdids = {} + sent: list[bytes] = [] + recorded: list[tuple[int, int]] = [] + + async def _send_keepalive(_: float) -> None: + return + + class _Cmd: + @staticmethod + def build_get_devices(table_id: int) -> bytes: + return bytes([0x34, 0x12, table_id]) + + async def _send_command(cmd: bytes) -> None: + sent.append(bytes(cmd)) + + def _record(cmd_id: int, table_id: int) -> None: + recorded.append((cmd_id, table_id)) + + state._connected = True + state.gateway_info = None + state._cmd = _Cmd() + state._select_get_devices_table_id = lambda: 0x03 + state.async_send_command = _send_command + state._record_pending_get_devices_cmd = _record + + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + runtime.send_transport_keepalive = _send_keepalive # type: ignore[method-assign] + + import asyncio + + asyncio.run(runtime.heartbeat_pre_gateway_cycle(3.0)) + + assert sent == [bytes([0x34, 0x12, 0x03])] + assert recorded == [(0x1234, 0x03)] + + +def test_ids_runtime_device_id_bootstraps_identity_and_entity_state() -> None: + """DEVICE_ID semantic frames should seed identity and default state for entity creation.""" + state = _base_state() + dispatched = [] + state._dispatch_event_update = lambda event: dispatched.append(event) + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + # NETWORK frame first, so source->product MAC is known. + network_frame = bytes.fromhex("0800cd031400000008e9cd") + assert runtime.handle_frame(network_frame) is True + + # DEVICE_ID for source 0xCD, device_type=20 (dimmable light). + # payload: product_id=0x0067 product_instance=0xCD device_type=0x14 + # function_name=0x0027 (39), dev_inst=4 fn_inst=0 caps=0xD0 + device_id_frame = bytes.fromhex("0802cd0067cd14002740d0") + assert runtime.handle_frame(device_id_frame) is True + + key = "03:cd" + assert key in state._device_identities + identity = state._device_identities[key] + assert identity.device_type == 20 + assert identity.device_instance == 4 + assert identity.product_id == 103 + assert identity.product_mac == "00000008E9CD" + assert key in state.dimmable_lights + assert len(dispatched) >= 1 + + +def test_ids_runtime_device_status_updates_bootstrapped_dimmable_state() -> None: + """DEVICE_STATUS should update coordinator light state after DEVICE_ID bootstrap.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + runtime.handle_frame(bytes.fromhex("0800cd031400000008e9cd")) + runtime.handle_frame(bytes.fromhex("0802cd0067cd14002740d0")) + + # DEVICE_STATUS for source 0xCD: status0=0x81 (on), payload[3]=0x1F brightness. + status_frame = bytes.fromhex("0603cd81ff001f0000") + assert runtime.handle_frame(status_frame) is True + + light = state.dimmable_lights.get("03:cd") + assert light is not None + assert light.mode == 1 + assert light.brightness == 0x1F + + +def test_ids_runtime_dimmable_stale_status_is_suppressed_during_settle_window() -> None: + """Contradictory DEVICE_STATUS immediately after command should be ignored.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + runtime.handle_frame(bytes.fromhex("0800cd031400000008e9cd")) + runtime.handle_frame(bytes.fromhex("0802cd0067cd14002740d0")) + + import asyncio + import time + + runtime._ids_session_opened_at[0xCD] = time.monotonic() + used = asyncio.run(runtime.send_light_toggle_command(0x03, 0xCD, True)) + assert used is True + + # Immediate stale status reports off (status0 bit0 == 0). + stale_off_status = bytes.fromhex("0603cd000000000000") + assert runtime.handle_frame(stale_off_status) is True + + light = state.dimmable_lights.get("03:cd") + assert light is not None + assert light.mode == 1 + assert light.brightness == 255 + + +def test_ids_runtime_dimmable_stale_status_is_applied_after_settle_timeout() -> None: + """Contradictory status should apply once settle window has expired.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + runtime.handle_frame(bytes.fromhex("0800cd031400000008e9cd")) + runtime.handle_frame(bytes.fromhex("0802cd0067cd14002740d0")) + + import asyncio + import time + + runtime._ids_session_opened_at[0xCD] = time.monotonic() + used = asyncio.run(runtime.send_light_toggle_command(0x03, 0xCD, True)) + assert used is True + + expectation = runtime._ids_pending_status_expectations.get((20, 0xCD)) + assert expectation is not None + desired_on, _expires_at = expectation + runtime._ids_pending_status_expectations[(20, 0xCD)] = (desired_on, time.monotonic() - 0.01) + + stale_off_status = bytes.fromhex("0603cd000000000000") + assert runtime.handle_frame(stale_off_status) is True + + light = state.dimmable_lights.get("03:cd") + assert light is not None + assert light.mode == 0 + assert light.brightness == 0 + + +def test_ids_runtime_send_light_toggle_command_writes_extended_ids_frame() -> None: + """send_light_toggle_command should emit REQUEST(0x80) then COMMAND(0x82).""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + # Seed identity map for dimmable light key used by command path. + runtime.handle_frame(bytes.fromhex("0800cd031400000008e9cd")) + runtime.handle_frame(bytes.fromhex("0802cd0067cd14002740d0")) + + import asyncio + import time + + runtime._ids_session_opened_at[0xCD] = time.monotonic() + + used = asyncio.run(runtime.send_light_toggle_command(0x03, 0xCD, True)) + assert used is True + + writer = state._eth_writer + assert writer.drains >= 1 + assert len(writer.writes) >= 1 + + decoder = CobsByteDecoder(use_crc=True) + raw = None + for byte_val in writer.writes[-1]: + raw = decoder.decode_byte(byte_val) + if raw is not None: + break + + assert raw is not None + parsed = parse_ids_can_wire_frame(raw) + assert parsed is not None + assert parsed.is_extended is True + assert parsed.message_type == 0x82 + assert parsed.source_address == 0x3A + assert parsed.target_address == 0xCD + assert parsed.message_data == 0x00 + assert parsed.payload == bytes.fromhex("01ff0000dc00dc00") + + light = state.dimmable_lights.get("03:cd") + assert light is not None + assert light.mode == 1 + assert light.brightness == 255 + + +def test_ids_runtime_send_relay_toggle_command_writes_extended_ids_frame() -> None: + """send_relay_toggle_command should emit REQUEST(0x80) then COMMAND(0x82).""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + # Seed identity map for relay key used by command path. + runtime.handle_frame(bytes.fromhex("0800da031400000008e9bc")) + runtime.handle_frame(bytes.fromhex("0802da0067da1e002250d0")) + + import asyncio + import time + + runtime._ids_session_opened_at[0xDA] = time.monotonic() + + used = asyncio.run(runtime.send_relay_toggle_command(0x03, 0xDA, True)) + assert used is True + + writer = state._eth_writer + assert writer.drains >= 1 + assert len(writer.writes) >= 1 + + decoder = CobsByteDecoder(use_crc=True) + raw = None + for byte_val in writer.writes[-1]: + raw = decoder.decode_byte(byte_val) + if raw is not None: + break + + assert raw is not None + parsed = parse_ids_can_wire_frame(raw) + assert parsed is not None + assert parsed.is_extended is True + assert parsed.message_type == 0x82 + assert parsed.source_address == 0x3A + assert parsed.target_address == 0xDA + assert parsed.message_data == 0x01 + assert parsed.payload == b"" + + relay = state.relays.get("03:da") + assert relay is not None + assert relay.is_on is True + assert relay.status_byte == 0x01 + + +def test_ids_runtime_light_toggle_uses_device_id_fallback_when_table_mismatch() -> None: + """Light toggle should still send when requested table id differs but device id/type match.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + runtime.handle_frame(bytes.fromhex("0800cd031400000008e9cd")) + runtime.handle_frame(bytes.fromhex("0802cd0067cd14002740d0")) + + import asyncio + import time + + runtime._ids_session_opened_at[0xCD] = time.monotonic() + + used = asyncio.run(runtime.send_light_toggle_command(0x81, 0xCD, True)) + assert used is True + + writer = state._eth_writer + decoder = CobsByteDecoder(use_crc=True) + raw = None + for byte_val in writer.writes[-1]: + raw = decoder.decode_byte(byte_val) + if raw is not None: + break + + assert raw is not None + parsed = parse_ids_can_wire_frame(raw) + assert parsed is not None + assert parsed.target_address == 0xCD + assert parsed.message_type == 0x82 + + +def test_ids_runtime_relay_toggle_uses_device_id_fallback_when_table_mismatch() -> None: + """Relay toggle should still send when requested table id differs but device id/type match.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + runtime.handle_frame(bytes.fromhex("0800da031400000008e9bc")) + runtime.handle_frame(bytes.fromhex("0802da0067da1e002250d0")) + + import asyncio + import time + + runtime._ids_session_opened_at[0xDA] = time.monotonic() + + used = asyncio.run(runtime.send_relay_toggle_command(0x81, 0xDA, True)) + assert used is True + + writer = state._eth_writer + decoder = CobsByteDecoder(use_crc=True) + raw = None + for byte_val in writer.writes[-1]: + raw = decoder.decode_byte(byte_val) + if raw is not None: + break + + assert raw is not None + parsed = parse_ids_can_wire_frame(raw) + assert parsed is not None + assert parsed.target_address == 0xDA + assert parsed.message_type == 0x82 diff --git a/tests/test_ids_can_wire.py b/tests/test_ids_can_wire.py new file mode 100644 index 0000000..7ddfa33 --- /dev/null +++ b/tests/test_ids_can_wire.py @@ -0,0 +1,228 @@ +"""Tests for IDS-CAN wire-frame parsing from Ethernet captures.""" + +from __future__ import annotations + +from custom_components.ha_onecontrol.protocol.ids_can_wire import ( + compose_ids_can_extended_wire_frame, + decode_ids_can_payload, + format_ids_can_payload, + ids_can_message_type_name, + ids_can_request_name, + ids_can_response_name, + parse_ids_can_wire_frame, +) + + +def test_parse_standard_can_wire_frame_from_capture() -> None: + """11-bit CAN wire frame should decode message type/source/payload.""" + frame = bytes.fromhex("0800cd031400000008e9cd") + + parsed = parse_ids_can_wire_frame(frame) + + assert parsed is not None + assert parsed.dlc == 8 + assert parsed.is_extended is False + assert parsed.can_id == 0x00CD + assert parsed.message_type == 0x00 + assert ids_can_message_type_name(parsed.message_type) == "NETWORK" + assert parsed.source_address == 0xCD + assert parsed.target_address is None + assert parsed.message_data is None + assert parsed.payload == bytes.fromhex("031400000008e9cd") + + decoded = decode_ids_can_payload(parsed) + assert decoded is not None + assert decoded.kind == "network" + assert decoded.fields["protocol_version"] == 0x14 + assert decoded.fields["mac"] == "00000008E9CD" + assert decoded.fields["in_motion_lockout_level"] == 0 + + +def test_parse_standard_device_status_wire_frame_from_capture() -> None: + """11-bit DEVICE_STATUS frame shape should decode correctly.""" + frame = bytes.fromhex("0603da80ff00200000") + + parsed = parse_ids_can_wire_frame(frame) + + assert parsed is not None + assert parsed.dlc == 6 + assert parsed.is_extended is False + assert parsed.can_id == 0x03DA + assert parsed.message_type == 0x03 + assert ids_can_message_type_name(parsed.message_type) == "DEVICE_STATUS" + assert parsed.source_address == 0xDA + assert parsed.payload == bytes.fromhex("80ff00200000") + + decoded = decode_ids_can_payload(parsed) + assert decoded is not None + assert decoded.kind == "device_status" + assert decoded.fields["status_length"] == 6 + assert decoded.fields["status_hex"] == "80ff00200000" + + +def test_parse_extended_request_wire_frame_from_capture() -> None: + """29-bit extended REQUEST frame shape should decode p2p fields.""" + frame = bytes.fromhex("0280e87511002b") + + parsed = parse_ids_can_wire_frame(frame) + + assert parsed is not None + assert parsed.dlc == 2 + assert parsed.is_extended is True + assert parsed.can_id == 0x00E87511 + assert parsed.message_type == 0x80 + assert ids_can_message_type_name(parsed.message_type) == "REQUEST" + assert parsed.source_address == 0x3A + assert parsed.target_address == 0x75 + assert parsed.message_data == 0x11 + assert parsed.payload == bytes.fromhex("002b") + + decoded = decode_ids_can_payload(parsed) + assert decoded is not None + assert decoded.kind == "request" + assert decoded.fields["request_code"] == 0x11 + assert decoded.fields["request_name"] == "PID_READ_WRITE" + + +def test_parse_extended_text_console_wire_frame_from_capture() -> None: + """29-bit TEXT_CONSOLE frame should decode as type 0x84.""" + frame = bytes.fromhex("088564002a2020202020202020") + + parsed = parse_ids_can_wire_frame(frame) + + assert parsed is not None + assert parsed.dlc == 8 + assert parsed.is_extended is True + assert parsed.can_id == 0x0564002A + assert parsed.message_type == 0x84 + assert ids_can_message_type_name(parsed.message_type) == "TEXT_CONSOLE" + assert parsed.source_address == 0x59 + assert parsed.target_address == 0x00 + assert parsed.message_data == 0x2A + assert parsed.payload == bytes.fromhex("2020202020202020") + + decoded = decode_ids_can_payload(parsed) + assert decoded is not None + assert decoded.kind == "text_console" + assert decoded.fields["text_ascii"] == " " + + +def test_parse_standard_device_id_wire_frame() -> None: + """DEVICE_ID payload bytes should decode to C#-parity fields.""" + # dlc=8, id=0x02DA (message type 0x02, source 0xDA) + # payload: product_id=0x1234, product_instance=0x56, device_type=0x78, + # function_name=0x0123, hi/lo nibble=dev_inst 0xA, fn_inst 0xB, caps=0xCD + frame = bytes.fromhex("0802da123456780123abcd") + + parsed = parse_ids_can_wire_frame(frame) + assert parsed is not None + assert parsed.message_type == 0x02 + + decoded = decode_ids_can_payload(parsed) + assert decoded is not None + assert decoded.kind == "device_id" + assert decoded.fields["product_id"] == 0x1234 + assert decoded.fields["product_instance"] == 0x56 + assert decoded.fields["device_type"] == 0x78 + assert decoded.fields["device_instance"] == 0x0A + assert decoded.fields["function_name"] == 0x0123 + assert decoded.fields["function_instance"] == 0x0B + assert decoded.fields["device_capabilities"] == 0xCD + + +def test_parse_standard_circuit_id_wire_frame() -> None: + """CIRCUIT_ID payload should decode to uint and text form.""" + frame = bytes.fromhex("0401da12345678") + + parsed = parse_ids_can_wire_frame(frame) + assert parsed is not None + assert parsed.message_type == 0x01 + + decoded = decode_ids_can_payload(parsed) + assert decoded is not None + assert decoded.kind == "circuit_id" + assert decoded.fields["circuit_id"] == 0x12345678 + assert decoded.fields["circuit_id_text"] == "12:34:56:78" + + +def test_parse_standard_product_status_wire_frame() -> None: + """PRODUCT_STATUS payload byte 0 low two bits carry update state.""" + frame = bytes.fromhex("0106da83") + + parsed = parse_ids_can_wire_frame(frame) + assert parsed is not None + assert parsed.message_type == 0x06 + + decoded = decode_ids_can_payload(parsed) + assert decoded is not None + assert decoded.kind == "product_status" + assert decoded.fields["software_update_state"] == 0x03 + + +def test_request_and_response_name_maps() -> None: + """Request/response enum names should match decompiled constants.""" + assert ids_can_request_name(0x42) == "SESSION_REQUEST_SEED" + assert ids_can_request_name(0xEE).startswith("UNKNOWN_") + assert ids_can_response_name(0x00) == "SUCCESS" + assert ids_can_response_name(0x16) == "IN_PROGRESS" + assert ids_can_response_name(0xEE).startswith("UNKNOWN_") + + +def test_format_ids_can_payload_compact_suffix() -> None: + """Formatted semantic suffix should include semantic kind and key-value fields.""" + frame = bytes.fromhex("0401da12345678") + parsed = parse_ids_can_wire_frame(frame) + assert parsed is not None + decoded = decode_ids_can_payload(parsed) + suffix = format_ids_can_payload(decoded) + + assert suffix.startswith(" semantic=circuit_id ") + assert "circuit_id=305419896" in suffix + assert "circuit_id_text=12:34:56:78" in suffix + + +def test_reject_non_wire_frame() -> None: + """MyRvLink command bytes should not be misread as IDS wire frames.""" + frame = bytes.fromhex("0000010200ff") + + parsed = parse_ids_can_wire_frame(frame) + + assert parsed is None + + +def test_compose_extended_wire_frame_round_trips() -> None: + """Composed extended frame should parse back to the same semantic fields.""" + payload = bytes.fromhex("0123") + frame = compose_ids_can_extended_wire_frame( + message_type=0x82, + source_address=0x3A, + target_address=0x75, + message_data=0x00, + payload=payload, + ) + + parsed = parse_ids_can_wire_frame(frame) + + assert parsed is not None + assert parsed.is_extended is True + assert parsed.message_type == 0x82 + assert parsed.source_address == 0x3A + assert parsed.target_address == 0x75 + assert parsed.message_data == 0x00 + assert parsed.payload == payload + + +def test_parse_extended_wire_frame_with_flagged_dlc_byte() -> None: + """Adapters may set upper DLC nibble flags; parser should use lower nibble length.""" + frame = bytes.fromhex("1880eac1000000000000000000") + + parsed = parse_ids_can_wire_frame(frame) + + assert parsed is not None + assert parsed.dlc == 8 + assert parsed.is_extended is True + assert parsed.message_type == 0x82 + assert parsed.source_address == 0x3A + assert parsed.target_address == 0xC1 + assert parsed.message_data == 0x00 + assert parsed.payload == bytes.fromhex("0000000000000000") diff --git a/tests/test_myrvlink_runtime.py b/tests/test_myrvlink_runtime.py new file mode 100644 index 0000000..e2ed5d5 --- /dev/null +++ b/tests/test_myrvlink_runtime.py @@ -0,0 +1,380 @@ +"""Runtime-focused tests for MyRvLink metadata sequencing behavior.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from custom_components.ha_onecontrol.protocol.events import DeviceMetadata +from custom_components.ha_onecontrol.protocol.events import GatewayInformation +from custom_components.ha_onecontrol.protocol.events import RelayStatus +from custom_components.ha_onecontrol.protocol.events import TankLevel +from custom_components.ha_onecontrol.runtime.ids_can_runtime import IdsCanRuntime +from custom_components.ha_onecontrol.runtime.myrvlink_runtime import MyRvLinkRuntime + + +class _DummyCmd: + def build_get_devices(self, table_id: int) -> bytes: + return bytes([0x34, 0x12, table_id & 0xFF]) + + def build_get_devices_metadata(self, table_id: int) -> bytes: + return bytes([0x78, 0x56, table_id & 0xFF]) + + +class _DummyHass: + def __init__(self) -> None: + self.scheduled = [] + + def async_create_task(self, coro): + self.scheduled.append(coro) + try: + coro.close() + except Exception: + pass + return None + + +def _base_state() -> SimpleNamespace: + pending_get = {} + pending_meta = {} + sent_commands = [] + + async def _send_command(raw: bytes) -> None: + sent_commands.append(raw) + + def _record_pending_get_devices_cmd(cmd_id: int, table_id: int) -> None: + pending_get[cmd_id] = table_id + + def _record_pending_metadata_cmd(cmd_id: int, table_id: int) -> None: + pending_meta[cmd_id] = table_id + + return SimpleNamespace( + address="AA:BB:CC:DD:EE:FF", + is_ethernet_gateway=False, + _supports_metadata_requests=True, + _connected=True, + _authenticated=True, + _initial_get_devices_sent=False, + _select_get_devices_table_id=lambda: 0x03, + _cmd=_DummyCmd(), + async_send_command=_send_command, + _record_pending_get_devices_cmd=_record_pending_get_devices_cmd, + _record_pending_metadata_cmd=_record_pending_metadata_cmd, + _pending_get_devices_cmdids=pending_get, + _pending_get_devices_sent_at={}, + _pending_metadata_cmdids=pending_meta, + _pending_metadata_sent_at={}, + _pending_metadata_entries={}, + _metadata_requested_tables=set(), + _metadata_loaded_tables=set(), + _metadata_rejected_tables=set(), + _metadata_raw={}, + device_names={}, + _get_devices_loaded_tables=set(), + _metadata_retry_counts={}, + _cmd_correlation_stats={ + "metadata_waiting_get_devices": 0, + "get_devices_identity_rows": 0, + "metadata_success_multi_discarded_unknown": 0, + "metadata_success_multi_accepted": 0, + "metadata_entries_staged": 0, + "metadata_parse_errors": 0, + "metadata_commit_success": 0, + "metadata_commit_crc_mismatch": 0, + "metadata_commit_count_mismatch": 0, + "metadata_retry_scheduled": 0, + "command_error_unknown": 0, + "get_devices_rejected": 0, + "get_devices_completed": 0, + "pending_cmdid_pruned": 0, + "pending_get_devices_peak": 0, + "unknown_cmdids_pruned": 0, + }, + _device_identities={}, + _unknown_command_counts={}, + _process_metadata=lambda *_: None, + _apply_external_name=lambda *_: None, + _bump_unknown_cmd_count=lambda _: 1, + _last_metadata_crc=None, + gateway_info=None, + hass=_DummyHass(), + _try_connect=lambda *_: None, + _try_connect_direct=lambda *_: None, + _client=None, + _pin_already_bonded=False, + _pin_dbus_succeeded=False, + is_pin_gateway=False, + ) + + +def test_myrvlink_runtime_do_send_initial_get_devices_records_and_sends() -> None: + """Initial GetDevices should set pending cmdId and mark bootstrap sent.""" + state = _base_state() + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + + asyncio.run(runtime.do_send_initial_get_devices()) + + assert state._initial_get_devices_sent is True + assert state._pending_get_devices_cmdids.get(0x1234) == 0x03 + + +def test_myrvlink_runtime_request_metadata_after_delay_defers_until_getdevices() -> None: + """Metadata delay path should defer and bump waiting counter when table not loaded.""" + state = _base_state() + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + + original_sleep = asyncio.sleep + + async def _sleep(_: float) -> None: + return + + asyncio.sleep = _sleep + try: + asyncio.run(runtime.request_metadata_after_delay(0x03)) + finally: + asyncio.sleep = original_sleep + + assert state._cmd_correlation_stats["metadata_waiting_get_devices"] == 1 + assert 0x5678 not in state._pending_metadata_cmdids + + +def test_myrvlink_runtime_send_metadata_request_tracks_pending() -> None: + """Metadata request should register pending cmdId and requested table state.""" + state = _base_state() + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + + asyncio.run(runtime.send_metadata_request(0x05)) + + assert state._pending_metadata_cmdids.get(0x5678) == 0x05 + assert 0x05 in state._metadata_requested_tables + + +def test_myrvlink_runtime_handle_command_frame_get_devices_successmulti() -> None: + """Standard 0x02 command frame should decode GetDevices identities.""" + state = _base_state() + state._pending_get_devices_cmdids = {0x1234: 0x03} + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + + frame = bytes( + [ + 0x02, + 0x34, + 0x12, + 0x01, + 0x03, + 0x00, + 0x01, + 0x01, + 0x0A, + 0x14, + 0x01, + 0x00, + 0x67, + 0x00, + 0x00, + 0x00, + 0x08, + 0xE9, + 0xBC, + ] + ) + + consumed = runtime.handle_command_frame(frame) + + assert consumed is True + assert state._cmd_correlation_stats["get_devices_identity_rows"] == 1 + assert "03:00" in state._device_identities + + +def test_myrvlink_runtime_gateway_info_crc_change_invalidates_cached_metadata() -> None: + """GatewayInfo with changed metadata CRC should invalidate cached table metadata.""" + state = _base_state() + state._last_metadata_crc = 0x11223344 + state._metadata_loaded_tables = {0x03} + state._metadata_requested_tables = {0x03} + state._metadata_rejected_tables = {0x03} + state._metadata_raw = { + "03:10": object(), + "03:11": object(), + "04:01": object(), + } + state.device_names = { + "03:10": "A", + "03:11": "B", + "04:01": "C", + } + state._supports_metadata_requests = False + + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + event = GatewayInformation( + table_id=0x03, + device_count=2, + device_table_crc=0, + device_metadata_table_crc=0x55667788, + ) + + runtime.handle_gateway_information(event) + + assert state._last_metadata_crc is None + assert 0x03 not in state._metadata_loaded_tables + assert 0x03 not in state._metadata_requested_tables + assert 0x03 not in state._metadata_rejected_tables + assert "03:10" not in state._metadata_raw + assert "03:11" not in state._metadata_raw + assert "04:01" in state._metadata_raw + assert state.gateway_info == event + + +def test_myrvlink_runtime_gateway_info_waits_for_get_devices_before_metadata() -> None: + """GatewayInfo should increment waiting counter when GetDevices not yet complete.""" + state = _base_state() + state._supports_metadata_requests = True + state._get_devices_loaded_tables = set() + state._initial_get_devices_sent = True + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + event = GatewayInformation( + table_id=0x05, + device_count=1, + device_table_crc=0, + device_metadata_table_crc=0, + ) + + runtime.handle_gateway_information(event) + + assert state._cmd_correlation_stats["metadata_waiting_get_devices"] == 1 + + +def test_myrvlink_runtime_process_metadata_sets_name_and_crc() -> None: + """Metadata rows should update name cache and primary-table CRC tracking.""" + state = _base_state() + state.gateway_info = GatewayInformation( + table_id=0x03, + device_count=1, + device_table_crc=0, + device_metadata_table_crc=0xABCDEF01, + ) + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + + runtime.process_metadata( + DeviceMetadata(table_id=0x03, device_id=0x10, function_name=1, function_instance=0) + ) + + assert "03:10" in state._metadata_raw + assert "03:10" in state.device_names + assert state._last_metadata_crc == 0xABCDEF01 + + +def test_myrvlink_runtime_reset_protocol_tracking_state_clears_pending_maps() -> None: + """Disconnect reset should clear pending metadata/get-devices tracking structures.""" + state = _base_state() + state._metadata_requested_tables = {0x03} + state._metadata_loaded_tables = {0x03} + state._metadata_rejected_tables = {0x05} + state._metadata_retry_counts = {0x05: 2} + state._pending_metadata_cmdids = {0x1111: 0x03} + state._pending_metadata_sent_at = {0x1111: 1.0} + state._pending_metadata_entries = {0x1111: {"03:10": object()}} + state._pending_get_devices_cmdids = {0x2222: 0x03} + state._pending_get_devices_sent_at = {0x2222: 1.0} + state._get_devices_loaded_tables = {0x03} + state._unknown_command_counts = {0x3333: 4} + state._initial_get_devices_sent = True + + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + runtime.reset_protocol_tracking_state() + + assert not state._metadata_requested_tables + assert not state._metadata_loaded_tables + assert not state._metadata_rejected_tables + assert not state._metadata_retry_counts + assert not state._pending_metadata_cmdids + assert not state._pending_metadata_sent_at + assert not state._pending_metadata_entries + assert not state._pending_get_devices_cmdids + assert not state._pending_get_devices_sent_at + assert not state._get_devices_loaded_tables + assert not state._unknown_command_counts + assert state._initial_get_devices_sent is False + + +def test_myrvlink_runtime_handle_metadata_for_event_routes_table_events() -> None: + """Table-scoped status events should route to ensure_metadata_for_table.""" + state = _base_state() + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + requested: list[int] = [] + + runtime.ensure_metadata_for_table = requested.append # type: ignore[method-assign] + runtime.handle_metadata_for_event(RelayStatus(table_id=0x05, device_id=0x22, is_on=True)) + + assert requested == [0x05] + + +def test_myrvlink_runtime_handle_metadata_for_event_routes_list_items() -> None: + """List payloads should recurse into tank status and metadata rows.""" + state = _base_state() + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + requested: list[int] = [] + processed: list[tuple[int, int]] = [] + + runtime.ensure_metadata_for_table = requested.append # type: ignore[method-assign] + runtime.process_metadata = ( # type: ignore[method-assign] + lambda meta: processed.append((meta.table_id, meta.device_id)) + ) + + runtime.handle_metadata_for_event( + [ + TankLevel(table_id=0x03, device_id=0x10, level=66), + DeviceMetadata(table_id=0x03, device_id=0x10, function_name=1, function_instance=0), + ] + ) + + assert requested == [0x03] + assert processed == [(0x03, 0x10)] + + +def test_myrvlink_runtime_record_pending_get_devices_prunes_to_limit() -> None: + """GetDevices cmd tracking should prune oldest entries when max is exceeded.""" + state = _base_state() + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + + runtime.record_pending_get_devices_cmd(0x1001, 0x03, max_pending=1) + runtime.record_pending_get_devices_cmd(0x1002, 0x04, max_pending=1) + + assert state._pending_get_devices_cmdids == {0x1002: 0x04} + assert state._cmd_correlation_stats["pending_cmdid_pruned"] >= 1 + assert state._cmd_correlation_stats["pending_get_devices_peak"] == 1 + + +def test_myrvlink_runtime_prune_pending_command_state_drops_stale_entries(monkeypatch) -> None: + """Stale pending cmd IDs should be removed from both get-devices and metadata maps.""" + state = _base_state() + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + + state._pending_get_devices_cmdids = {0x2001: 0x03} + state._pending_get_devices_sent_at = {0x2001: 10.0} + state._pending_metadata_cmdids = {0x2002: 0x05} + state._pending_metadata_sent_at = {0x2002: 10.0} + state._pending_metadata_entries = {0x2002: {"05:10": object()}} + + monkeypatch.setattr("custom_components.ha_onecontrol.runtime.myrvlink_runtime.time.monotonic", lambda: 100.0) + + runtime.prune_pending_command_state(stale_timeout_s=30.0) + + assert not state._pending_get_devices_cmdids + assert not state._pending_get_devices_sent_at + assert not state._pending_metadata_cmdids + assert not state._pending_metadata_sent_at + assert not state._pending_metadata_entries + assert state._cmd_correlation_stats["pending_cmdid_pruned"] >= 2 + + +def test_myrvlink_runtime_bump_unknown_cmd_count_prunes_when_bounded() -> None: + """Unknown cmdId tracking should prune oldest entries past the configured bound.""" + state = _base_state() + runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] + + assert runtime.bump_unknown_cmd_count(0x3001, max_unknown=1) == 1 + assert runtime.bump_unknown_cmd_count(0x3002, max_unknown=1) == 1 + + assert state._unknown_command_counts == {0x3002: 1} + assert state._cmd_correlation_stats["unknown_cmdids_pruned"] == 1 From b83f05e9d44f5e089d0f12be5888f5d7755de88e Mon Sep 17 00:00:00 2001 From: Greg Brunke Date: Sat, 11 Apr 2026 14:37:48 -0400 Subject: [PATCH 3/8] Added initial support for - Dimmable lights - RGB Lighting control - HVAC Control and Status --- .gitignore | 6 + .../ha_onecontrol/coordinator.py | 142 ++++++- custom_components/ha_onecontrol/light.py | 44 ++ .../ha_onecontrol/protocol/events.py | 1 + .../ha_onecontrol/runtime/ids_can_runtime.py | 379 +++++++++++++++++- tests/test_coordinator_hvac.py | 172 ++++++++ tests/test_ids_can_runtime.py | 326 ++++++++++++++- tests/test_light_rgb_brightness.py | 19 + 8 files changed, 1058 insertions(+), 31 deletions(-) create mode 100644 tests/test_coordinator_hvac.py create mode 100644 tests/test_light_rgb_brightness.py diff --git a/.gitignore b/.gitignore index 4d5a31f..38f25eb 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,9 @@ TEST_PLAN.md BT_ADAPTER_PINNING.md BT .vscode/ +Decompiled/ +OneControlAppLogs/ +ha_config/ +docker-compose.ha-dev.yml +ha-onecontrol.sln +docs/IDS_CAN_CSHARP_REFERENCE.md diff --git a/custom_components/ha_onecontrol/coordinator.py b/custom_components/ha_onecontrol/coordinator.py index 3a0af60..662ed76 100644 --- a/custom_components/ha_onecontrol/coordinator.py +++ b/custom_components/ha_onecontrol/coordinator.py @@ -545,21 +545,21 @@ async def async_set_dimmable( ) -> None: """Send a dimmable light brightness command.""" if self.is_ethernet_gateway: - used_ids_native = await self._ids_runtime.send_light_toggle_command( + used_ids_native = await self._ids_runtime.send_light_brightness_command( table_id, device_id, - brightness > 0, + brightness, ) if used_ids_native: _LOGGER.warning( - "PACKET TX IDS light-toggle accepted table=0x%02X device=0x%02X brightness=%d (IDS-only mode)", + "PACKET TX IDS light-set accepted table=0x%02X device=0x%02X brightness=%d (IDS-only mode)", table_id & 0xFF, device_id & 0xFF, brightness, ) return _LOGGER.warning( - "PACKET TX IDS light-toggle skipped table=0x%02X device=0x%02X brightness=%d (IDS-only mode; legacy fallback disabled)", + "PACKET TX IDS light-set skipped table=0x%02X device=0x%02X brightness=%d (IDS-only mode; legacy fallback disabled)", table_id & 0xFF, device_id & 0xFF, brightness, @@ -580,6 +580,35 @@ async def async_set_dimmable_effect( cycle_time2: int = 1055, ) -> None: """Send a dimmable light effect command (blink/swell).""" + if self.is_ethernet_gateway: + used_ids_native = await self._ids_runtime.send_light_effect_command( + table_id, + device_id, + mode, + brightness, + duration, + cycle_time1, + cycle_time2, + ) + if used_ids_native: + _LOGGER.warning( + "PACKET TX IDS light-effect accepted table=0x%02X device=0x%02X mode=0x%02X brightness=%d duration=%d (IDS-only mode)", + table_id & 0xFF, + device_id & 0xFF, + mode & 0xFF, + brightness, + duration, + ) + return + _LOGGER.warning( + "PACKET TX IDS light-effect skipped table=0x%02X device=0x%02X mode=0x%02X brightness=%d (IDS-only mode; legacy fallback disabled)", + table_id & 0xFF, + device_id & 0xFF, + mode & 0xFF, + brightness, + ) + return + cmd = self._cmd.build_action_dimmable_effect( table_id, device_id, mode, brightness, duration, cycle_time1, cycle_time2, ) @@ -598,10 +627,44 @@ async def async_set_hvac( is_preset_change: bool = False, ) -> None: """Send an HVAC command and register a pending command guard.""" - cmd = self._cmd.build_action_hvac( - table_id, device_id, heat_mode, heat_source, fan_mode, low_trip_f, high_trip_f - ) - await self.async_send_command(cmd) + command_sent = False + if self.is_ethernet_gateway: + used_ids_native = await self._ids_runtime.send_hvac_command( + table_id=table_id, + device_id=device_id, + heat_mode=heat_mode, + heat_source=heat_source, + fan_mode=fan_mode, + low_trip_f=low_trip_f, + high_trip_f=high_trip_f, + ) + if used_ids_native: + command_sent = True + _LOGGER.warning( + "PACKET TX IDS hvac-set accepted table=0x%02X device=0x%02X mode=%d source=%d fan=%d low=%d high=%d", + table_id & 0xFF, + device_id & 0xFF, + heat_mode & 0x07, + heat_source & 0x03, + fan_mode & 0x03, + low_trip_f, + high_trip_f, + ) + else: + _LOGGER.warning( + "PACKET TX IDS hvac-set skipped table=0x%02X device=0x%02X reason=ids-path-not-ready", + table_id & 0xFF, + device_id & 0xFF, + ) + else: + cmd = self._cmd.build_action_hvac( + table_id, device_id, heat_mode, heat_source, fan_mode, low_trip_f, high_trip_f + ) + await self.async_send_command(cmd) + command_sent = True + + if not command_sent: + return key = _device_key(table_id, device_id) self._pending_hvac[key] = PendingHvacCommand( @@ -795,6 +858,10 @@ def _update_observed_hvac_capability(self, zone_key: str, zone: HvacZone) -> Non prev = self.observed_hvac_capability.get(zone_key, 0) cap = prev + identity = self._device_identities.get(zone_key) + if identity is not None: + cap |= getattr(identity, "raw_device_capability", 0) & 0x0F + active_status = zone.zone_status & 0x0F if active_status == 2: cap |= HVAC_CAP_AC @@ -906,12 +973,28 @@ async def _do_retry_setpoint(self, zone_key: str) -> None: pending.retry_count + 1, HVAC_SETPOINT_MAX_RETRIES, zone_key, pending.low_trip_f, pending.high_trip_f, ) - cmd = self._cmd.build_action_hvac( - pending.table_id, pending.device_id, - pending.heat_mode, pending.heat_source, pending.fan_mode, - pending.low_trip_f, pending.high_trip_f, - ) - await self.async_send_command(cmd) + if self.is_ethernet_gateway: + sent = await self._ids_runtime.send_hvac_command( + table_id=pending.table_id, + device_id=pending.device_id, + heat_mode=pending.heat_mode, + heat_source=pending.heat_source, + fan_mode=pending.fan_mode, + low_trip_f=pending.low_trip_f, + high_trip_f=pending.high_trip_f, + ) + if not sent: + _LOGGER.warning( + "HVAC setpoint retry skipped for %s (ids-path-not-ready)", + zone_key, + ) + else: + cmd = self._cmd.build_action_hvac( + pending.table_id, pending.device_id, + pending.heat_mode, pending.heat_source, pending.fan_mode, + pending.low_trip_f, pending.high_trip_f, + ) + await self.async_send_command(cmd) self._pending_hvac[zone_key] = replace( pending, retry_count=pending.retry_count + 1, @@ -940,6 +1023,37 @@ async def async_set_rgb( transition_interval: int = 1000, ) -> None: """Send an RGB light command.""" + if self.is_ethernet_gateway: + used_ids_native = await self._ids_runtime.send_rgb_command( + table_id=table_id, + device_id=device_id, + mode=mode, + red=red, + green=green, + blue=blue, + auto_off=auto_off, + blink_on_interval=blink_on_interval, + blink_off_interval=blink_off_interval, + transition_interval=transition_interval, + ) + if used_ids_native: + _LOGGER.warning( + "PACKET TX IDS rgb-set accepted table=0x%02X device=0x%02X mode=0x%02X rgb=(%d,%d,%d)", + table_id & 0xFF, + device_id & 0xFF, + mode & 0xFF, + red & 0xFF, + green & 0xFF, + blue & 0xFF, + ) + else: + _LOGGER.warning( + "PACKET TX IDS rgb-set skipped table=0x%02X device=0x%02X reason=ids-path-not-ready", + table_id & 0xFF, + device_id & 0xFF, + ) + return + cmd = self._cmd.build_action_rgb( table_id, device_id, mode, red, green, blue, auto_off, blink_on_interval, blink_off_interval, transition_interval, diff --git a/custom_components/ha_onecontrol/light.py b/custom_components/ha_onecontrol/light.py index 2b60e85..82bdcc4 100644 --- a/custom_components/ha_onecontrol/light.py +++ b/custom_components/ha_onecontrol/light.py @@ -9,12 +9,14 @@ from __future__ import annotations +import colorsys import logging from typing import Any from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_EFFECT, + ATTR_HS_COLOR, ATTR_RGB_COLOR, ColorMode, LightEntity, @@ -241,6 +243,31 @@ def _on_event(self, event: Any) -> None: _EFFECT_NAME_TO_MODE = {k: v for k, v in _RGB_EFFECTS.items()} +def _apply_brightness_to_rgb(rgb: tuple[int, int, int], brightness: int) -> tuple[int, int, int]: + """Apply native-style RGB brightness using HSV value mapping (5..250 range).""" + target = min(max(int(brightness), 0), 255) + if target <= 0: + return (0, 0, 0) + + r, g, b = (min(max(int(c), 0), 255) for c in rgb) + if max(r, g, b) == 0: + r = g = b = 255 + + rf, gf, bf = r / 255.0, g / 255.0, b / 255.0 + h, s, _v = colorsys.rgb_to_hsv(rf, gf, bf) + + # Native app path clamps brightness command to [5, 250] then maps to HSV value [0,1]. + native_brightness = min(max(target, 5), 250) + value = (native_brightness - 5) / 245.0 + + nr, ng, nb = colorsys.hsv_to_rgb(h, s, value) + return ( + min(max(int(round(nr * 255.0)), 0), 255), + min(max(int(round(ng * 255.0)), 0), 255), + min(max(int(round(nb * 255.0)), 0), 255), + ) + + class OneControlRgbLight(CoordinatorEntity[OneControlCoordinator], LightEntity): """A OneControl RGB light.""" @@ -310,8 +337,15 @@ async def async_turn_on(self, **kwargs: Any) -> None: # Resolve RGB color — mirrors Android lastKnownRgbColor fallback: # prefer explicit payload color, then last known non-zero color, then default white. rgb = kwargs.get(ATTR_RGB_COLOR) + hs = kwargs.get(ATTR_HS_COLOR) if rgb: r, g, b = rgb + elif hs: + h, s = hs + nr, ng, nb = colorsys.hsv_to_rgb(float(h) / 360.0, float(s) / 100.0, 1.0) + r, g, b = int(round(nr * 255.0)), int(round(ng * 255.0)), int(round(nb * 255.0)) + elif light: + r, g, b = light.red, light.green, light.blue else: last = self.coordinator._last_known_rgb_color.get(self._key) if last: @@ -329,6 +363,10 @@ async def async_turn_on(self, **kwargs: Any) -> None: g = min(255, round(g * factor)) b = min(255, round(b * factor)) + brightness = kwargs.get(ATTR_BRIGHTNESS) + if brightness is not None: + r, g, b = _apply_brightness_to_rgb((r, g, b), int(brightness)) + # Resolve effect / mode effect = kwargs.get(ATTR_EFFECT) if effect and effect in _EFFECT_NAME_TO_MODE: @@ -338,6 +376,12 @@ async def async_turn_on(self, **kwargs: Any) -> None: else: mode = CommandBuilder.RGB_MODE_SOLID + # Native behavior only supports direct brightness changes on ON/BLINK-like paths. + # If HA sends a brightness-only adjustment while on a transition effect, + # force SOLID so the payload RGB bytes represent the new brightness. + if brightness is not None and not effect and mode >= CommandBuilder.RGB_MODE_TRANSITION_SOLID: + mode = CommandBuilder.RGB_MODE_SOLID + # Optimistic update if light: self.coordinator.rgb_lights[self._key] = RgbLight( diff --git a/custom_components/ha_onecontrol/protocol/events.py b/custom_components/ha_onecontrol/protocol/events.py index 2c7a691..10cae6e 100644 --- a/custom_components/ha_onecontrol/protocol/events.py +++ b/custom_components/ha_onecontrol/protocol/events.py @@ -280,6 +280,7 @@ class DeviceIdentity: device_instance: int = 0 product_id: int = 0 product_mac: str = "" + raw_device_capability: int = 0 # ── Parsers ─────────────────────────────────────────────────────────────── diff --git a/custom_components/ha_onecontrol/runtime/ids_can_runtime.py b/custom_components/ha_onecontrol/runtime/ids_can_runtime.py index 76ba57a..14c7dec 100644 --- a/custom_components/ha_onecontrol/runtime/ids_can_runtime.py +++ b/custom_components/ha_onecontrol/runtime/ids_can_runtime.py @@ -42,6 +42,7 @@ _IDS_SESSION_REMOTE_CONTROL = 0x0004 _IDS_SESSION_REMOTE_CONTROL_CYPHER = 0xB16B5E95 _IDS_COMMAND_SETTLE_WINDOW_S = 1.2 +_HVAC_INVALID_TEMPERATURE_RAW_VALUES = {0x8000, 0x2FF0} class IdsCanRuntime: @@ -124,6 +125,15 @@ def _ids_encrypt_session_seed(self, seed: int) -> int: summation = (summation + delta) & 0xFFFFFFFF return s + @staticmethod + def _decode_hvac_temp_88(raw: int) -> float | None: + """Decode signed big-endian 8.8 HVAC temperature with native invalid markers.""" + value = raw & 0xFFFF + if value in _HVAC_INVALID_TEMPERATURE_RAW_VALUES: + return None + signed = value - 0x10000 if value >= 0x8000 else value + return signed / 256.0 + async def _send_ids_request(self, target_address: int, request_code: int, payload: bytes) -> bool: """Send one IDS REQUEST(0x80) frame to target over Ethernet.""" if not self._c.is_ethernet_gateway or not self._c._connected or self._c._eth_writer is None: @@ -437,6 +447,7 @@ def _handle_ids_device_id(self, source_address: int, semantic_fields: dict[str, product_id = int(semantic_fields.get("product_id", 0)) device_type = int(semantic_fields.get("device_type", 0)) device_instance = int(semantic_fields.get("device_instance", 0)) + raw_device_capability = int(semantic_fields.get("device_capabilities", 0)) product_mac = self._ids_source_product_mac.get(device_id, "") identity = DeviceIdentity( @@ -447,6 +458,7 @@ def _handle_ids_device_id(self, source_address: int, semantic_fields: dict[str, device_instance=device_instance, product_id=product_id, product_mac=product_mac, + raw_device_capability=raw_device_capability, ) key = f"{table_id:02x}:{device_id:02x}" self._ids_source_identities[device_id] = identity @@ -470,7 +482,7 @@ def _handle_ids_device_status(self, source_address: int, payload: bytes) -> None status0 = payload[0] & 0xFF on_state = (status0 & 0x01) != 0 - if identity.device_type in {20, 30} and not self._should_accept_status( + if identity.device_type in {13, 20, 30} and not self._should_accept_status( identity.device_type, device_id, on_state, @@ -511,6 +523,36 @@ def _handle_ids_device_status(self, source_address: int, payload: bytes) -> None ) self._c.rgb_lights[key] = event emitted_event = event + elif identity.device_type == 16 and len(payload) >= 8: + cmd = payload[0] & 0xFF + low_trip_f = payload[1] & 0xFF + high_trip_f = payload[2] & 0xFF + zone_status = payload[3] & 0x8F + indoor_raw = ((payload[4] & 0xFF) << 8) | (payload[5] & 0xFF) + outdoor_raw = ((payload[6] & 0xFF) << 8) | (payload[7] & 0xFF) + dtc_code = int.from_bytes(payload[8:10], "big") if len(payload) >= 10 else 0 + + event = HvacZone( + table_id=table_id, + device_id=device_id, + heat_mode=cmd & 0x07, + heat_source=(cmd >> 4) & 0x03, + fan_mode=(cmd >> 6) & 0x03, + low_trip_f=low_trip_f, + high_trip_f=high_trip_f, + zone_status=zone_status, + indoor_temp_f=self._decode_hvac_temp_88(indoor_raw), + outdoor_temp_f=self._decode_hvac_temp_88(outdoor_raw), + dtc_code=dtc_code, + ) + handle_hvac_zone = getattr(self._c, "_handle_hvac_zone", None) + if callable(handle_hvac_zone): + handle_hvac_zone(event) + else: + self._c.hvac_zones[key] = event + if hasattr(self._c, "_hvac_zone_states"): + self._c._hvac_zone_states[key] = event + emitted_event = event elif identity.device_type == 33: position = payload[1] & 0xFF if len(payload) >= 2 and payload[1] != 0xFF else None event = CoverStatus( @@ -761,9 +803,14 @@ async def send_light_toggle_command(self, table_id: int, device_id: int, turn_on Returns True when IDS-native command path was used and written. Returns False when prerequisites are missing so callers can fallback. """ + brightness = 255 if turn_on else 0 + return await self.send_light_brightness_command(table_id, device_id, brightness) + + async def send_light_brightness_command(self, table_id: int, device_id: int, brightness: int) -> bool: + """Send IDS dimmable brightness command using native 8-byte payload.""" if not self._c.is_ethernet_gateway or not self._c._connected or self._c._eth_writer is None: _LOGGER.warning( - "PACKET TX IDS light-toggle skipped table=0x%02X device=0x%02X reason=transport-not-ready", + "PACKET TX IDS light-set skipped table=0x%02X device=0x%02X reason=transport-not-ready", table_id & 0xFF, device_id & 0xFF, ) @@ -772,7 +819,7 @@ async def send_light_toggle_command(self, table_id: int, device_id: int, turn_on resolved = self._resolve_ids_identity(table_id, device_id, expected_device_type=20) if resolved is None: _LOGGER.warning( - "PACKET TX IDS light-toggle skipped table=0x%02X device=0x%02X reason=identity-not-found", + "PACKET TX IDS light-set skipped table=0x%02X device=0x%02X reason=identity-not-found", table_id & 0xFF, device_id & 0xFF, ) @@ -781,7 +828,7 @@ async def send_light_toggle_command(self, table_id: int, device_id: int, turn_on key, identity = resolved if identity.protocol != 2 or identity.device_type != 20: _LOGGER.warning( - "PACKET TX IDS light-toggle skipped key=%s reason=identity-mismatch protocol=%d device_type=%d", + "PACKET TX IDS light-set skipped key=%s reason=identity-mismatch protocol=%d device_type=%d", key, identity.protocol, identity.device_type, @@ -792,12 +839,9 @@ async def send_light_toggle_command(self, table_id: int, device_id: int, turn_on if not session_ok: return False - # Dimmable light command packet parity: - # commandByte=0x00, data[0]=mode(0=Off,1=On), fixed 8-byte payload. - if turn_on: - payload = bytes([0x01, 0xFF, 0x00, 0x00, 0xDC, 0x00, 0xDC, 0x00]) - else: - payload = bytes([0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00]) + clamped_brightness = min(max(brightness, 0), 255) + mode = 0x00 if clamped_brightness == 0 else 0x01 + payload = bytes([mode, clamped_brightness, 0x00, 0x00, 0xDC, 0x00, 0xDC, 0x00]) raw = compose_ids_can_extended_wire_frame( message_type=0x82, @@ -813,22 +857,325 @@ async def send_light_toggle_command(self, table_id: int, device_id: int, turn_on # Optimistic state mirrors BLE behavior and is corrected by incoming status. self._ensure_event_store_maps() - brightness = 255 if turn_on else 0 event = DimmableLight( table_id=table_id & 0xFF, device_id=device_id & 0xFF, - brightness=brightness, - mode=1 if turn_on else 0, + brightness=clamped_brightness, + mode=mode, ) self._c.dimmable_lights[key] = event self._dispatch_state_event(event) - self._set_pending_status_expectation(identity.device_type, device_id, turn_on) + self._set_pending_status_expectation(identity.device_type, device_id, clamped_brightness > 0) _LOGGER.warning( - "PACKET TX IDS light-toggle src=0x%02X dst=0x%02X cmd=0x00 on=%s payload=%s raw=%s", + "PACKET TX IDS light-set src=0x%02X dst=0x%02X cmd=0x00 mode=0x%02X brightness=%d payload=%s raw=%s", self._ids_controller_source_address & 0xFF, device_id & 0xFF, - turn_on, + mode, + clamped_brightness, + payload.hex(), + raw.hex(), + ) + return True + + async def send_light_effect_command( + self, + table_id: int, + device_id: int, + mode: int, + brightness: int, + duration: int, + cycle_time1: int, + cycle_time2: int, + ) -> bool: + """Send IDS dimmable effect command (blink/swell) via COMMAND(0x82).""" + if not self._c.is_ethernet_gateway or not self._c._connected or self._c._eth_writer is None: + _LOGGER.warning( + "PACKET TX IDS light-effect skipped table=0x%02X device=0x%02X reason=transport-not-ready", + table_id & 0xFF, + device_id & 0xFF, + ) + return False + + resolved = self._resolve_ids_identity(table_id, device_id, expected_device_type=20) + if resolved is None: + _LOGGER.warning( + "PACKET TX IDS light-effect skipped table=0x%02X device=0x%02X reason=identity-not-found", + table_id & 0xFF, + device_id & 0xFF, + ) + return False + + key, identity = resolved + if identity.protocol != 2 or identity.device_type != 20: + _LOGGER.warning( + "PACKET TX IDS light-effect skipped key=%s reason=identity-mismatch protocol=%d device_type=%d", + key, + identity.protocol, + identity.device_type, + ) + return False + + session_ok = await self.ensure_remote_control_session(device_id & 0xFF) + if not session_ok: + return False + + effect_mode = mode & 0xFF + clamped_brightness = min(max(brightness, 0), 255) + dur = duration & 0xFF + ct1 = min(max(cycle_time1, 0), 0xFFFF) + ct2 = min(max(cycle_time2, 0), 0xFFFF) + # Dimmable effect payload parity uses little-endian cycle timing fields. + payload = bytes([ + effect_mode, + clamped_brightness, + dur, + 0x00, + ct1 & 0xFF, + (ct1 >> 8) & 0xFF, + ct2 & 0xFF, + (ct2 >> 8) & 0xFF, + ]) + + raw = compose_ids_can_extended_wire_frame( + message_type=0x82, + source_address=self._ids_controller_source_address, + target_address=device_id & 0xFF, + message_data=0x00, + payload=payload, + ) + self._record_recent_command_target(device_id & 0xFF) + self._c._eth_writer.write(cobs_encode(raw)) + await self._c._eth_writer.drain() + self._c._last_ethernet_tx_time = time.monotonic() + + # Optimistic state mirrors requested brightness/effect mode until status arrives. + self._ensure_event_store_maps() + event = DimmableLight( + table_id=table_id & 0xFF, + device_id=device_id & 0xFF, + brightness=clamped_brightness, + mode=effect_mode, + ) + self._c.dimmable_lights[key] = event + self._dispatch_state_event(event) + self._set_pending_status_expectation(identity.device_type, device_id, clamped_brightness > 0) + + _LOGGER.warning( + "PACKET TX IDS light-effect src=0x%02X dst=0x%02X mode=0x%02X brightness=%d duration=%d ct1=%d ct2=%d payload=%s raw=%s", + self._ids_controller_source_address & 0xFF, + device_id & 0xFF, + effect_mode, + clamped_brightness, + dur, + ct1, + ct2, + payload.hex(), + raw.hex(), + ) + return True + + async def send_rgb_command( + self, + table_id: int, + device_id: int, + mode: int = 0x01, + red: int = 255, + green: int = 255, + blue: int = 255, + auto_off: int = 0, + blink_on_interval: int = 0, + blink_off_interval: int = 0, + transition_interval: int = 1000, + ) -> bool: + """Send IDS RGB command as COMMAND(0x82) with native 8-byte payload.""" + if not self._c.is_ethernet_gateway or not self._c._connected or self._c._eth_writer is None: + _LOGGER.warning( + "PACKET TX IDS rgb-set skipped table=0x%02X device=0x%02X reason=transport-not-ready", + table_id & 0xFF, + device_id & 0xFF, + ) + return False + + resolved = self._resolve_ids_identity(table_id, device_id, expected_device_type=13) + if resolved is None: + _LOGGER.warning( + "PACKET TX IDS rgb-set skipped table=0x%02X device=0x%02X reason=identity-not-found", + table_id & 0xFF, + device_id & 0xFF, + ) + return False + + key, identity = resolved + if identity.protocol != 2 or identity.device_type != 13: + _LOGGER.warning( + "PACKET TX IDS rgb-set skipped key=%s reason=identity-mismatch protocol=%d device_type=%d", + key, + identity.protocol, + identity.device_type, + ) + return False + + session_ok = await self.ensure_remote_control_session(device_id & 0xFF) + if not session_ok: + return False + + mode_byte = mode & 0xFF + r = min(max(red, 0), 255) + g = min(max(green, 0), 255) + b = min(max(blue, 0), 255) + auto = auto_off & 0xFF + interval_msb = 0 + interval_lsb = 0 + + if mode_byte == 0x02: + interval_msb = blink_on_interval & 0xFF + interval_lsb = blink_off_interval & 0xFF + elif 0x04 <= mode_byte <= 0x08: + interval = min(max(transition_interval, 0), 0xFFFF) + interval_msb = (interval >> 8) & 0xFF + interval_lsb = interval & 0xFF + + payload = bytes([mode_byte, r, g, b, auto, interval_msb, interval_lsb, 0x00]) + + raw = compose_ids_can_extended_wire_frame( + message_type=0x82, + source_address=self._ids_controller_source_address, + target_address=device_id & 0xFF, + message_data=0x00, + payload=payload, + ) + self._record_recent_command_target(device_id & 0xFF) + self._c._eth_writer.write(cobs_encode(raw)) + await self._c._eth_writer.drain() + self._c._last_ethernet_tx_time = time.monotonic() + + self._ensure_event_store_maps() + current = self._c.rgb_lights.get(key) + brightness = current.brightness if current else 255 + if mode_byte == 0x00: + brightness = 0 + elif mode_byte in {0x01, 0x02}: + brightness = max(r, g, b) + event = RgbLight( + table_id=table_id & 0xFF, + device_id=device_id & 0xFF, + mode=mode_byte, + red=r, + green=g, + blue=b, + brightness=brightness, + ) + self._c.rgb_lights[key] = event + self._dispatch_state_event(event) + self._set_pending_status_expectation(identity.device_type, device_id, mode_byte > 0) + + _LOGGER.warning( + "PACKET TX IDS rgb-set src=0x%02X dst=0x%02X mode=0x%02X rgb=(%d,%d,%d) auto_off=%d i1=0x%02X i2=0x%02X payload=%s raw=%s", + self._ids_controller_source_address & 0xFF, + device_id & 0xFF, + mode_byte, + r, + g, + b, + auto, + interval_msb, + interval_lsb, + payload.hex(), + raw.hex(), + ) + return True + + async def send_hvac_command( + self, + table_id: int, + device_id: int, + heat_mode: int = 0, + heat_source: int = 0, + fan_mode: int = 0, + low_trip_f: int = 65, + high_trip_f: int = 78, + ) -> bool: + """Send IDS HVAC command as COMMAND(0x82) with native 3-byte payload.""" + if not self._c.is_ethernet_gateway or not self._c._connected or self._c._eth_writer is None: + _LOGGER.warning( + "PACKET TX IDS hvac-set skipped table=0x%02X device=0x%02X reason=transport-not-ready", + table_id & 0xFF, + device_id & 0xFF, + ) + return False + + resolved = self._resolve_ids_identity(table_id, device_id, expected_device_type=16) + if resolved is None: + _LOGGER.warning( + "PACKET TX IDS hvac-set skipped table=0x%02X device=0x%02X reason=identity-not-found", + table_id & 0xFF, + device_id & 0xFF, + ) + return False + + key, identity = resolved + if identity.protocol != 2 or identity.device_type != 16: + _LOGGER.warning( + "PACKET TX IDS hvac-set skipped key=%s reason=identity-mismatch protocol=%d device_type=%d", + key, + identity.protocol, + identity.device_type, + ) + return False + + session_ok = await self.ensure_remote_control_session(device_id & 0xFF) + if not session_ok: + return False + + command_byte = ( + (heat_mode & 0x07) + | ((heat_source & 0x03) << 4) + | ((fan_mode & 0x03) << 6) + ) + low = min(max(low_trip_f, 0), 255) + high = min(max(high_trip_f, 0), 255) + payload = bytes([command_byte & 0xFF, low, high]) + + raw = compose_ids_can_extended_wire_frame( + message_type=0x82, + source_address=self._ids_controller_source_address, + target_address=device_id & 0xFF, + message_data=0x00, + payload=payload, + ) + self._record_recent_command_target(device_id & 0xFF) + self._c._eth_writer.write(cobs_encode(raw)) + await self._c._eth_writer.drain() + self._c._last_ethernet_tx_time = time.monotonic() + + self._ensure_event_store_maps() + current = self._c.hvac_zones.get(key) + event = HvacZone( + table_id=table_id & 0xFF, + device_id=device_id & 0xFF, + heat_mode=command_byte & 0x07, + heat_source=(command_byte >> 4) & 0x03, + fan_mode=(command_byte >> 6) & 0x03, + low_trip_f=low, + high_trip_f=high, + zone_status=current.zone_status if current else 0, + indoor_temp_f=current.indoor_temp_f if current else None, + outdoor_temp_f=current.outdoor_temp_f if current else None, + dtc_code=current.dtc_code if current else 0, + ) + self._c.hvac_zones[key] = event + if hasattr(self._c, "_hvac_zone_states"): + self._c._hvac_zone_states[key] = event + self._dispatch_state_event(event) + + _LOGGER.warning( + "PACKET TX IDS hvac-set src=0x%02X dst=0x%02X cmd=0x%02X low=%d high=%d payload=%s raw=%s", + self._ids_controller_source_address & 0xFF, + device_id & 0xFF, + command_byte & 0xFF, + low, + high, payload.hex(), raw.hex(), ) diff --git a/tests/test_coordinator_hvac.py b/tests/test_coordinator_hvac.py new file mode 100644 index 0000000..06891a8 --- /dev/null +++ b/tests/test_coordinator_hvac.py @@ -0,0 +1,172 @@ +"""Unit tests for coordinator HVAC routing behavior.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace +from typing import Any, cast + +from custom_components.ha_onecontrol.coordinator import OneControlCoordinator, PendingHvacCommand +from custom_components.ha_onecontrol.protocol.events import DeviceIdentity, HvacZone + + +class _FakeIdsRuntime: + def __init__(self, result: bool) -> None: + self.result = result + self.calls: list[dict[str, int]] = [] + + async def send_hvac_command(self, **kwargs: int) -> bool: + self.calls.append(dict(kwargs)) + return self.result + + +def test_async_set_hvac_routes_to_ids_runtime_for_ethernet() -> None: + """Ethernet HVAC commands should use IDS-native sender and set pending guard.""" + ids_runtime = _FakeIdsRuntime(result=True) + retry_keys: list[str] = [] + + async def _async_send_command(_: bytes) -> None: + raise AssertionError("Legacy HVAC path should not be used on Ethernet") + + state = SimpleNamespace( + is_ethernet_gateway=True, + _ids_runtime=ids_runtime, + _cmd=SimpleNamespace(build_action_hvac=lambda *args, **kwargs: b""), + async_send_command=_async_send_command, + _pending_hvac={}, + _schedule_setpoint_retry=lambda key: retry_keys.append(key), + ) + + asyncio.run( + OneControlCoordinator.async_set_hvac( + cast(Any, state), + table_id=0x03, + device_id=0xCF, + heat_mode=3, + heat_source=1, + fan_mode=2, + low_trip_f=66, + high_trip_f=79, + is_setpoint_change=True, + is_preset_change=False, + ) + ) + + assert len(ids_runtime.calls) == 1 + assert ids_runtime.calls[0]["table_id"] == 0x03 + assert ids_runtime.calls[0]["device_id"] == 0xCF + + pending = state._pending_hvac.get("03:cf") + assert pending is not None + assert pending.low_trip_f == 66 + assert pending.high_trip_f == 79 + assert pending.is_setpoint_change is True + assert retry_keys == ["03:cf"] + + +def test_async_set_hvac_skips_pending_when_ids_send_fails() -> None: + """When IDS HVAC send is unavailable, coordinator should not create pending guard.""" + ids_runtime = _FakeIdsRuntime(result=False) + + async def _async_send_command(_: bytes) -> None: + raise AssertionError("Legacy HVAC path should not be used on Ethernet") + + state = SimpleNamespace( + is_ethernet_gateway=True, + _ids_runtime=ids_runtime, + _cmd=SimpleNamespace(build_action_hvac=lambda *args, **kwargs: b""), + async_send_command=_async_send_command, + _pending_hvac={}, + _schedule_setpoint_retry=lambda *_: None, + ) + + asyncio.run( + OneControlCoordinator.async_set_hvac( + cast(Any, state), + table_id=0x03, + device_id=0xCF, + heat_mode=1, + heat_source=0, + fan_mode=0, + low_trip_f=65, + high_trip_f=78, + is_setpoint_change=False, + is_preset_change=False, + ) + ) + + assert len(ids_runtime.calls) == 1 + assert state._pending_hvac == {} + + +def test_do_retry_setpoint_uses_ids_hvac_sender_on_ethernet() -> None: + """Setpoint retries on Ethernet should use IDS-native HVAC sender.""" + ids_runtime = _FakeIdsRuntime(result=True) + scheduled: list[str] = [] + + state = SimpleNamespace( + is_ethernet_gateway=True, + _ids_runtime=ids_runtime, + _cmd=SimpleNamespace(build_action_hvac=lambda *args, **kwargs: b""), + async_send_command=lambda *_: None, + _pending_hvac={ + "03:cf": PendingHvacCommand( + table_id=0x03, + device_id=0xCF, + heat_mode=3, + heat_source=1, + fan_mode=2, + low_trip_f=66, + high_trip_f=79, + is_setpoint_change=True, + is_preset_change=False, + sent_at=1.0, + retry_count=0, + ) + }, + _hvac_retry_handles={}, + _schedule_setpoint_retry=lambda key: scheduled.append(key), + ) + + asyncio.run(OneControlCoordinator._do_retry_setpoint(cast(Any, state), "03:cf")) + + assert len(ids_runtime.calls) == 1 + pending = state._pending_hvac["03:cf"] + assert pending.retry_count == 1 + assert scheduled == ["03:cf"] + + +def test_update_observed_hvac_capability_seeds_from_identity_raw_capability() -> None: + """Observed HVAC capability should include IDS raw capability bitmask hints.""" + state = SimpleNamespace( + observed_hvac_capability={}, + _device_identities={ + "03:cf": DeviceIdentity( + table_id=0x03, + device_id=0xCF, + protocol=2, + device_type=16, + device_instance=0, + product_id=0, + product_mac="", + raw_device_capability=0x0D, # Gas + HeatPump + MultiSpeedFan + ) + }, + ) + zone = HvacZone( + table_id=0x03, + device_id=0xCF, + heat_mode=0, + heat_source=0, + fan_mode=0, + low_trip_f=66, + high_trip_f=79, + zone_status=1, + indoor_temp_f=72.0, + outdoor_temp_f=55.0, + dtc_code=0, + ) + + OneControlCoordinator._update_observed_hvac_capability(cast(Any, state), "03:cf", zone) + + assert state.observed_hvac_capability["03:cf"] & 0x0D == 0x0D diff --git a/tests/test_ids_can_runtime.py b/tests/test_ids_can_runtime.py index ead8564..611d1c3 100644 --- a/tests/test_ids_can_runtime.py +++ b/tests/test_ids_can_runtime.py @@ -5,7 +5,7 @@ from types import SimpleNamespace from custom_components.ha_onecontrol.protocol.cobs import CobsByteDecoder -from custom_components.ha_onecontrol.protocol.events import DeviceMetadata +from custom_components.ha_onecontrol.protocol.events import DeviceIdentity, DeviceMetadata, HvacZone from custom_components.ha_onecontrol.protocol.ids_can_wire import parse_ids_can_wire_frame from custom_components.ha_onecontrol.runtime.ids_can_runtime import IdsCanRuntime @@ -564,6 +564,94 @@ def test_ids_runtime_send_relay_toggle_command_writes_extended_ids_frame() -> No assert relay.status_byte == 0x01 +def test_ids_runtime_send_light_brightness_command_writes_brightness_payload() -> None: + """send_light_brightness_command should preserve requested brightness in payload.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + runtime.handle_frame(bytes.fromhex("0800cd031400000008e9cd")) + runtime.handle_frame(bytes.fromhex("0802cd0067cd14002740d0")) + + import asyncio + import time + + runtime._ids_session_opened_at[0xCD] = time.monotonic() + + used = asyncio.run(runtime.send_light_brightness_command(0x03, 0xCD, 82)) + assert used is True + + writer = state._eth_writer + decoder = CobsByteDecoder(use_crc=True) + raw = None + for byte_val in writer.writes[-1]: + raw = decoder.decode_byte(byte_val) + if raw is not None: + break + + assert raw is not None + parsed = parse_ids_can_wire_frame(raw) + assert parsed is not None + assert parsed.message_type == 0x82 + assert parsed.target_address == 0xCD + assert parsed.message_data == 0x00 + assert parsed.payload == bytes.fromhex("01520000dc00dc00") + + light = state.dimmable_lights.get("03:cd") + assert light is not None + assert light.mode == 1 + assert light.brightness == 82 + + +def test_ids_runtime_send_light_effect_command_writes_effect_payload() -> None: + """send_light_effect_command should emit effect mode and timing payload fields.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + runtime.handle_frame(bytes.fromhex("0800cd031400000008e9cd")) + runtime.handle_frame(bytes.fromhex("0802cd0067cd14002740d0")) + + import asyncio + import time + + runtime._ids_session_opened_at[0xCD] = time.monotonic() + + used = asyncio.run( + runtime.send_light_effect_command( + 0x03, + 0xCD, + mode=0x02, + brightness=128, + duration=5, + cycle_time1=1055, + cycle_time2=2447, + ) + ) + assert used is True + + writer = state._eth_writer + decoder = CobsByteDecoder(use_crc=True) + raw = None + for byte_val in writer.writes[-1]: + raw = decoder.decode_byte(byte_val) + if raw is not None: + break + + assert raw is not None + parsed = parse_ids_can_wire_frame(raw) + assert parsed is not None + assert parsed.message_type == 0x82 + assert parsed.target_address == 0xCD + assert parsed.message_data == 0x00 + # mode=0x02, brightness=0x80, duration=0x05, reserved=0x00, + # cycle1=1055=>0x041F (little-endian 1f04), cycle2=2447=>0x098F (8f09) + assert parsed.payload == bytes.fromhex("028005001f048f09") + + light = state.dimmable_lights.get("03:cd") + assert light is not None + assert light.mode == 0x02 + assert light.brightness == 128 + + def test_ids_runtime_light_toggle_uses_device_id_fallback_when_table_mismatch() -> None: """Light toggle should still send when requested table id differs but device id/type match.""" state = _base_state() @@ -624,3 +712,239 @@ def test_ids_runtime_relay_toggle_uses_device_id_fallback_when_table_mismatch() assert parsed is not None assert parsed.target_address == 0xDA assert parsed.message_type == 0x82 + + +def test_ids_runtime_send_rgb_command_writes_solid_payload() -> None: + """send_rgb_command should emit native 8-byte RGB payload for solid mode.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + runtime.handle_frame(bytes.fromhex("0800ce031400000008e9ce")) + runtime.handle_frame(bytes.fromhex("0802ce0067ce0d002740d0")) + + import asyncio + import time + + runtime._ids_session_opened_at[0xCE] = time.monotonic() + + used = asyncio.run( + runtime.send_rgb_command( + 0x03, + 0xCE, + mode=0x01, + red=0x11, + green=0x22, + blue=0x33, + auto_off=0x04, + ) + ) + assert used is True + + writer = state._eth_writer + decoder = CobsByteDecoder(use_crc=True) + raw = None + for byte_val in writer.writes[-1]: + raw = decoder.decode_byte(byte_val) + if raw is not None: + break + + assert raw is not None + parsed = parse_ids_can_wire_frame(raw) + assert parsed is not None + assert parsed.message_type == 0x82 + assert parsed.target_address == 0xCE + assert parsed.message_data == 0x00 + assert parsed.payload == bytes.fromhex("0111223304000000") + + light = state.rgb_lights.get("03:ce") + assert light is not None + assert light.mode == 0x01 + assert (light.red, light.green, light.blue) == (0x11, 0x22, 0x33) + + +def test_ids_runtime_send_rgb_command_writes_transition_payload() -> None: + """Transition RGB modes should encode transition interval as big-endian.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + runtime.handle_frame(bytes.fromhex("0800ce031400000008e9ce")) + runtime.handle_frame(bytes.fromhex("0802ce0067ce0d002740d0")) + + import asyncio + import time + + runtime._ids_session_opened_at[0xCE] = time.monotonic() + + used = asyncio.run( + runtime.send_rgb_command( + 0x03, + 0xCE, + mode=0x08, + auto_off=0x09, + transition_interval=0x1234, + ) + ) + assert used is True + + writer = state._eth_writer + decoder = CobsByteDecoder(use_crc=True) + raw = None + for byte_val in writer.writes[-1]: + raw = decoder.decode_byte(byte_val) + if raw is not None: + break + + assert raw is not None + parsed = parse_ids_can_wire_frame(raw) + assert parsed is not None + assert parsed.message_type == 0x82 + assert parsed.target_address == 0xCE + assert parsed.message_data == 0x00 + assert parsed.payload == bytes.fromhex("08ffffff09123400") + + +def test_ids_runtime_send_rgb_command_writes_off_payload() -> None: + """Off RGB command should set mode byte to 0x00 in native payload.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + runtime.handle_frame(bytes.fromhex("0800ce031400000008e9ce")) + runtime.handle_frame(bytes.fromhex("0802ce0067ce0d002740d0")) + + import asyncio + import time + + runtime._ids_session_opened_at[0xCE] = time.monotonic() + + used = asyncio.run(runtime.send_rgb_command(0x03, 0xCE, mode=0x00)) + assert used is True + + writer = state._eth_writer + decoder = CobsByteDecoder(use_crc=True) + raw = None + for byte_val in writer.writes[-1]: + raw = decoder.decode_byte(byte_val) + if raw is not None: + break + + assert raw is not None + parsed = parse_ids_can_wire_frame(raw) + assert parsed is not None + assert parsed.message_type == 0x82 + assert parsed.target_address == 0xCE + assert parsed.message_data == 0x00 + assert parsed.payload[0] == 0x00 + + light = state.rgb_lights.get("03:ce") + assert light is not None + assert light.mode == 0x00 + + +def test_ids_runtime_send_hvac_command_writes_native_payload() -> None: + """send_hvac_command should emit native 3-byte HVAC payload and optimistic state.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + state._device_identities["03:cf"] = DeviceIdentity( + table_id=0x03, + device_id=0xCF, + protocol=2, + device_type=16, + device_instance=0, + product_id=0, + product_mac="", + ) + state.hvac_zones["03:cf"] = HvacZone( + table_id=0x03, + device_id=0xCF, + heat_mode=0, + heat_source=0, + fan_mode=0, + low_trip_f=68, + high_trip_f=72, + zone_status=1, + indoor_temp_f=70, + outdoor_temp_f=55, + dtc_code=0, + ) + + import asyncio + import time + + runtime._ids_session_opened_at[0xCF] = time.monotonic() + + used = asyncio.run( + runtime.send_hvac_command( + 0x03, + 0xCF, + heat_mode=3, + heat_source=1, + fan_mode=2, + low_trip_f=66, + high_trip_f=79, + ) + ) + assert used is True + + writer = state._eth_writer + decoder = CobsByteDecoder(use_crc=True) + raw = None + for byte_val in writer.writes[-1]: + raw = decoder.decode_byte(byte_val) + if raw is not None: + break + + assert raw is not None + parsed = parse_ids_can_wire_frame(raw) + assert parsed is not None + assert parsed.message_type == 0x82 + assert parsed.target_address == 0xCF + assert parsed.message_data == 0x00 + assert parsed.payload == bytes([0x93, 66, 79]) + + zone = state.hvac_zones.get("03:cf") + assert zone is not None + assert zone.heat_mode == 3 + assert zone.heat_source == 1 + assert zone.fan_mode == 2 + assert zone.low_trip_f == 66 + assert zone.high_trip_f == 79 + + +def test_ids_runtime_hvac_device_status_decodes_temperature_feedback() -> None: + """IDS HVAC DEVICE_STATUS payload should decode command/setpoint/temperature fields.""" + state = _base_state() + runtime = IdsCanRuntime(state) # type: ignore[arg-type] + + runtime._ids_source_identities[0xCF] = DeviceIdentity( + table_id=0x03, + device_id=0xCF, + protocol=2, + device_type=16, + device_instance=0, + product_id=0, + product_mac="", + ) + + payload = bytes([ + 0x93, # heat_mode=3 heat_source=1 fan_mode=2 + 66, # low trip + 79, # high trip + 0x03, # zone status + 0x48, 0x00, # indoor 72.0F + 0x37, 0x00, # outdoor 55.0F + 0x12, 0x34, # dtc + ]) + runtime._handle_ids_device_status(0xCF, payload) + + zone = state.hvac_zones.get("03:cf") + assert zone is not None + assert zone.heat_mode == 3 + assert zone.heat_source == 1 + assert zone.fan_mode == 2 + assert zone.low_trip_f == 66 + assert zone.high_trip_f == 79 + assert zone.zone_status == 0x03 + assert zone.indoor_temp_f == 72.0 + assert zone.outdoor_temp_f == 55.0 + assert zone.dtc_code == 0x1234 diff --git a/tests/test_light_rgb_brightness.py b/tests/test_light_rgb_brightness.py new file mode 100644 index 0000000..e42068b --- /dev/null +++ b/tests/test_light_rgb_brightness.py @@ -0,0 +1,19 @@ +"""Tests for HA RGB brightness handling.""" + +from custom_components.ha_onecontrol.light import _apply_brightness_to_rgb + + +def test_apply_brightness_to_rgb_scales_preserving_ratios() -> None: + """Brightness scaling should preserve color ratios for non-zero colors.""" + # Native mapping clamps to 5..250 before converting to HSV value. + assert _apply_brightness_to_rgb((200, 100, 50), 100) == (99, 49, 25) + + +def test_apply_brightness_to_rgb_uses_white_when_color_unknown() -> None: + """If source color is black/unknown, brightness should map to neutral white.""" + assert _apply_brightness_to_rgb((0, 0, 0), 64) == (61, 61, 61) + + +def test_apply_brightness_to_rgb_handles_off() -> None: + """Zero brightness should produce black.""" + assert _apply_brightness_to_rgb((255, 10, 10), 0) == (0, 0, 0) From b69b000d0d1973c00bf6ae75563b665e72df4102 Mon Sep 17 00:00:00 2001 From: Greg Brunke Date: Mon, 13 Apr 2026 01:30:29 -0400 Subject: [PATCH 4/8] Improve IDS command reliability and stale status masking --- .../ha_onecontrol/runtime/ids_can_runtime.py | 220 ++++++++++++------ 1 file changed, 144 insertions(+), 76 deletions(-) diff --git a/custom_components/ha_onecontrol/runtime/ids_can_runtime.py b/custom_components/ha_onecontrol/runtime/ids_can_runtime.py index 14c7dec..3da514e 100644 --- a/custom_components/ha_onecontrol/runtime/ids_can_runtime.py +++ b/custom_components/ha_onecontrol/runtime/ids_can_runtime.py @@ -10,7 +10,7 @@ import logging import socket import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Callable from ..protocol.ids_can_wire import ( compose_ids_can_extended_wire_frame, @@ -41,7 +41,7 @@ _IDS_SESSION_REMOTE_CONTROL = 0x0004 _IDS_SESSION_REMOTE_CONTROL_CYPHER = 0xB16B5E95 -_IDS_COMMAND_SETTLE_WINDOW_S = 1.2 +_IDS_COMMAND_SETTLE_WINDOW_S = 8.0 _HVAC_INVALID_TEMPERATURE_RAW_VALUES = {0x8000, 0x2FF0} @@ -64,6 +64,7 @@ def __init__(self, coordinator: OneControlCoordinator) -> None: self._ids_session_last_heartbeat_at: dict[int, float] = {} self._ids_active_session_target: int | None = None self._ids_pending_status_expectations: dict[tuple[int, int], tuple[bool, float]] = {} + self._ids_command_locks: dict[int, asyncio.Lock] = {} def _record_recent_command_target(self, target_address: int) -> None: """Track recent IDS command targets for response correlation logs.""" @@ -82,6 +83,10 @@ def _set_pending_status_expectation(self, device_type: int, device_id: int, desi time.monotonic() + _IDS_COMMAND_SETTLE_WINDOW_S, ) + def _clear_pending_status_expectation(self, device_type: int, device_id: int) -> None: + """Clear pending status expectation for a device when command send fails.""" + self._ids_pending_status_expectations.pop((device_type & 0xFF, device_id & 0xFF), None) + def _should_accept_status(self, device_type: int, device_id: int, observed_on: bool) -> bool: """Gate status updates during a small post-command settle window.""" key = (device_type & 0xFF, device_id & 0xFF) @@ -158,6 +163,72 @@ async def _send_ids_request(self, target_address: int, request_code: int, payloa ) return True + def _get_command_lock(self, target_address: int) -> asyncio.Lock: + """Return per-target lock to serialize IDS commands per device.""" + target = target_address & 0xFF + lock = self._ids_command_locks.get(target) + if lock is None: + lock = asyncio.Lock() + self._ids_command_locks[target] = lock + return lock + + async def _send_ids_command_with_retry( + self, + target_address: int, + compose_frame: Callable[[], bytes], + command_name: str, + max_attempts: int = 3, + ) -> tuple[bool, bytes | None]: + """Serialize and retry IDS command sends with session establishment.""" + target = target_address & 0xFF + lock = self._get_command_lock(target) + async with lock: + for attempt in range(1, max_attempts + 1): + if not self._c.is_ethernet_gateway or not self._c._connected or self._c._eth_writer is None: + _LOGGER.warning( + "PACKET TX IDS %s aborted dst=0x%02X reason=transport-not-ready attempt=%d/%d", + command_name, + target, + attempt, + max_attempts, + ) + return False, None + + session_ok = await self.ensure_remote_control_session(target) + if not session_ok: + _LOGGER.warning( + "PACKET TX IDS %s session-not-ready dst=0x%02X attempt=%d/%d", + command_name, + target, + attempt, + max_attempts, + ) + if attempt < max_attempts: + await asyncio.sleep(0.2 * attempt) + continue + return False, None + + raw = compose_frame() + try: + self._record_recent_command_target(target) + self._c._eth_writer.write(cobs_encode(raw)) + await self._c._eth_writer.drain() + self._c._last_ethernet_tx_time = time.monotonic() + return True, raw + except Exception as exc: # noqa: BLE001 + _LOGGER.warning( + "PACKET TX IDS %s write failed dst=0x%02X attempt=%d/%d err=%s", + command_name, + target, + attempt, + max_attempts, + exc, + ) + if attempt < max_attempts: + await asyncio.sleep(0.2 * attempt) + + return False, None + async def ensure_remote_control_session(self, target_address: int) -> bool: """Best-effort IDS REMOTE_CONTROL session open for command-capable devices.""" target = target_address & 0xFF @@ -835,25 +906,26 @@ async def send_light_brightness_command(self, table_id: int, device_id: int, bri ) return False - session_ok = await self.ensure_remote_control_session(device_id & 0xFF) - if not session_ok: - return False - clamped_brightness = min(max(brightness, 0), 255) mode = 0x00 if clamped_brightness == 0 else 0x01 payload = bytes([mode, clamped_brightness, 0x00, 0x00, 0xDC, 0x00, 0xDC, 0x00]) - raw = compose_ids_can_extended_wire_frame( - message_type=0x82, - source_address=self._ids_controller_source_address, - target_address=device_id & 0xFF, - message_data=0x00, - payload=payload, + self._set_pending_status_expectation(identity.device_type, device_id, clamped_brightness > 0) + + sent, raw = await self._send_ids_command_with_retry( + device_id & 0xFF, + lambda: compose_ids_can_extended_wire_frame( + message_type=0x82, + source_address=self._ids_controller_source_address, + target_address=device_id & 0xFF, + message_data=0x00, + payload=payload, + ), + "light-set", ) - self._record_recent_command_target(device_id & 0xFF) - self._c._eth_writer.write(cobs_encode(raw)) - await self._c._eth_writer.drain() - self._c._last_ethernet_tx_time = time.monotonic() + if not sent or raw is None: + self._clear_pending_status_expectation(identity.device_type, device_id) + return False # Optimistic state mirrors BLE behavior and is corrected by incoming status. self._ensure_event_store_maps() @@ -865,7 +937,6 @@ async def send_light_brightness_command(self, table_id: int, device_id: int, bri ) self._c.dimmable_lights[key] = event self._dispatch_state_event(event) - self._set_pending_status_expectation(identity.device_type, device_id, clamped_brightness > 0) _LOGGER.warning( "PACKET TX IDS light-set src=0x%02X dst=0x%02X cmd=0x00 mode=0x%02X brightness=%d payload=%s raw=%s", @@ -916,10 +987,6 @@ async def send_light_effect_command( ) return False - session_ok = await self.ensure_remote_control_session(device_id & 0xFF) - if not session_ok: - return False - effect_mode = mode & 0xFF clamped_brightness = min(max(brightness, 0), 255) dur = duration & 0xFF @@ -937,17 +1004,22 @@ async def send_light_effect_command( (ct2 >> 8) & 0xFF, ]) - raw = compose_ids_can_extended_wire_frame( - message_type=0x82, - source_address=self._ids_controller_source_address, - target_address=device_id & 0xFF, - message_data=0x00, - payload=payload, + self._set_pending_status_expectation(identity.device_type, device_id, clamped_brightness > 0) + + sent, raw = await self._send_ids_command_with_retry( + device_id & 0xFF, + lambda: compose_ids_can_extended_wire_frame( + message_type=0x82, + source_address=self._ids_controller_source_address, + target_address=device_id & 0xFF, + message_data=0x00, + payload=payload, + ), + "light-effect", ) - self._record_recent_command_target(device_id & 0xFF) - self._c._eth_writer.write(cobs_encode(raw)) - await self._c._eth_writer.drain() - self._c._last_ethernet_tx_time = time.monotonic() + if not sent or raw is None: + self._clear_pending_status_expectation(identity.device_type, device_id) + return False # Optimistic state mirrors requested brightness/effect mode until status arrives. self._ensure_event_store_maps() @@ -959,7 +1031,6 @@ async def send_light_effect_command( ) self._c.dimmable_lights[key] = event self._dispatch_state_event(event) - self._set_pending_status_expectation(identity.device_type, device_id, clamped_brightness > 0) _LOGGER.warning( "PACKET TX IDS light-effect src=0x%02X dst=0x%02X mode=0x%02X brightness=%d duration=%d ct1=%d ct2=%d payload=%s raw=%s", @@ -1016,10 +1087,6 @@ async def send_rgb_command( ) return False - session_ok = await self.ensure_remote_control_session(device_id & 0xFF) - if not session_ok: - return False - mode_byte = mode & 0xFF r = min(max(red, 0), 255) g = min(max(green, 0), 255) @@ -1038,17 +1105,22 @@ async def send_rgb_command( payload = bytes([mode_byte, r, g, b, auto, interval_msb, interval_lsb, 0x00]) - raw = compose_ids_can_extended_wire_frame( - message_type=0x82, - source_address=self._ids_controller_source_address, - target_address=device_id & 0xFF, - message_data=0x00, - payload=payload, + self._set_pending_status_expectation(identity.device_type, device_id, mode_byte > 0) + + sent, raw = await self._send_ids_command_with_retry( + device_id & 0xFF, + lambda: compose_ids_can_extended_wire_frame( + message_type=0x82, + source_address=self._ids_controller_source_address, + target_address=device_id & 0xFF, + message_data=0x00, + payload=payload, + ), + "rgb-set", ) - self._record_recent_command_target(device_id & 0xFF) - self._c._eth_writer.write(cobs_encode(raw)) - await self._c._eth_writer.drain() - self._c._last_ethernet_tx_time = time.monotonic() + if not sent or raw is None: + self._clear_pending_status_expectation(identity.device_type, device_id) + return False self._ensure_event_store_maps() current = self._c.rgb_lights.get(key) @@ -1068,7 +1140,6 @@ async def send_rgb_command( ) self._c.rgb_lights[key] = event self._dispatch_state_event(event) - self._set_pending_status_expectation(identity.device_type, device_id, mode_byte > 0) _LOGGER.warning( "PACKET TX IDS rgb-set src=0x%02X dst=0x%02X mode=0x%02X rgb=(%d,%d,%d) auto_off=%d i1=0x%02X i2=0x%02X payload=%s raw=%s", @@ -1124,10 +1195,6 @@ async def send_hvac_command( ) return False - session_ok = await self.ensure_remote_control_session(device_id & 0xFF) - if not session_ok: - return False - command_byte = ( (heat_mode & 0x07) | ((heat_source & 0x03) << 4) @@ -1137,17 +1204,19 @@ async def send_hvac_command( high = min(max(high_trip_f, 0), 255) payload = bytes([command_byte & 0xFF, low, high]) - raw = compose_ids_can_extended_wire_frame( - message_type=0x82, - source_address=self._ids_controller_source_address, - target_address=device_id & 0xFF, - message_data=0x00, - payload=payload, + sent, raw = await self._send_ids_command_with_retry( + device_id & 0xFF, + lambda: compose_ids_can_extended_wire_frame( + message_type=0x82, + source_address=self._ids_controller_source_address, + target_address=device_id & 0xFF, + message_data=0x00, + payload=payload, + ), + "hvac-set", ) - self._record_recent_command_target(device_id & 0xFF) - self._c._eth_writer.write(cobs_encode(raw)) - await self._c._eth_writer.drain() - self._c._last_ethernet_tx_time = time.monotonic() + if not sent or raw is None: + return False self._ensure_event_store_maps() current = self._c.hvac_zones.get(key) @@ -1214,23 +1283,23 @@ async def send_relay_toggle_command(self, table_id: int, device_id: int, turn_on ) return False - session_ok = await self.ensure_remote_control_session(device_id & 0xFF) - if not session_ok: - return False - # RelayBasicLatching Type2 parity: # Off=commandByte 0x00, On=commandByte 0x01, empty data payload. - raw = compose_ids_can_extended_wire_frame( - message_type=0x82, - source_address=self._ids_controller_source_address, - target_address=device_id & 0xFF, - message_data=0x01 if turn_on else 0x00, - payload=b"", + self._set_pending_status_expectation(identity.device_type, device_id, turn_on) + sent, raw = await self._send_ids_command_with_retry( + device_id & 0xFF, + lambda: compose_ids_can_extended_wire_frame( + message_type=0x82, + source_address=self._ids_controller_source_address, + target_address=device_id & 0xFF, + message_data=0x01 if turn_on else 0x00, + payload=b"", + ), + "relay-toggle", ) - self._record_recent_command_target(device_id & 0xFF) - self._c._eth_writer.write(cobs_encode(raw)) - await self._c._eth_writer.drain() - self._c._last_ethernet_tx_time = time.monotonic() + if not sent or raw is None: + self._clear_pending_status_expectation(identity.device_type, device_id) + return False # Optimistic state mirrors BLE behavior and is corrected by incoming status. self._ensure_event_store_maps() @@ -1243,7 +1312,6 @@ async def send_relay_toggle_command(self, table_id: int, device_id: int, turn_on ) self._c.relays[key] = event self._dispatch_state_event(event) - self._set_pending_status_expectation(identity.device_type, device_id, turn_on) _LOGGER.warning( "PACKET TX IDS relay-toggle src=0x%02X dst=0x%02X cmd=0x%02X on=%s raw=%s", From 2c1e617d5f327736fadb14f3d53610094824d61a Mon Sep 17 00:00:00 2001 From: Greg Brunke Date: Sat, 18 Apr 2026 14:56:35 -0400 Subject: [PATCH 5/8] chore: remove obsolete MyRvLinkRuntime extraction --- .../ha_onecontrol/runtime/myrvlink_runtime.py | 576 ------------------ tests/test_myrvlink_runtime.py | 380 ------------ 2 files changed, 956 deletions(-) delete mode 100644 custom_components/ha_onecontrol/runtime/myrvlink_runtime.py delete mode 100644 tests/test_myrvlink_runtime.py diff --git a/custom_components/ha_onecontrol/runtime/myrvlink_runtime.py b/custom_components/ha_onecontrol/runtime/myrvlink_runtime.py deleted file mode 100644 index 918cf9a..0000000 --- a/custom_components/ha_onecontrol/runtime/myrvlink_runtime.py +++ /dev/null @@ -1,576 +0,0 @@ -"""MyRvLink/BLE runtime orchestration. - -This runtime owns BLE connect/auth retry sequencing while preserving the -coordinator's public API and existing parsing/state semantics. -""" - -from __future__ import annotations - -import asyncio -import logging -import time -from typing import TYPE_CHECKING - -from .ids_can_runtime import IdsCanRuntime -from ..ble_agent import remove_bond -from ..protocol.function_names import get_friendly_name -from ..protocol.events import ( - CoverStatus, - DeviceLock, - GatewayInformation, - DeviceMetadata, - DeviceOnline, - DimmableLight, - GeneratorStatus, - HourMeter, - RelayStatus, - RgbLight, - TankLevel, - parse_get_devices_response, - parse_metadata_response, -) - -if TYPE_CHECKING: - from ..coordinator import OneControlCoordinator - -_LOGGER = logging.getLogger(__name__) - - -class MyRvLinkRuntime: - """Runtime for BLE/MyRvLink connection orchestration.""" - - def __init__(self, coordinator: OneControlCoordinator, ids_runtime: IdsCanRuntime) -> None: - self._c = coordinator - self._ids_runtime = ids_runtime - - async def send_metadata_request(self, table_id: int) -> None: - """Send GetDevicesMetadata for a single table ID.""" - if not self._c._supports_metadata_requests: - return - cmd = self._c._cmd.build_get_devices_metadata(table_id) - cmd_id = int.from_bytes(cmd[0:2], "little") - self.record_pending_metadata_cmd(cmd_id, table_id, max_pending=128) - self._c._pending_metadata_entries.pop(cmd_id, None) - self._c._metadata_rejected_tables.discard(table_id) - self._c._metadata_requested_tables.add(table_id) - try: - await self._c.async_send_command(cmd) - _LOGGER.info("Sent GetDevicesMetadata for table %d (cmdId=%d)", table_id, cmd_id) - except Exception as exc: - self._c._pending_metadata_cmdids.pop(cmd_id, None) - self._c._pending_metadata_sent_at.pop(cmd_id, None) - self._c._pending_metadata_entries.pop(cmd_id, None) - self._c._metadata_requested_tables.discard(table_id) - _LOGGER.warning("Failed to send metadata request: %s", exc) - - async def retry_metadata_after_rejection(self, table_id: int) -> None: - """Retry GetDevicesMetadata 10s after a rejection.""" - await asyncio.sleep(10.0) - if not self._c._supports_metadata_requests: - return - if not self._c._connected: - return - if table_id in self._c._metadata_loaded_tables: - return - _LOGGER.debug("Retrying metadata for table_id=%d after 0x0f rejection", table_id) - self._c._metadata_requested_tables.discard(table_id) - if table_id not in self._c._get_devices_loaded_tables: - self._c._cmd_correlation_stats["metadata_waiting_get_devices"] += 1 - _LOGGER.debug( - "Retry for metadata table %d deferred - waiting for GetDevices completion", - table_id, - ) - return - await self.send_metadata_request(table_id) - - async def send_initial_get_devices(self) -> None: - """Delay then send GetDevices wake-up command.""" - await asyncio.sleep(0.5) - await self.do_send_initial_get_devices() - - async def do_send_initial_get_devices(self) -> None: - """Send initial GetDevices if connection/auth state allows.""" - if self._c._initial_get_devices_sent: - return - if not self._c._connected or not self._c._authenticated: - return - table_id = self._c._select_get_devices_table_id() - if table_id is None: - return - try: - cmd = self._c._cmd.build_get_devices(table_id) - cmd_id = int.from_bytes(cmd[0:2], "little") - self.record_pending_get_devices_cmd(cmd_id, table_id, max_pending=128) - await self._c.async_send_command(cmd) - self._c._initial_get_devices_sent = True - _LOGGER.debug( - "Initial GetDevices sent for table %d (cmdId=%d)", - table_id, - cmd_id, - ) - except Exception as exc: # noqa: BLE001 - _LOGGER.warning("Initial GetDevices failed: %s", exc) - - async def request_metadata_after_delay(self, table_id: int) -> None: - """Wait then request metadata, gated by completion/rejection state.""" - if not self._c._supports_metadata_requests: - return - await asyncio.sleep(1.5) - if table_id in self._c._metadata_loaded_tables: - return - if table_id in self._c._metadata_rejected_tables: - return - if table_id in self._c._metadata_requested_tables: - return - if table_id not in self._c._get_devices_loaded_tables: - self._c._cmd_correlation_stats["metadata_waiting_get_devices"] += 1 - _LOGGER.debug( - "Metadata request deferred for table %d - waiting for GetDevices completion", - table_id, - ) - return - await self.send_metadata_request(table_id) - - def ensure_metadata_for_table(self, table_id: int) -> None: - """Schedule metadata request for newly observed table IDs.""" - if not self._c._supports_metadata_requests: - return - if table_id == 0: - return - if ( - table_id in self._c._metadata_loaded_tables - or table_id in self._c._metadata_rejected_tables - or table_id in self._c._metadata_requested_tables - ): - return - if table_id not in self._c._get_devices_loaded_tables: - self._c._cmd_correlation_stats["metadata_waiting_get_devices"] += 1 - _LOGGER.debug( - "Observed table_id=%d but delaying metadata until GetDevices completes", - table_id, - ) - return - _LOGGER.info("Requesting metadata for observed table_id=%d", table_id) - self._c.hass.async_create_task(self.send_metadata_request(table_id)) - - def handle_gateway_information(self, event: GatewayInformation) -> None: - """Apply GatewayInformation and manage metadata CRC/scheduling behavior.""" - _LOGGER.debug( - "GatewayInfo: table_id=%d, devices=%d, table_crc=0x%08x, metadata_crc=0x%08x", - event.table_id, - event.device_count, - event.device_table_crc, - event.device_metadata_table_crc, - ) - - crc = event.device_metadata_table_crc - if crc != 0 and crc == self._c._last_metadata_crc: - self._c._metadata_loaded_tables.add(event.table_id) - _LOGGER.debug( - "Metadata CRC unchanged (0x%08x), skipping re-request for table %d", - crc, - event.table_id, - ) - elif ( - self._c._last_metadata_crc is not None - and crc != self._c._last_metadata_crc - and event.table_id in self._c._metadata_loaded_tables - ): - _LOGGER.info( - "Metadata CRC changed (0x%08x -> 0x%08x), invalidating table %d", - self._c._last_metadata_crc, - crc, - event.table_id, - ) - self._c._last_metadata_crc = None - prefix = f"{event.table_id:02x}:" - for key in list(self._c._metadata_raw): - if key.startswith(prefix): - del self._c._metadata_raw[key] - self._c.device_names.pop(key, None) - self._c._metadata_requested_tables.discard(event.table_id) - self._c._metadata_loaded_tables.discard(event.table_id) - self._c._metadata_rejected_tables.discard(event.table_id) - - self._c.gateway_info = event - - if not self._c._initial_get_devices_sent: - self._c.hass.async_create_task(self.do_send_initial_get_devices()) - - if ( - self._c._supports_metadata_requests - and event.table_id not in self._c._metadata_loaded_tables - and event.table_id not in self._c._metadata_requested_tables - ): - if event.table_id in self._c._get_devices_loaded_tables: - self._c.hass.async_create_task(self.request_metadata_after_delay(event.table_id)) - else: - self._c._cmd_correlation_stats["metadata_waiting_get_devices"] += 1 - _LOGGER.debug( - "GatewayInfo table %d waiting for GetDevices completion before metadata request", - event.table_id, - ) - - def process_metadata(self, meta: DeviceMetadata) -> None: - """Store metadata rows and resolve canonical friendly names.""" - key = f"{meta.table_id:02x}:{meta.device_id:02x}" - self._c._metadata_raw[key] = meta - name = get_friendly_name(meta.function_name, meta.function_instance) - self._c.device_names[key] = name - self._c._metadata_loaded_tables.add(meta.table_id) - if ( - self._c.gateway_info is not None - and meta.table_id == self._c.gateway_info.table_id - and self._c.gateway_info.device_metadata_table_crc != 0 - ): - self._c._last_metadata_crc = self._c.gateway_info.device_metadata_table_crc - _LOGGER.info( - "Metadata: %s -> func=%d inst=%d -> %s", - key.upper(), - meta.function_name, - meta.function_instance, - name, - ) - - def reset_protocol_tracking_state(self) -> None: - """Clear protocol-level command/metadata tracking on disconnect.""" - self._c._metadata_requested_tables.clear() - self._c._metadata_loaded_tables.clear() - self._c._metadata_rejected_tables.clear() - self._c._metadata_retry_counts.clear() - self._c._pending_metadata_cmdids.clear() - self._c._pending_metadata_sent_at.clear() - self._c._pending_metadata_entries.clear() - self._c._pending_get_devices_cmdids.clear() - self._c._pending_get_devices_sent_at.clear() - self._c._get_devices_loaded_tables.clear() - self._c._unknown_command_counts.clear() - self._c._initial_get_devices_sent = False - - def record_pending_get_devices_cmd(self, cmd_id: int, table_id: int, max_pending: int) -> None: - """Track in-flight GetDevices command IDs with bounded retention.""" - now = time.monotonic() - self._c._pending_get_devices_cmdids[cmd_id] = table_id - self._c._pending_get_devices_sent_at[cmd_id] = now - while len(self._c._pending_get_devices_cmdids) > max_pending: - oldest_cmd_id = next(iter(self._c._pending_get_devices_cmdids)) - self._c._pending_get_devices_cmdids.pop(oldest_cmd_id, None) - self._c._pending_get_devices_sent_at.pop(oldest_cmd_id, None) - self._c._cmd_correlation_stats["pending_cmdid_pruned"] += 1 - self._c._cmd_correlation_stats["pending_get_devices_peak"] = max( - self._c._cmd_correlation_stats["pending_get_devices_peak"], - len(self._c._pending_get_devices_cmdids), - ) - - def record_pending_metadata_cmd(self, cmd_id: int, table_id: int, max_pending: int) -> None: - """Track in-flight metadata command IDs with bounded retention.""" - now = time.monotonic() - self._c._pending_metadata_cmdids[cmd_id] = table_id - self._c._pending_metadata_sent_at[cmd_id] = now - while len(self._c._pending_metadata_cmdids) > max_pending: - oldest_cmd_id = next(iter(self._c._pending_metadata_cmdids)) - self._c._pending_metadata_cmdids.pop(oldest_cmd_id, None) - self._c._pending_metadata_sent_at.pop(oldest_cmd_id, None) - self._c._pending_metadata_entries.pop(oldest_cmd_id, None) - self._c._cmd_correlation_stats["pending_cmdid_pruned"] += 1 - - def prune_pending_command_state(self, stale_timeout_s: float) -> None: - """Drop stale pending cmdIds so late/missing responses do not accumulate.""" - cutoff = time.monotonic() - stale_timeout_s - - stale_get_devices = [ - cmd_id - for cmd_id, sent_at in self._c._pending_get_devices_sent_at.items() - if sent_at < cutoff - ] - for cmd_id in stale_get_devices: - self._c._pending_get_devices_sent_at.pop(cmd_id, None) - self._c._pending_get_devices_cmdids.pop(cmd_id, None) - self._c._cmd_correlation_stats["pending_cmdid_pruned"] += 1 - - stale_metadata = [ - cmd_id - for cmd_id, sent_at in self._c._pending_metadata_sent_at.items() - if sent_at < cutoff - ] - for cmd_id in stale_metadata: - self._c._pending_metadata_sent_at.pop(cmd_id, None) - self._c._pending_metadata_cmdids.pop(cmd_id, None) - self._c._pending_metadata_entries.pop(cmd_id, None) - self._c._cmd_correlation_stats["pending_cmdid_pruned"] += 1 - - def bump_unknown_cmd_count(self, cmd_id: int, max_unknown: int) -> int: - """Increment unknown cmdId counter and bound map size.""" - count = self._c._unknown_command_counts.get(cmd_id, 0) + 1 - self._c._unknown_command_counts[cmd_id] = count - while len(self._c._unknown_command_counts) > max_unknown: - self._c._unknown_command_counts.pop(next(iter(self._c._unknown_command_counts))) - self._c._cmd_correlation_stats["unknown_cmdids_pruned"] += 1 - return count - - def handle_metadata_for_event(self, event: object) -> None: - """Route metadata processing/requests for event payload types.""" - if isinstance(event, list): - for item in event: - self.handle_metadata_for_event(item) - return - - if isinstance(event, DeviceMetadata): - self.process_metadata(event) - return - - if isinstance( - event, - ( - RelayStatus, - DimmableLight, - RgbLight, - CoverStatus, - TankLevel, - DeviceOnline, - DeviceLock, - GeneratorStatus, - HourMeter, - ), - ): - self.ensure_metadata_for_table(event.table_id) - - def handle_command_frame(self, frame: bytes) -> bool: - """Handle standard MyRvLink command envelopes. - - Returns True when the frame is consumed and should not be parsed as a - regular state/event payload. - """ - if not frame or frame[0] != 0x02: - return False - - if len(frame) < 4: - return True - - def _resolve_pending_cmd_id(raw_cmd_id: int) -> int: - if ( - raw_cmd_id in self._c._pending_get_devices_cmdids - or raw_cmd_id in self._c._pending_metadata_cmdids - ): - return raw_cmd_id - swapped = ((raw_cmd_id & 0xFF) << 8) | ((raw_cmd_id >> 8) & 0xFF) - if ( - swapped in self._c._pending_get_devices_cmdids - or swapped in self._c._pending_metadata_cmdids - ): - return swapped - return raw_cmd_id - - cmd_id = (frame[1] & 0xFF) | ((frame[2] & 0xFF) << 8) - cmd_id = _resolve_pending_cmd_id(cmd_id) - response_type = frame[3] & 0xFF - - if response_type == 0x81: - completed_get_devices_table = self._c._pending_get_devices_cmdids.pop(cmd_id, None) - self._c._pending_get_devices_sent_at.pop(cmd_id, None) - if completed_get_devices_table is not None: - self._c._cmd_correlation_stats["get_devices_completed"] += 1 - self._c._get_devices_loaded_tables.add(completed_get_devices_table) - if ( - self._c._supports_metadata_requests - and completed_get_devices_table not in self._c._metadata_loaded_tables - and completed_get_devices_table not in self._c._metadata_requested_tables - ): - self._c.hass.async_create_task( - self.send_metadata_request(completed_get_devices_table) - ) - return True - - completed_table = self._c._pending_metadata_cmdids.pop(cmd_id, None) - self._c._pending_metadata_sent_at.pop(cmd_id, None) - if completed_table is not None and len(frame) >= 8: - response_crc = int.from_bytes(frame[4:8], "big") - response_count = frame[8] & 0xFF if len(frame) >= 9 else None - staged_entries = self._c._pending_metadata_entries.pop(cmd_id, {}) - staged_count = len(staged_entries) - expected_crc = ( - self._c.gateway_info.device_metadata_table_crc - if self._c.gateway_info is not None - else 0 - ) - if expected_crc != 0 and response_crc != expected_crc: - self._c._cmd_correlation_stats["metadata_commit_crc_mismatch"] += 1 - self._c._metadata_loaded_tables.discard(completed_table) - self._c._metadata_requested_tables.discard(completed_table) - self._c._last_metadata_crc = None - elif response_count is not None and response_count != staged_count: - self._c._cmd_correlation_stats["metadata_commit_count_mismatch"] += 1 - self._c._metadata_loaded_tables.discard(completed_table) - self._c._metadata_requested_tables.discard(completed_table) - self._c._last_metadata_crc = None - else: - for meta in staged_entries.values(): - self._c._process_metadata(meta) - self._c._metadata_loaded_tables.add(completed_table) - self._c._metadata_rejected_tables.discard(completed_table) - self._c._last_metadata_crc = response_crc - self._c._cmd_correlation_stats["metadata_commit_success"] += 1 - return True - - if response_type == 0x82: - rejected_table = self._c._pending_metadata_cmdids.pop(cmd_id, None) - self._c._pending_metadata_sent_at.pop(cmd_id, None) - self._c._pending_metadata_entries.pop(cmd_id, None) - if rejected_table is not None: - error_code = frame[4] & 0xFF if len(frame) >= 5 else -1 - if error_code == 0x0F: - self._c._metadata_rejected_tables.discard(rejected_table) - retry_count = self._c._metadata_retry_counts.get(rejected_table, 0) + 1 - self._c._metadata_retry_counts[rejected_table] = retry_count - self._c._cmd_correlation_stats["metadata_retry_scheduled"] += 1 - self._c.hass.async_create_task( - self.retry_metadata_after_rejection(rejected_table) - ) - else: - self._c._metadata_requested_tables.discard(rejected_table) - self._c._metadata_rejected_tables.add(rejected_table) - else: - gd_table = self._c._pending_get_devices_cmdids.pop(cmd_id, None) - self._c._pending_get_devices_sent_at.pop(cmd_id, None) - if gd_table is not None: - self._c._cmd_correlation_stats["get_devices_rejected"] += 1 - self._c._get_devices_loaded_tables.discard(gd_table) - else: - self._c._cmd_correlation_stats["command_error_unknown"] += 1 - self.bump_unknown_cmd_count(cmd_id, max_unknown=512) - return True - - if response_type == 0x01: - if cmd_id in self._c._pending_get_devices_cmdids: - identities = parse_get_devices_response(frame) - if identities: - self._c._cmd_correlation_stats["get_devices_identity_rows"] += len(identities) - for identity in identities: - key = f"{identity.table_id:02x}:{identity.device_id:02x}" - self._c._device_identities[key] = identity - self._c._apply_external_name(key, identity) - return True - - if cmd_id not in self._c._pending_metadata_cmdids: - self._c._cmd_correlation_stats["metadata_success_multi_discarded_unknown"] += 1 - self.bump_unknown_cmd_count(cmd_id, max_unknown=512) - return True - - self._c._cmd_correlation_stats["metadata_success_multi_accepted"] += 1 - staged = self._c._pending_metadata_entries.setdefault(cmd_id, {}) - added = 0 - try: - parsed_metadata = parse_metadata_response(frame) - except Exception: - self._c._cmd_correlation_stats["metadata_parse_errors"] += 1 - return True - - for meta in parsed_metadata: - key = f"{meta.table_id:02x}:{meta.device_id:02x}" - if key not in staged: - added += 1 - staged[key] = meta - if added: - self._c._cmd_correlation_stats["metadata_entries_staged"] += added - return True - - return True - - async def connect(self) -> None: - """Internal connect routine with retry logic.""" - if self._c.is_ethernet_gateway: - await self._ids_runtime.connect() - return - - max_attempts = 3 - last_exc: Exception | None = None - for attempt in range(1, max_attempts + 1): - try: - await self._c._try_connect(attempt) - return - except Exception as exc: - last_exc = exc - _LOGGER.warning( - "Connection attempt %d/%d failed: %s", - attempt, - max_attempts, - exc, - ) - if self._c._client: - try: - await self._c._client.disconnect() - except Exception: - pass - self._c._client = None - self._c._connected = False - self._c._authenticated = False - - if attempt < max_attempts: - delay = 3 * attempt - _LOGGER.info("Retrying in %ds...", delay) - await asyncio.sleep(delay) - - assert last_exc is not None - - if self._c.is_pin_gateway and self._c._pin_already_bonded: - _LOGGER.warning( - "PIN gateway %s: BlueZ bond present but all connection attempts failed " - "- removing stale bond and retrying with fresh PIN pairing", - self._c.address, - ) - removed = await remove_bond(self._c.address) - if removed: - _LOGGER.info( - "Stale bond removed for %s - attempting fresh PIN pairing", - self._c.address, - ) - self._c._pin_dbus_succeeded = False - self._c._pin_already_bonded = False - try: - await self._c._try_connect(max_attempts + 1) - return - except Exception as stale_exc: - last_exc = stale_exc - _LOGGER.warning( - "Re-pair attempt after stale bond removal failed for %s: %s", - self._c.address, - stale_exc, - ) - - if self._c.is_pin_gateway and not self._c._pin_dbus_succeeded: - _LOGGER.warning( - "PIN gateway %s: D-Bus bonding did not succeed - skipping " - "direct adapter fallback. Ensure the gateway PIN is correct " - "and the device is powered on and in pairing mode.", - self._c.address, - ) - raise last_exc - - _LOGGER.warning( - "All %d HA-routed connection attempts failed for %s; " - "trying direct HCI adapter fallback", - max_attempts, - self._c.address, - ) - for adapter in ("hci0", "hci1", "hci2", "hci3"): - _LOGGER.info("Direct BLE connect to %s via %s", self._c.address, adapter) - try: - await self._c._try_connect_direct(adapter) - _LOGGER.info( - "Direct connect succeeded via %s for %s", - adapter, - self._c.address, - ) - return - except Exception as exc: - _LOGGER.debug("Direct connect via %s failed: %s", adapter, exc) - if self._c._client: - try: - await self._c._client.disconnect() - except Exception: - pass - self._c._client = None - self._c._connected = False - self._c._authenticated = False - - raise last_exc diff --git a/tests/test_myrvlink_runtime.py b/tests/test_myrvlink_runtime.py deleted file mode 100644 index e2ed5d5..0000000 --- a/tests/test_myrvlink_runtime.py +++ /dev/null @@ -1,380 +0,0 @@ -"""Runtime-focused tests for MyRvLink metadata sequencing behavior.""" - -from __future__ import annotations - -import asyncio -from types import SimpleNamespace - -from custom_components.ha_onecontrol.protocol.events import DeviceMetadata -from custom_components.ha_onecontrol.protocol.events import GatewayInformation -from custom_components.ha_onecontrol.protocol.events import RelayStatus -from custom_components.ha_onecontrol.protocol.events import TankLevel -from custom_components.ha_onecontrol.runtime.ids_can_runtime import IdsCanRuntime -from custom_components.ha_onecontrol.runtime.myrvlink_runtime import MyRvLinkRuntime - - -class _DummyCmd: - def build_get_devices(self, table_id: int) -> bytes: - return bytes([0x34, 0x12, table_id & 0xFF]) - - def build_get_devices_metadata(self, table_id: int) -> bytes: - return bytes([0x78, 0x56, table_id & 0xFF]) - - -class _DummyHass: - def __init__(self) -> None: - self.scheduled = [] - - def async_create_task(self, coro): - self.scheduled.append(coro) - try: - coro.close() - except Exception: - pass - return None - - -def _base_state() -> SimpleNamespace: - pending_get = {} - pending_meta = {} - sent_commands = [] - - async def _send_command(raw: bytes) -> None: - sent_commands.append(raw) - - def _record_pending_get_devices_cmd(cmd_id: int, table_id: int) -> None: - pending_get[cmd_id] = table_id - - def _record_pending_metadata_cmd(cmd_id: int, table_id: int) -> None: - pending_meta[cmd_id] = table_id - - return SimpleNamespace( - address="AA:BB:CC:DD:EE:FF", - is_ethernet_gateway=False, - _supports_metadata_requests=True, - _connected=True, - _authenticated=True, - _initial_get_devices_sent=False, - _select_get_devices_table_id=lambda: 0x03, - _cmd=_DummyCmd(), - async_send_command=_send_command, - _record_pending_get_devices_cmd=_record_pending_get_devices_cmd, - _record_pending_metadata_cmd=_record_pending_metadata_cmd, - _pending_get_devices_cmdids=pending_get, - _pending_get_devices_sent_at={}, - _pending_metadata_cmdids=pending_meta, - _pending_metadata_sent_at={}, - _pending_metadata_entries={}, - _metadata_requested_tables=set(), - _metadata_loaded_tables=set(), - _metadata_rejected_tables=set(), - _metadata_raw={}, - device_names={}, - _get_devices_loaded_tables=set(), - _metadata_retry_counts={}, - _cmd_correlation_stats={ - "metadata_waiting_get_devices": 0, - "get_devices_identity_rows": 0, - "metadata_success_multi_discarded_unknown": 0, - "metadata_success_multi_accepted": 0, - "metadata_entries_staged": 0, - "metadata_parse_errors": 0, - "metadata_commit_success": 0, - "metadata_commit_crc_mismatch": 0, - "metadata_commit_count_mismatch": 0, - "metadata_retry_scheduled": 0, - "command_error_unknown": 0, - "get_devices_rejected": 0, - "get_devices_completed": 0, - "pending_cmdid_pruned": 0, - "pending_get_devices_peak": 0, - "unknown_cmdids_pruned": 0, - }, - _device_identities={}, - _unknown_command_counts={}, - _process_metadata=lambda *_: None, - _apply_external_name=lambda *_: None, - _bump_unknown_cmd_count=lambda _: 1, - _last_metadata_crc=None, - gateway_info=None, - hass=_DummyHass(), - _try_connect=lambda *_: None, - _try_connect_direct=lambda *_: None, - _client=None, - _pin_already_bonded=False, - _pin_dbus_succeeded=False, - is_pin_gateway=False, - ) - - -def test_myrvlink_runtime_do_send_initial_get_devices_records_and_sends() -> None: - """Initial GetDevices should set pending cmdId and mark bootstrap sent.""" - state = _base_state() - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - - asyncio.run(runtime.do_send_initial_get_devices()) - - assert state._initial_get_devices_sent is True - assert state._pending_get_devices_cmdids.get(0x1234) == 0x03 - - -def test_myrvlink_runtime_request_metadata_after_delay_defers_until_getdevices() -> None: - """Metadata delay path should defer and bump waiting counter when table not loaded.""" - state = _base_state() - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - - original_sleep = asyncio.sleep - - async def _sleep(_: float) -> None: - return - - asyncio.sleep = _sleep - try: - asyncio.run(runtime.request_metadata_after_delay(0x03)) - finally: - asyncio.sleep = original_sleep - - assert state._cmd_correlation_stats["metadata_waiting_get_devices"] == 1 - assert 0x5678 not in state._pending_metadata_cmdids - - -def test_myrvlink_runtime_send_metadata_request_tracks_pending() -> None: - """Metadata request should register pending cmdId and requested table state.""" - state = _base_state() - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - - asyncio.run(runtime.send_metadata_request(0x05)) - - assert state._pending_metadata_cmdids.get(0x5678) == 0x05 - assert 0x05 in state._metadata_requested_tables - - -def test_myrvlink_runtime_handle_command_frame_get_devices_successmulti() -> None: - """Standard 0x02 command frame should decode GetDevices identities.""" - state = _base_state() - state._pending_get_devices_cmdids = {0x1234: 0x03} - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - - frame = bytes( - [ - 0x02, - 0x34, - 0x12, - 0x01, - 0x03, - 0x00, - 0x01, - 0x01, - 0x0A, - 0x14, - 0x01, - 0x00, - 0x67, - 0x00, - 0x00, - 0x00, - 0x08, - 0xE9, - 0xBC, - ] - ) - - consumed = runtime.handle_command_frame(frame) - - assert consumed is True - assert state._cmd_correlation_stats["get_devices_identity_rows"] == 1 - assert "03:00" in state._device_identities - - -def test_myrvlink_runtime_gateway_info_crc_change_invalidates_cached_metadata() -> None: - """GatewayInfo with changed metadata CRC should invalidate cached table metadata.""" - state = _base_state() - state._last_metadata_crc = 0x11223344 - state._metadata_loaded_tables = {0x03} - state._metadata_requested_tables = {0x03} - state._metadata_rejected_tables = {0x03} - state._metadata_raw = { - "03:10": object(), - "03:11": object(), - "04:01": object(), - } - state.device_names = { - "03:10": "A", - "03:11": "B", - "04:01": "C", - } - state._supports_metadata_requests = False - - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - event = GatewayInformation( - table_id=0x03, - device_count=2, - device_table_crc=0, - device_metadata_table_crc=0x55667788, - ) - - runtime.handle_gateway_information(event) - - assert state._last_metadata_crc is None - assert 0x03 not in state._metadata_loaded_tables - assert 0x03 not in state._metadata_requested_tables - assert 0x03 not in state._metadata_rejected_tables - assert "03:10" not in state._metadata_raw - assert "03:11" not in state._metadata_raw - assert "04:01" in state._metadata_raw - assert state.gateway_info == event - - -def test_myrvlink_runtime_gateway_info_waits_for_get_devices_before_metadata() -> None: - """GatewayInfo should increment waiting counter when GetDevices not yet complete.""" - state = _base_state() - state._supports_metadata_requests = True - state._get_devices_loaded_tables = set() - state._initial_get_devices_sent = True - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - event = GatewayInformation( - table_id=0x05, - device_count=1, - device_table_crc=0, - device_metadata_table_crc=0, - ) - - runtime.handle_gateway_information(event) - - assert state._cmd_correlation_stats["metadata_waiting_get_devices"] == 1 - - -def test_myrvlink_runtime_process_metadata_sets_name_and_crc() -> None: - """Metadata rows should update name cache and primary-table CRC tracking.""" - state = _base_state() - state.gateway_info = GatewayInformation( - table_id=0x03, - device_count=1, - device_table_crc=0, - device_metadata_table_crc=0xABCDEF01, - ) - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - - runtime.process_metadata( - DeviceMetadata(table_id=0x03, device_id=0x10, function_name=1, function_instance=0) - ) - - assert "03:10" in state._metadata_raw - assert "03:10" in state.device_names - assert state._last_metadata_crc == 0xABCDEF01 - - -def test_myrvlink_runtime_reset_protocol_tracking_state_clears_pending_maps() -> None: - """Disconnect reset should clear pending metadata/get-devices tracking structures.""" - state = _base_state() - state._metadata_requested_tables = {0x03} - state._metadata_loaded_tables = {0x03} - state._metadata_rejected_tables = {0x05} - state._metadata_retry_counts = {0x05: 2} - state._pending_metadata_cmdids = {0x1111: 0x03} - state._pending_metadata_sent_at = {0x1111: 1.0} - state._pending_metadata_entries = {0x1111: {"03:10": object()}} - state._pending_get_devices_cmdids = {0x2222: 0x03} - state._pending_get_devices_sent_at = {0x2222: 1.0} - state._get_devices_loaded_tables = {0x03} - state._unknown_command_counts = {0x3333: 4} - state._initial_get_devices_sent = True - - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - runtime.reset_protocol_tracking_state() - - assert not state._metadata_requested_tables - assert not state._metadata_loaded_tables - assert not state._metadata_rejected_tables - assert not state._metadata_retry_counts - assert not state._pending_metadata_cmdids - assert not state._pending_metadata_sent_at - assert not state._pending_metadata_entries - assert not state._pending_get_devices_cmdids - assert not state._pending_get_devices_sent_at - assert not state._get_devices_loaded_tables - assert not state._unknown_command_counts - assert state._initial_get_devices_sent is False - - -def test_myrvlink_runtime_handle_metadata_for_event_routes_table_events() -> None: - """Table-scoped status events should route to ensure_metadata_for_table.""" - state = _base_state() - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - requested: list[int] = [] - - runtime.ensure_metadata_for_table = requested.append # type: ignore[method-assign] - runtime.handle_metadata_for_event(RelayStatus(table_id=0x05, device_id=0x22, is_on=True)) - - assert requested == [0x05] - - -def test_myrvlink_runtime_handle_metadata_for_event_routes_list_items() -> None: - """List payloads should recurse into tank status and metadata rows.""" - state = _base_state() - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - requested: list[int] = [] - processed: list[tuple[int, int]] = [] - - runtime.ensure_metadata_for_table = requested.append # type: ignore[method-assign] - runtime.process_metadata = ( # type: ignore[method-assign] - lambda meta: processed.append((meta.table_id, meta.device_id)) - ) - - runtime.handle_metadata_for_event( - [ - TankLevel(table_id=0x03, device_id=0x10, level=66), - DeviceMetadata(table_id=0x03, device_id=0x10, function_name=1, function_instance=0), - ] - ) - - assert requested == [0x03] - assert processed == [(0x03, 0x10)] - - -def test_myrvlink_runtime_record_pending_get_devices_prunes_to_limit() -> None: - """GetDevices cmd tracking should prune oldest entries when max is exceeded.""" - state = _base_state() - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - - runtime.record_pending_get_devices_cmd(0x1001, 0x03, max_pending=1) - runtime.record_pending_get_devices_cmd(0x1002, 0x04, max_pending=1) - - assert state._pending_get_devices_cmdids == {0x1002: 0x04} - assert state._cmd_correlation_stats["pending_cmdid_pruned"] >= 1 - assert state._cmd_correlation_stats["pending_get_devices_peak"] == 1 - - -def test_myrvlink_runtime_prune_pending_command_state_drops_stale_entries(monkeypatch) -> None: - """Stale pending cmd IDs should be removed from both get-devices and metadata maps.""" - state = _base_state() - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - - state._pending_get_devices_cmdids = {0x2001: 0x03} - state._pending_get_devices_sent_at = {0x2001: 10.0} - state._pending_metadata_cmdids = {0x2002: 0x05} - state._pending_metadata_sent_at = {0x2002: 10.0} - state._pending_metadata_entries = {0x2002: {"05:10": object()}} - - monkeypatch.setattr("custom_components.ha_onecontrol.runtime.myrvlink_runtime.time.monotonic", lambda: 100.0) - - runtime.prune_pending_command_state(stale_timeout_s=30.0) - - assert not state._pending_get_devices_cmdids - assert not state._pending_get_devices_sent_at - assert not state._pending_metadata_cmdids - assert not state._pending_metadata_sent_at - assert not state._pending_metadata_entries - assert state._cmd_correlation_stats["pending_cmdid_pruned"] >= 2 - - -def test_myrvlink_runtime_bump_unknown_cmd_count_prunes_when_bounded() -> None: - """Unknown cmdId tracking should prune oldest entries past the configured bound.""" - state = _base_state() - runtime = MyRvLinkRuntime(state, IdsCanRuntime(state)) # type: ignore[arg-type] - - assert runtime.bump_unknown_cmd_count(0x3001, max_unknown=1) == 1 - assert runtime.bump_unknown_cmd_count(0x3002, max_unknown=1) == 1 - - assert state._unknown_command_counts == {0x3002: 1} - assert state._cmd_correlation_stats["unknown_cmdids_pruned"] == 1 From 7fff5cc0bc10d226e2c41ee87d51eb7f35ee3ca7 Mon Sep 17 00:00:00 2001 From: Greg Brunke Date: Sat, 18 Apr 2026 15:15:41 -0400 Subject: [PATCH 6/8] fix: resolve stale import errors and fix entity migration regex for Ethernet --- custom_components/ha_onecontrol/__init__.py | 2 +- custom_components/ha_onecontrol/coordinator.py | 3 +-- custom_components/ha_onecontrol/runtime/__init__.py | 3 +-- 3 files changed, 3 insertions(+), 5 deletions(-) diff --git a/custom_components/ha_onecontrol/__init__.py b/custom_components/ha_onecontrol/__init__.py index d8bbbb8..c0cf545 100644 --- a/custom_components/ha_onecontrol/__init__.py +++ b/custom_components/ha_onecontrol/__init__.py @@ -45,7 +45,7 @@ async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: "hourmeter", ) pattern = re.compile( - r"^([0-9a-f]{12})_(" + r"^([a-z0-9\.]+)_(" + "|".join(re.escape(t) for t in _DEVICE_TYPES) + r")_([0-9a-f]{2})([0-9a-f]{2})$" ) diff --git a/custom_components/ha_onecontrol/coordinator.py b/custom_components/ha_onecontrol/coordinator.py index 662ed76..0a8881b 100644 --- a/custom_components/ha_onecontrol/coordinator.py +++ b/custom_components/ha_onecontrol/coordinator.py @@ -106,7 +106,7 @@ ) from .protocol.dtc_codes import get_name as dtc_get_name, is_fault as dtc_is_fault from .protocol.tea import calculate_step1_key, calculate_step2_key -from .runtime import IdsCanRuntime, MyRvLinkRuntime +from .runtime import IdsCanRuntime _LOGGER = logging.getLogger(__name__) @@ -353,7 +353,6 @@ def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: # Protocol runtimes: keep coordinator as HA-facing facade while # transport/protocol orchestration is split by backend. self._ids_runtime = IdsCanRuntime(self) - self._myrvlink_runtime = MyRvLinkRuntime(self, self._ids_runtime) # Cancel non-critical reconnect/heartbeat tasks as HA stops. if hasattr(self.hass, "bus") and hasattr(self.hass.bus, "async_listen_once"): diff --git a/custom_components/ha_onecontrol/runtime/__init__.py b/custom_components/ha_onecontrol/runtime/__init__.py index be246ec..2b9cae4 100644 --- a/custom_components/ha_onecontrol/runtime/__init__.py +++ b/custom_components/ha_onecontrol/runtime/__init__.py @@ -1,6 +1,5 @@ """Protocol runtime implementations for OneControl.""" from .ids_can_runtime import IdsCanRuntime -from .myrvlink_runtime import MyRvLinkRuntime -__all__ = ["IdsCanRuntime", "MyRvLinkRuntime"] +__all__ = ["IdsCanRuntime"] From 6485cbd0144c0f1f0d44c9a96a944dbd1746948c Mon Sep 17 00:00:00 2001 From: Greg Brunke Date: Sat, 18 Apr 2026 15:26:16 -0400 Subject: [PATCH 7/8] docs: document CAN-to-Ethernet device metadata import process --- README.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/README.md b/README.md index 92ae003..6dac728 100644 --- a/README.md +++ b/README.md @@ -81,20 +81,20 @@ This approach is experimental. If you are attempting this, use the `pairing\_te ## Experimental: CAN-to-Ethernet Bridge -The integration now includes an experimental Ethernet path for older OneControl -systems that expose CAN traffic through an IDS CAN-to-Ethernet bridge. +The integration now includes an experimental Ethernet path for older OneControl systems that expose CAN traffic through an IDS CAN-to-Ethernet bridge. -1. In config flow, select **CAN-to-Ethernet bridge** as the connection type. +**Important Note on Device Names:** The legacy CAN-to-Ethernet bridge uses an older IDS-CAN protocol that does not appear to support device metadata retrieval over the network. To get proper device names (instead of generic names like "Switch 1"), you must manually import the device names from the official app's diagnostic data. -2. The integration listens for UDP advertisements and pre-fills host/port when found. - -3. If discovery does not find the bridge, enter host and port manually. +### How to get your Device Names: +1. Open the official OneControl app on your Android/iOS device while connected to your RV. +2. Generate a diagnostics package through the app settings and email it to yourself. +3. Extract the zip file on your computer and open both `DeviceManifestV1.json` and `DeviceSnapshotV1.json` in a text editor. +4. During the Home Assistant configuration flow, you will be prompted for these files. Copy and paste the entire contents of both files into their respective fields. The integration will use this data to automatically name your devices as they are discovered! -Default bridge values are commonly `192.168.1.1` and `6969`. - -The current implementation assumes payload framing compatibility with the BLE -COBS protocol path. If your bridge wraps frames differently, bridge-specific -framing support will be needed. +### Setup Instructions: +1. In the HA config flow, select **CAN-to-Ethernet bridge** as the connection type. +2. The integration listens for UDP advertisements and pre-fills host/port when found. +3. If discovery does not find the bridge, enter the host and port manually. Default bridge values are commonly `192.168.1.1` and `6969`. ## Supported Devices From 11b9533432acb778c3a12f48e810b881d21e8867 Mon Sep 17 00:00:00 2001 From: Greg Brunke Date: Wed, 22 Apr 2026 22:41:29 -0400 Subject: [PATCH 8/8] fix: correct IDS REMOTE_CONTROL session cipher and harden session lifecycle --- .../ha_onecontrol/runtime/ids_can_runtime.py | 44 +++++++++++++++++-- tests/test_tea.py | 33 ++++++++++++++ 2 files changed, 73 insertions(+), 4 deletions(-) diff --git a/custom_components/ha_onecontrol/runtime/ids_can_runtime.py b/custom_components/ha_onecontrol/runtime/ids_can_runtime.py index 3da514e..e7e8434 100644 --- a/custom_components/ha_onecontrol/runtime/ids_can_runtime.py +++ b/custom_components/ha_onecontrol/runtime/ids_can_runtime.py @@ -40,7 +40,7 @@ _LOGGER = logging.getLogger(__name__) _IDS_SESSION_REMOTE_CONTROL = 0x0004 -_IDS_SESSION_REMOTE_CONTROL_CYPHER = 0xB16B5E95 +_IDS_SESSION_REMOTE_CONTROL_CYPHER = 0xB16B00B5 _IDS_COMMAND_SETTLE_WINDOW_S = 8.0 _HVAC_INVALID_TEMPERATURE_RAW_VALUES = {0x8000, 0x2FF0} @@ -248,7 +248,22 @@ async def ensure_remote_control_session(self, target_address: int) -> bool: sent_heartbeat = await self._send_ids_request(target, 0x44, heartbeat_payload) if sent_heartbeat: self._ids_session_last_heartbeat_at[target] = now - return True + # Yield once so the read loop can process a session-close + # rejection that may arrive immediately after the heartbeat. + await asyncio.sleep(0) + if target not in self._ids_session_opened_at: + _LOGGER.warning( + "PACKET TX IDS session-heartbeat rejected dst=0x%02X; re-opening", + target, + ) + # Fall through to fresh session open below. + pass + else: + return True + else: + return True + else: + return True previous_target = self._ids_active_session_target if previous_target is not None and previous_target != target: @@ -289,13 +304,16 @@ async def ensure_remote_control_session(self, target_address: int) -> bool: await asyncio.wait_for(event.wait(), timeout=1.2) except TimeoutError: _LOGGER.warning( - "PACKET TX IDS session-open timeout dst=0x%02X attempt=%d/%d; skipping IDS command send", + "PACKET TX IDS session-open timeout dst=0x%02X attempt=%d/%d", target, attempt, max_attempts, ) self._ids_session_seed_requested_at.pop(target, None) self._ids_session_results.pop(target, None) + if attempt < max_attempts: + await asyncio.sleep(0.15) + continue return False result = self._ids_session_results.get(target) @@ -1333,6 +1351,21 @@ def cleanup_on_disconnect(self) -> None: self._c._ethernet_reader_task.cancel() self._c._ethernet_reader_task = None self._ids_pending_status_expectations.clear() + # IDS sessions are bound to the TCP connection. After a transport close the + # device has no active session, so any cached opened_at timestamps are stale. + # Clear all session state here so the first command after reconnect goes + # through a fresh handshake rather than falsely reusing a closed session. + self._ids_session_opened_at.clear() + self._ids_session_last_heartbeat_at.clear() + self._ids_session_results.clear() + self._ids_session_seed_requested_at.clear() + self._ids_session_last_status_code.clear() + self._ids_active_session_target = None + # Cancel any pending session waiters so in-flight open attempts don't + # block indefinitely after the transport is gone. + for event in self._ids_session_waiters.values(): + event.set() + self._ids_session_waiters.clear() def handle_frame(self, frame: bytes) -> bool: """Handle IDS-CAN/Ethernet command envelopes and cmdId correlation. @@ -1438,7 +1471,10 @@ def handle_frame(self, frame: bytes) -> bool: likely_related_to_recent_tx = ( target_seen_at is not None and (now - target_seen_at) <= 2.5 ) - log_level = logging.WARNING if likely_related_to_recent_tx else logging.DEBUG + # Always log session-auth responses at WARNING so rejection + # codes (e.g. INVALID_KEY, IN_MOTION_LOCKOUT) are visible. + is_session_request = request_code in {0x42, 0x43, 0x44, 0x45} + log_level = logging.WARNING if (likely_related_to_recent_tx or is_session_request) else logging.DEBUG _LOGGER.log( log_level, "PACKET RX IDS response-status src=0x%02X dst=0x%02X req=0x%02X(%s) status=0x%02X(%s) related_to_recent_tx=%s payload=%s", diff --git a/tests/test_tea.py b/tests/test_tea.py index a83d103..ebc1e60 100644 --- a/tests/test_tea.py +++ b/tests/test_tea.py @@ -1,5 +1,9 @@ """Tests for TEA encryption — verify against known values from the Android app.""" +from custom_components.ha_onecontrol.runtime.ids_can_runtime import ( + IdsCanRuntime, + _IDS_SESSION_REMOTE_CONTROL_CYPHER, +) from custom_components.ha_onecontrol.protocol.tea import ( MASK32, STEP1_CIPHER, @@ -123,3 +127,32 @@ def test_different_pins_different_keys(self): assert k1[:4] == k2[:4] # PIN portions differ assert k1[4:10] != k2[4:10] + + +class TestIdsCanSessionCipher: + """Regression guard for the IDS-CAN REMOTE_CONTROL session cipher constant. + + The expected output below was computed with the correct constant (0xB16B00B5) + and differs from the output of the previously wrong constant (0xB16B5E95). + Any future change to _IDS_SESSION_REMOTE_CONTROL_CYPHER will break this test. + """ + + def test_cipher_constant_matches_decompiled_source(self): + # SESSION_ID(4, 2976579765u) in IDS.Core.IDS_CAN.Descriptors/SESSION_ID.cs + assert _IDS_SESSION_REMOTE_CONTROL_CYPHER == 0xB16B00B5, ( + "IDS REMOTE_CONTROL session cipher must match decompiled SESSION_ID.cs " + "(value=4, cypher=2976579765). Wrong constant causes KEY_NOT_CORRECT on " + "every SESSION_TRANSMIT_KEY, silently blocking all device commands." + ) + + def test_encrypt_known_vector(self): + """Output must match the value computed from the correct C# cipher.""" + # Computed from the C# Encrypt() implementation in SESSION_ID.cs with + # seed=0xDEADBEEF and cypher=0xB16B00B5 (REMOTE_CONTROL session). + # Old wrong cypher (0xB16B5E95) would produce 0xDC3AFD9F instead. + result = IdsCanRuntime._ids_encrypt_session_seed(None, 0xDEADBEEF) # type: ignore[arg-type] + assert result == 0x2A053086, ( + f"IDS session cipher produced 0x{result:08X}, expected 0x2A053086. " + "Check _IDS_SESSION_REMOTE_CONTROL_CYPHER — wrong value causes " + "KEY_NOT_CORRECT (0x0D) on every session auth attempt." + )