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/README.md b/README.md index 401bfbc..6dac728 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. + +**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. + +### 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! + +### 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 - **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..c0cf545 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__) @@ -42,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})$" ) @@ -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) @@ -110,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( @@ -130,6 +137,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: @@ -142,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/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..e7de9ab 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 @@ -10,20 +11,35 @@ 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, + CONNECTION_TYPE_ETHERNET, + CONF_CONNECTION_TYPE, CONF_BLUETOOTH_PIN, + 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, + ETH_DISCOVERY_LISTEN_SECS, DEFAULT_GATEWAY_PIN, DOMAIN, GATEWAY_NAME_PREFIX, 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 _LOGGER = logging.getLogger(__name__) @@ -33,12 +49,20 @@ 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 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 +96,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 +155,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 +304,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, } @@ -196,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 b925671..c09261d 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,15 @@ 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" +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 +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..0a8881b 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 @@ -44,9 +45,17 @@ 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_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, @@ -73,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, @@ -92,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 _LOGGER = logging.getLogger(__name__) @@ -138,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: @@ -176,6 +191,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 +213,19 @@ 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._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 @@ -207,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]] = {} @@ -220,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, @@ -228,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 @@ -272,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. @@ -297,6 +350,16 @@ 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) + + # 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 @@ -307,8 +370,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 @@ -332,6 +402,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) @@ -342,12 +459,51 @@ 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) # ------------------------------------------------------------------ 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) + 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: raise BleakError("Not connected to gateway") encoded = cobs_encode(raw_command) @@ -358,6 +514,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) @@ -365,6 +543,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_brightness_command( + table_id, + device_id, + brightness, + ) + if used_ids_native: + _LOGGER.warning( + "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-set 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) @@ -379,6 +579,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, ) @@ -397,10 +626,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( @@ -594,6 +857,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 @@ -705,12 +972,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, @@ -739,6 +1022,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, @@ -760,6 +1074,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") @@ -784,6 +1102,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() @@ -791,8 +1115,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 @@ -832,6 +1158,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 +1172,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,11 +1290,171 @@ 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.""" + await self._ids_runtime.connect() + + async def _try_connect_ethernet(self, attempt: int) -> None: + """Open TCP connection to Ethernet bridge and start reader task.""" + await self._ids_runtime._try_connect(attempt) + + async def _ethernet_read_loop(self) -> None: + """Read Ethernet bytes and decode COBS frames into protocol events.""" + await self._ids_runtime.read_loop() + + async def _close_ethernet_transport(self) -> None: + """Close active Ethernet socket and reader task.""" + await self._ids_runtime.close_transport() + + 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 + + # 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( @@ -1058,11 +1552,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: @@ -1404,18 +1893,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. @@ -1473,18 +1951,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. @@ -1551,10 +2067,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.""" @@ -1563,6 +2084,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. @@ -1571,22 +2106,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: @@ -1594,14 +2162,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 @@ -1627,9 +2202,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 @@ -1827,7 +2408,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, @@ -1896,7 +2498,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) @@ -1932,7 +2533,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) @@ -1945,7 +2545,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 @@ -1955,14 +2554,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) @@ -1970,7 +2565,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 @@ -1982,50 +2576,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. @@ -2074,7 +2642,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 +2662,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", "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, 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 @@ -2118,9 +2707,14 @@ def _on_disconnect(self, client: BleakClient) -> None: self._pin_agent_ctx = None self.hass.async_create_task(ctx.cleanup()) + if transport == "ethernet": + 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: @@ -2131,6 +2725,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() @@ -2159,6 +2756,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 @@ -2202,7 +2801,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/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..e7ee145 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,11 +43,34 @@ 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), + "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), } @@ -107,9 +133,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..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, @@ -23,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.commands import CommandBuilder from .protocol.events import DimmableLight, RgbLight @@ -119,12 +121,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) @@ -244,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.""" @@ -266,12 +290,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) @@ -316,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: @@ -335,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: @@ -344,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/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/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/protocol/events.py b/custom_components/ha_onecontrol/protocol/events.py index 56e5ba0..10cae6e 100644 --- a/custom_components/ha_onecontrol/protocol/events.py +++ b/custom_components/ha_onecontrol/protocol/events.py @@ -266,6 +266,23 @@ 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 = "" + raw_device_capability: int = 0 + + # ── Parsers ─────────────────────────────────────────────────────────────── @@ -667,7 +684,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 +694,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 +703,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..2b9cae4 --- /dev/null +++ b/custom_components/ha_onecontrol/runtime/__init__.py @@ -0,0 +1,5 @@ +"""Protocol runtime implementations for OneControl.""" + +from .ids_can_runtime import IdsCanRuntime + +__all__ = ["IdsCanRuntime"] 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..e7e8434 --- /dev/null +++ b/custom_components/ha_onecontrol/runtime/ids_can_runtime.py @@ -0,0 +1,1755 @@ +"""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, Callable + +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 = 0xB16B00B5 +_IDS_COMMAND_SETTLE_WINDOW_S = 8.0 +_HVAC_INVALID_TEMPERATURE_RAW_VALUES = {0x8000, 0x2FF0} + + +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]] = {} + 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.""" + 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 _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) + 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 + + @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: + 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 + + 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 + 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 + # 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: + 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", + 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) + 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)) + raw_device_capability = int(semantic_fields.get("device_capabilities", 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, + raw_device_capability=raw_device_capability, + ) + 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 {13, 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 == 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( + 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. + """ + 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-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=20) + if resolved is None: + _LOGGER.warning( + "PACKET TX IDS light-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 != 20: + _LOGGER.warning( + "PACKET TX IDS light-set skipped key=%s reason=identity-mismatch protocol=%d device_type=%d", + key, + identity.protocol, + identity.device_type, + ) + 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]) + + 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", + ) + 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() + event = DimmableLight( + table_id=table_id & 0xFF, + device_id=device_id & 0xFF, + brightness=clamped_brightness, + mode=mode, + ) + self._c.dimmable_lights[key] = event + self._dispatch_state_event(event) + + _LOGGER.warning( + "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, + 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 + + 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, + ]) + + 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", + ) + 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() + 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) + + _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 + + 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]) + + 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", + ) + 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) + 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) + + _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 + + 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]) + + 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", + ) + if not sent or raw is None: + return False + + 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(), + ) + 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 + + # RelayBasicLatching Type2 parity: + # Off=commandByte 0x00, On=commandByte 0x01, empty data payload. + 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", + ) + 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() + 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) + + _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() + # 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. + + 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 + ) + # 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", + 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/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..be26665 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,11 +48,31 @@ } }, "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", "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/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..be26665 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,11 +48,31 @@ } }, "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", "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/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..835d248 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 @@ -36,8 +36,50 @@ def __repr__(self) -> str: "homeassistant.components.sensor", "voluptuous", "bleak", + "bleak.backends", + "bleak.backends.characteristic", "bleak.exc", ] 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)) + + +# 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_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..46b9262 --- /dev/null +++ b/tests/test_config_flow_ethernet.py @@ -0,0 +1,65 @@ +"""Tests for Ethernet-specific config flow behavior.""" + +from __future__ import annotations + +import asyncio +from types import SimpleNamespace + +from custom_components.ha_onecontrol.config_flow import ( + OneControlConfigFlow, + OneControlOptionsFlow, +) + + +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 + + +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_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_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_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..611d1c3 --- /dev/null +++ b/tests/test_ids_can_runtime.py @@ -0,0 +1,950 @@ +"""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 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 + + +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_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() + 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 + + +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_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_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) diff --git a/tests/test_tea.py b/tests/test_tea.py index 3d5c063..ebc1e60 100644 --- a/tests/test_tea.py +++ b/tests/test_tea.py @@ -1,13 +1,18 @@ """Tests for TEA encryption — verify against known values from the Android app.""" -from custom_components.onecontrol.protocol.tea import ( +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, + STEP2_CIPHER, calculate_step1_key, calculate_step2_key, tea_decrypt, tea_encrypt, ) -from custom_components.onecontrol.const import STEP1_CIPHER, STEP2_CIPHER class TestTeaEncrypt: @@ -122,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." + )