diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..7008979 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,83 @@ +# MyHOME - Home Assistant Custom Integration + +## What this is + +A Home Assistant custom integration for **BTicino MyHOME** home automation systems. It communicates with BTicino gateways (F455, MH200N, etc.) over the **OpenWebNet (OWN)** protocol via TCP/IP. + +This project was abandoned by its original author (`anotherjulien`) and is being adopted. + +## Companion library: OWNd + +Located at `/Users/jack/Projects/OWNd` — a Python library that implements the OpenWebNet protocol. This integration depends on it (`OWNd==0.7.48` in manifest, but the local copy is `0.7.49`). + +OWNd handles: gateway discovery (SSDP), TCP connection management, HMAC-SHA authentication, event listening, command sending, and OWN message parsing. + +## Project structure + +``` +custom_components/myhome/ +├── __init__.py # Integration setup, service registration, entity pruning +├── config_flow.py # UI-based configuration (SSDP discovery + manual entry) +├── gateway.py # Gateway handler: event loop, command queue, message routing +├── myhome_device.py # Base entity class for all MyHOME entities +├── const.py # Constants and config keys +├── validate.py # YAML config schema (voluptuous-based) +├── manifest.json # HA integration metadata +├── services.yaml # Service definitions (sync_time, send_message) +├── light.py # Light entities (WHO=1, dimmable/non-dimmable) +├── switch.py # Switch entities (WHO=1, outlet/switch class) +├── cover.py # Cover entities (WHO=2, shutters/blinds) +├── climate.py # Climate entities (WHO=4, heating/cooling zones) +├── binary_sensor.py # Binary sensor entities (WHO=25, dry contacts/motion) +├── sensor.py # Sensor entities (WHO=18 energy, WHO=4 temp, WHO=1 lux) +├── button.py # Button entities (enable/disable commands) +└── translations/ # en, fr, it, nl +``` + +## OpenWebNet protocol basics + +Messages follow the format `*WHO*WHAT*WHERE##`: +- **WHO**: device category (1=Lights, 2=Covers, 4=Climate, 5=Alarm, 18=Energy, 25=DryContacts) +- **WHAT**: action/state (0=off, 1=on, etc.) +- **WHERE**: device address (area+point: "11"=area1/point1, "#N"=group N) + +Status requests: `*#WHO*WHERE##` +Dimension requests: `*#WHO*WHERE*dimension##` + +## Architecture + +1. Config flow discovers gateways via SSDP or manual IP entry +2. Device configuration is in a YAML file (default: `/config/myhome.yaml`) +3. Gateway handler maintains: + - One **event session** (listens for device state changes) + - N **command sessions** (sends commands via async queue) +4. Events are dispatched to entity `handle_event()` methods +5. Commands go through `send_buffer` (asyncio.Queue) + +## Key patterns + +- Entities are looked up via `hass.data[DOMAIN][mac][CONF_PLATFORMS][platform][entity_id][CONF_ENTITIES]` +- Device addresses include optional bus interface: `WHO-WHERE#4#BUS` +- The validate.py schemas transform user-friendly YAML into internal data structures (re-keying by MAC, adding WHO prefixes, etc.) + +## How to run/test + +This is a Home Assistant custom component — it runs inside HA. Install by copying `custom_components/myhome/` into HA's `custom_components/` directory (or via HACS). + +No test suite exists in this repo. + +## Dependencies + +- `OWNd` (OpenWebNet protocol library) +- `aiofiles` (async file I/O for YAML loading) +- `PyYAML` (YAML parsing — comes with HA) +- `voluptuous` (config validation — comes with HA) +- Home Assistant >= 2024.3.0 + +## Current state / known issues + +- Version mismatch: manifest requires `OWNd==0.7.48` but the local OWNd is `0.7.49` +- Typo in `__init__.py` line 65: "Configartion" → "Configuration" +- `gateway.py` line 385 has a buggy log format string (extra positional arg `self.gateway.host`) +- The `PLATFORMS` list in `__init__.py` doesn't include `"button"` but button entities are set up via the validate schema +- No automated tests diff --git a/custom_components/myhome/__init__.py b/custom_components/myhome/__init__.py index e5bdbba..924a92f 100644 --- a/custom_components/myhome/__init__.py +++ b/custom_components/myhome/__init__.py @@ -279,10 +279,14 @@ async def async_unload_entry(hass, entry): for platform in hass.data[DOMAIN][entry.data[CONF_MAC]][CONF_PLATFORMS].keys(): await hass.config_entries.async_forward_entry_unload(entry, platform) - hass.services.async_remove(DOMAIN, "sync_time") - hass.services.async_remove(DOMAIN, "send_message") + if hass.services.has_service(DOMAIN, "sync_time"): + hass.services.async_remove(DOMAIN, "sync_time") + + if hass.services.has_service(DOMAIN, "send_message"): + hass.services.async_remove(DOMAIN, "send_message") gateway_handler = hass.data[DOMAIN][entry.data[CONF_MAC]].pop(CONF_ENTITY) del hass.data[DOMAIN][entry.data[CONF_MAC]] - return await gateway_handler.close_listener() + await gateway_handler.stop() + return True diff --git a/custom_components/myhome/gateway.py b/custom_components/myhome/gateway.py index 821a20d..1db0ede 100644 --- a/custom_components/myhome/gateway.py +++ b/custom_components/myhome/gateway.py @@ -1,5 +1,6 @@ """Code to handle a MyHome Gateway.""" import asyncio +import traceback from typing import Dict, List from homeassistant.const import ( @@ -140,229 +141,247 @@ async def listening_loop(self): self.is_connected = True while not self._terminate_listener: - message = await _event_session.get_next() - LOGGER.debug("%s Message received: `%s`", self.log_id, message) - - if self.generate_events: - if isinstance(message, OWNMessage): - _event_content = {"gateway": str(self.gateway.host)} - _event_content.update(message.event_content) - self.hass.bus.async_fire("myhome_message_event", _event_content) - else: - self.hass.bus.async_fire("myhome_message_event", {"gateway": str(self.gateway.host), "message": str(message)}) - - if not isinstance(message, OWNMessage): - LOGGER.warning( - "%s Data received is not a message: `%s`", + try: + message = await _event_session.get_next() + except asyncio.CancelledError: + raise + except Exception: + LOGGER.error( + "%s Error receiving message:\n%s", self.log_id, - message, + traceback.format_exc(), ) - elif isinstance(message, OWNEnergyEvent): - if SENSOR in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS] and message.entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR]: - for _entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR][message.entity][CONF_ENTITIES]: - if isinstance( - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR][message.entity][CONF_ENTITIES][_entity], - MyHOMEEntity, - ): - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR][message.entity][CONF_ENTITIES][_entity].handle_event(message) - else: - continue - elif ( - isinstance(message, OWNLightingEvent) - or isinstance(message, OWNAutomationEvent) - or isinstance(message, OWNDryContactEvent) - or isinstance(message, OWNAuxEvent) - or isinstance(message, OWNHeatingEvent) - ): - if not message.is_translation: - is_event = False - if isinstance(message, OWNLightingEvent): - if message.is_general: - is_event = True - event = "on" if message.is_on else "off" - self.hass.bus.async_fire( - "myhome_general_light_event", - {"message": str(message), "event": event}, - ) - await asyncio.sleep(0.1) - await self.send_status_request(OWNLightingCommand.status("0")) - elif message.is_area: - is_event = True - event = "on" if message.is_on else "off" - self.hass.bus.async_fire( - "myhome_area_light_event", - { - "message": str(message), - "area": message.area, - "event": event, - }, - ) - await asyncio.sleep(0.1) - await self.send_status_request(OWNLightingCommand.status(message.area)) - elif message.is_group: - is_event = True - event = "on" if message.is_on else "off" - self.hass.bus.async_fire( - "myhome_group_light_event", - { - "message": str(message), - "group": message.group, - "event": event, - }, - ) - elif isinstance(message, OWNAutomationEvent): - if message.is_general: - is_event = True - if message.is_opening and not message.is_closing: - event = "open" - elif message.is_closing and not message.is_opening: - event = "close" - else: - event = "stop" - self.hass.bus.async_fire( - "myhome_general_automation_event", - {"message": str(message), "event": event}, - ) - elif message.is_area: - is_event = True - if message.is_opening and not message.is_closing: - event = "open" - elif message.is_closing and not message.is_opening: - event = "close" - else: - event = "stop" - self.hass.bus.async_fire( - "myhome_area_automation_event", - { - "message": str(message), - "area": message.area, - "event": event, - }, - ) - elif message.is_group: - is_event = True - if message.is_opening and not message.is_closing: - event = "open" - elif message.is_closing and not message.is_opening: - event = "close" - else: - event = "stop" - self.hass.bus.async_fire( - "myhome_group_automation_event", - { - "message": str(message), - "group": message.group, - "event": event, - }, - ) - if not is_event: - if isinstance(message, OWNLightingEvent) and message.brightness_preset: + continue + + LOGGER.debug("%s Message received: `%s`", self.log_id, message) + + try: + if self.generate_events: + if isinstance(message, OWNMessage): + _event_content = {"gateway": str(self.gateway.host)} + _event_content.update(message.event_content) + self.hass.bus.async_fire("myhome_message_event", _event_content) + else: + self.hass.bus.async_fire("myhome_message_event", {"gateway": str(self.gateway.host), "message": str(message)}) + + if not isinstance(message, OWNMessage): + LOGGER.warning( + "%s Data received is not a message: `%s`", + self.log_id, + message, + ) + elif isinstance(message, OWNEnergyEvent): + if SENSOR in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS] and message.entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR]: + for _entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR][message.entity][CONF_ENTITIES]: if isinstance( - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][LIGHT][message.entity][CONF_ENTITIES][LIGHT], + self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR][message.entity][CONF_ENTITIES][_entity], MyHOMEEntity, ): - await self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][LIGHT][message.entity][CONF_ENTITIES][LIGHT].async_update() - else: - for _platform in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS]: - if _platform != BUTTON and message.entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform]: - for _entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES]: - if ( - isinstance( - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES][_entity], - MyHOMEEntity, - ) - and not isinstance( - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES][_entity], - DisableCommandButtonEntity, - ) - and not isinstance( - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES][_entity], - EnableCommandButtonEntity, - ) - ): - self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES][_entity].handle_event(message) - - else: + self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][SENSOR][message.entity][CONF_ENTITIES][_entity].handle_event(message) + else: + continue + elif ( + isinstance(message, OWNLightingEvent) + or isinstance(message, OWNAutomationEvent) + or isinstance(message, OWNDryContactEvent) + or isinstance(message, OWNAuxEvent) + or isinstance(message, OWNHeatingEvent) + ): + if not message.is_translation: + is_event = False + if isinstance(message, OWNLightingEvent): + if message.is_general: + is_event = True + event = "on" if message.is_on else "off" + self.hass.bus.async_fire( + "myhome_general_light_event", + {"message": str(message), "event": event}, + ) + await asyncio.sleep(0.1) + await self.send_status_request(OWNLightingCommand.status("0")) + elif message.is_area: + is_event = True + event = "on" if message.is_on else "off" + self.hass.bus.async_fire( + "myhome_area_light_event", + { + "message": str(message), + "area": message.area, + "event": event, + }, + ) + await asyncio.sleep(0.1) + await self.send_status_request(OWNLightingCommand.status(message.area)) + elif message.is_group: + is_event = True + event = "on" if message.is_on else "off" + self.hass.bus.async_fire( + "myhome_group_light_event", + { + "message": str(message), + "group": message.group, + "event": event, + }, + ) + elif isinstance(message, OWNAutomationEvent): + if message.is_general: + is_event = True + if message.is_opening and not message.is_closing: + event = "open" + elif message.is_closing and not message.is_opening: + event = "close" + else: + event = "stop" + self.hass.bus.async_fire( + "myhome_general_automation_event", + {"message": str(message), "event": event}, + ) + elif message.is_area: + is_event = True + if message.is_opening and not message.is_closing: + event = "open" + elif message.is_closing and not message.is_opening: + event = "close" + else: + event = "stop" + self.hass.bus.async_fire( + "myhome_area_automation_event", + { + "message": str(message), + "area": message.area, + "event": event, + }, + ) + elif message.is_group: + is_event = True + if message.is_opening and not message.is_closing: + event = "open" + elif message.is_closing and not message.is_opening: + event = "close" + else: + event = "stop" + self.hass.bus.async_fire( + "myhome_group_automation_event", + { + "message": str(message), + "group": message.group, + "event": event, + }, + ) + if not is_event: + if isinstance(message, OWNLightingEvent) and message.brightness_preset: + if isinstance( + self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][LIGHT][message.entity][CONF_ENTITIES][LIGHT], + MyHOMEEntity, + ): + await self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][LIGHT][message.entity][CONF_ENTITIES][LIGHT].async_update() + else: + for _platform in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS]: + if _platform != BUTTON and message.entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform]: + for _entity in self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES]: + if ( + isinstance( + self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES][_entity], + MyHOMEEntity, + ) + and not isinstance( + self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES][_entity], + DisableCommandButtonEntity, + ) + and not isinstance( + self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES][_entity], + EnableCommandButtonEntity, + ) + ): + self.hass.data[DOMAIN][self.mac][CONF_PLATFORMS][_platform][message.entity][CONF_ENTITIES][_entity].handle_event(message) + + else: + LOGGER.debug( + "%s Ignoring translation message `%s`", + self.log_id, + message, + ) + elif isinstance(message, OWNHeatingCommand) and message.dimension is not None and message.dimension == 14: + where = message.where[1:] if message.where.startswith("#") else message.where LOGGER.debug( - "%s Ignoring translation message `%s`", + "%s Received heating command, sending query to zone %s", self.log_id, - message, + where, ) - elif isinstance(message, OWNHeatingCommand) and message.dimension is not None and message.dimension == 14: - where = message.where[1:] if message.where.startswith("#") else message.where - LOGGER.debug( - "%s Received heating command, sending query to zone %s", - self.log_id, - where, - ) - await self.send_status_request(OWNHeatingCommand.status(where)) - elif isinstance(message, OWNCENPlusEvent): - event = None - if message.is_short_pressed: - event = CONF_SHORT_PRESS - elif message.is_held or message.is_still_held: - event = CONF_LONG_PRESS - elif message.is_released: - event = CONF_LONG_RELEASE - else: + await self.send_status_request(OWNHeatingCommand.status(where)) + elif isinstance(message, OWNCENPlusEvent): event = None - self.hass.bus.async_fire( - "myhome_cenplus_event", - { - "object": int(message.object), - "pushbutton": int(message.push_button), - "event": event, - }, - ) - LOGGER.info( - "%s %s", - self.log_id, - message.human_readable_log, - ) - elif isinstance(message, OWNCENEvent): - event = None - if message.is_pressed: - event = CONF_SHORT_PRESS - elif message.is_released_after_short_press: - event = CONF_SHORT_RELEASE - elif message.is_held: - event = CONF_LONG_PRESS - elif message.is_released_after_long_press: - event = CONF_LONG_RELEASE - else: + if message.is_short_pressed: + event = CONF_SHORT_PRESS + elif message.is_held or message.is_still_held: + event = CONF_LONG_PRESS + elif message.is_released: + event = CONF_LONG_RELEASE + else: + event = None + self.hass.bus.async_fire( + "myhome_cenplus_event", + { + "object": int(message.object), + "pushbutton": int(message.push_button), + "event": event, + }, + ) + LOGGER.info( + "%s %s", + self.log_id, + message.human_readable_log, + ) + elif isinstance(message, OWNCENEvent): event = None - self.hass.bus.async_fire( - "myhome_cen_event", - { - "object": int(message.object), - "pushbutton": int(message.push_button), - "event": event, - }, - ) - LOGGER.info( - "%s %s", - self.log_id, - message.human_readable_log, - ) - elif isinstance(message, OWNGatewayEvent) or isinstance(message, OWNGatewayCommand): - LOGGER.info( - "%s %s", - self.log_id, - message.human_readable_log, - ) - else: - LOGGER.info( - "%s Unsupported message type: `%s`", + if message.is_pressed: + event = CONF_SHORT_PRESS + elif message.is_released_after_short_press: + event = CONF_SHORT_RELEASE + elif message.is_held: + event = CONF_LONG_PRESS + elif message.is_released_after_long_press: + event = CONF_LONG_RELEASE + else: + event = None + self.hass.bus.async_fire( + "myhome_cen_event", + { + "object": int(message.object), + "pushbutton": int(message.push_button), + "event": event, + }, + ) + LOGGER.info( + "%s %s", + self.log_id, + message.human_readable_log, + ) + elif isinstance(message, OWNGatewayEvent) or isinstance(message, OWNGatewayCommand): + LOGGER.info( + "%s %s", + self.log_id, + message.human_readable_log, + ) + else: + LOGGER.info( + "%s Unsupported message type: `%s`", + self.log_id, + message, + ) + except asyncio.CancelledError: + raise + except Exception: + LOGGER.error( + "%s Error handling message `%s`:\n%s", self.log_id, message, + traceback.format_exc(), ) await _event_session.close() self.is_connected = False - LOGGER.debug("%s Destroying listening worker.", self.log_id) - self.listening_worker.cancel() - async def sending_loop(self, worker_id: int): self._terminate_sender = False @@ -379,8 +398,7 @@ async def sending_loop(self, worker_id: int): task = await self.send_buffer.get() LOGGER.debug( "%s Message `%s` was successfully unqueued by worker %s.", - self.name, - self.gateway.host, + self.log_id, task["message"], worker_id, ) @@ -389,19 +407,29 @@ async def sending_loop(self, worker_id: int): await _command_session.close() - LOGGER.debug( - "%s Destroying sending worker %s", - self.log_id, - worker_id, - ) - self.sending_workers[worker_id].cancel() - async def close_listener(self) -> bool: LOGGER.info("%s Closing event listener", self.log_id) - self._terminate_sender = True + await self.stop() + return True + + async def stop(self): + """Stop all background asyncio workers.""" self._terminate_listener = True + self._terminate_sender = True - return True + if self.listening_worker: + self.listening_worker.cancel() + try: + await self.listening_worker + except asyncio.CancelledError: + pass + + for task in self.sending_workers: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass async def send(self, message: OWNCommand): await self.send_buffer.put({"message": message, "is_status_request": False}) diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..6f94355 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +asyncio_mode = auto diff --git a/requirements_test.txt b/requirements_test.txt new file mode 100644 index 0000000..bf5a72c --- /dev/null +++ b/requirements_test.txt @@ -0,0 +1,3 @@ +pytest>=7.0 +pytest-asyncio>=0.21 +pytest-cov diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..81e6bf3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,136 @@ +"""Shared test fixtures for MyHOME tests. + +Mocks the homeassistant package so custom_components can be imported +without a full HA installation. +""" +import sys +import importlib +import importlib.abc +import importlib.machinery +from unittest.mock import MagicMock + + +# Stub base classes that HA entities inherit from +class _StubEntity: + """Stub for homeassistant.helpers.entity.Entity""" + pass + + +class _StubButtonEntity(_StubEntity): + pass + + +class _StubLightEntity(_StubEntity): + pass + + +class _StubSwitchEntity(_StubEntity): + pass + + +class _StubCoverEntity(_StubEntity): + pass + + +class _StubBinarySensorEntity(_StubEntity): + pass + + +class _StubSensorEntity(_StubEntity): + pass + + +class _StubClimateEntity(_StubEntity): + pass + + +_CONST_VALUES = { + "CONF_ENTITIES": "entities", + "CONF_HOST": "host", + "CONF_PORT": "port", + "CONF_PASSWORD": "password", + "CONF_NAME": "name", + "CONF_MAC": "mac", + "CONF_FRIENDLY_NAME": "friendly_name", + "EntityCategory": MagicMock(), + "SOURCE_REAUTH": "reauth", +} + +_DOMAIN_MAP = { + "homeassistant.components.light": "light", + "homeassistant.components.switch": "switch", + "homeassistant.components.button": "button", + "homeassistant.components.cover": "cover", + "homeassistant.components.binary_sensor": "binary_sensor", + "homeassistant.components.sensor": "sensor", + "homeassistant.components.climate": "climate", +} + +_ENTITY_CLASSES = { + "homeassistant.components.button": {"ButtonEntity": _StubButtonEntity}, + "homeassistant.components.light": {"LightEntity": _StubLightEntity}, + "homeassistant.components.switch": {"SwitchEntity": _StubSwitchEntity}, + "homeassistant.components.cover": {"CoverEntity": _StubCoverEntity}, + "homeassistant.components.binary_sensor": {"BinarySensorEntity": _StubBinarySensorEntity}, + "homeassistant.components.sensor": {"SensorEntity": _StubSensorEntity}, + "homeassistant.components.climate": {"ClimateEntity": _StubClimateEntity}, +} + + +def _make_ha_mock(fullname): + """Create a MagicMock module that satisfies HA imports.""" + mock = MagicMock() + + for prefix, domain in _DOMAIN_MAP.items(): + if fullname == prefix or fullname.startswith(prefix + "."): + mock.DOMAIN = domain + break + + # Set entity base classes as real classes (not MagicMock) + if fullname in _ENTITY_CLASSES: + for attr_name, cls in _ENTITY_CLASSES[fullname].items(): + setattr(mock, attr_name, cls) + + if fullname == "homeassistant.const": + for k, v in _CONST_VALUES.items(): + setattr(mock, k, v) + + if fullname == "homeassistant.helpers.device_registry": + mock.format_mac = lambda x: ":".join( + x.replace(":", "").replace("-", "").replace(".", "").lower()[i:i+2] + for i in range(0, 12, 2) + ) + mock.CONNECTION_NETWORK_MAC = "mac" + + if fullname == "homeassistant.helpers.entity": + mock.Entity = _StubEntity + + if fullname == "homeassistant.helpers.config_validation": + mock.config_entry_only_config_schema = lambda d: None + + return mock + + +class _HALoader(importlib.abc.Loader): + def create_module(self, spec): + return _make_ha_mock(spec.name) + + def exec_module(self, module): + pass + + +_ha_loader = _HALoader() + + +class _HAFinder(importlib.abc.MetaPathFinder): + def find_spec(self, fullname, path, target=None): + if fullname == "homeassistant" or fullname.startswith("homeassistant."): + return importlib.machinery.ModuleSpec( + fullname, + _ha_loader, + is_package=True, + ) + return None + + +sys.meta_path.insert(0, _HAFinder()) diff --git a/tests/test_gateway.py b/tests/test_gateway.py new file mode 100644 index 0000000..f35bf3a --- /dev/null +++ b/tests/test_gateway.py @@ -0,0 +1,294 @@ +"""Tests for the MyHOME gateway listening loop resilience.""" +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch, PropertyMock + +import pytest + +from OWNd.message import OWNLightingEvent, OWNMessage +from OWNd.connection import OWNEventSession + +from custom_components.myhome.gateway import MyHOMEGatewayHandler + + +def make_gateway_handler(generate_events=False): + """Create a MyHOMEGatewayHandler with mocked dependencies.""" + hass = MagicMock() + hass.bus = MagicMock() + hass.bus.async_fire = MagicMock() + hass.data = {} + + config_entry = MagicMock() + config_entry.data = { + "host": "192.168.1.100", + "port": 20000, + "password": "12345", + "ssdp_location": "http://192.168.1.100:8080/desc.xml", + "ssdp_st": "upnp:rootdevice", + "deviceType": "gateway", + "friendly_name": "Test Gateway", + "manufacturer": "BTicino S.p.A.", + "manufacturerURL": "http://www.bticino.it", + "name": "F455", + "firmware": "1.0.0", + "mac": "AA:BB:CC:DD:EE:FF", + "UDN": "uuid:test", + } + + with patch("OWNd.connection.OWNGateway") as mock_gw_class: + mock_gw = MagicMock() + mock_gw.host = "192.168.1.100" + mock_gw.serial = "aa:bb:cc:dd:ee:ff" + mock_gw.log_id = "[Test]" + mock_gw.model_name = "F455" + mock_gw.manufacturer = "BTicino S.p.A." + mock_gw.firmware = "1.0.0" + mock_gw_class.return_value = mock_gw + + handler = MyHOMEGatewayHandler( + hass=hass, + config_entry=config_entry, + generate_events=generate_events, + ) + + return handler + + +@pytest.fixture +def gateway_handler(): + return make_gateway_handler() + + +class TestListeningLoopResilience: + """Tests that the listening loop survives errors without dying.""" + + @pytest.mark.asyncio + async def test_continues_after_get_next_raises(self, gateway_handler): + """If get_next() throws, the loop should log and continue.""" + call_count = 0 + + async def mock_get_next(): + nonlocal call_count + call_count += 1 + if call_count == 1: + raise RuntimeError("Connection blip") + elif call_count == 2: + gateway_handler._terminate_listener = True + return MagicMock(spec=OWNMessage) + return None + + with patch.object(OWNEventSession, "__init__", return_value=None), \ + patch.object(OWNEventSession, "connect", new_callable=AsyncMock), \ + patch.object(OWNEventSession, "get_next", side_effect=mock_get_next), \ + patch.object(OWNEventSession, "close", new_callable=AsyncMock): + + gateway_handler.listening_worker = MagicMock() + await gateway_handler.listening_loop() + + assert call_count == 2, "Loop should have continued past the first exception" + + @pytest.mark.asyncio + async def test_continues_after_message_dispatch_raises(self, gateway_handler): + """If dispatching a message throws (e.g. KeyError for unconfigured device), loop continues.""" + mac = gateway_handler.mac + gateway_handler.hass.data = { + "myhome": { + mac: { + "platforms": {} + } + } + } + + call_count = 0 + mock_event = MagicMock(spec=OWNLightingEvent) + mock_event.is_translation = False + mock_event.is_general = False + mock_event.is_area = False + mock_event.is_group = False + mock_event.brightness_preset = False + mock_event.entity = "1-99" # Non-existent entity -> will cause KeyError in dispatch + + async def mock_get_next(): + nonlocal call_count + call_count += 1 + if call_count == 1: + return mock_event + else: + gateway_handler._terminate_listener = True + return MagicMock(spec=OWNMessage) + + with patch.object(OWNEventSession, "__init__", return_value=None), \ + patch.object(OWNEventSession, "connect", new_callable=AsyncMock), \ + patch.object(OWNEventSession, "get_next", side_effect=mock_get_next), \ + patch.object(OWNEventSession, "close", new_callable=AsyncMock): + + gateway_handler.listening_worker = MagicMock() + await gateway_handler.listening_loop() + + assert call_count == 2, "Loop should have survived the dispatch error" + + @pytest.mark.asyncio + async def test_cancelled_error_propagates(self, gateway_handler): + """asyncio.CancelledError must not be swallowed — HA needs it to stop tasks.""" + async def mock_get_next(): + raise asyncio.CancelledError() + + with patch.object(OWNEventSession, "__init__", return_value=None), \ + patch.object(OWNEventSession, "connect", new_callable=AsyncMock), \ + patch.object(OWNEventSession, "get_next", side_effect=mock_get_next), \ + patch.object(OWNEventSession, "close", new_callable=AsyncMock): + + gateway_handler.listening_worker = MagicMock() + with pytest.raises(asyncio.CancelledError): + await gateway_handler.listening_loop() + + @pytest.mark.asyncio + async def test_cancelled_error_propagates_from_dispatch(self, gateway_handler): + """CancelledError during dispatch must also propagate.""" + mac = gateway_handler.mac + gateway_handler.hass.data = { + "myhome": { + mac: { + "platforms": { + "light": {} + } + } + } + } + + mock_event = MagicMock(spec=OWNLightingEvent) + mock_event.is_translation = False + mock_event.is_general = False + mock_event.is_area = False + mock_event.is_group = False + mock_event.brightness_preset = True + mock_event.entity = "1-11" + + # Make the entity lookup raise CancelledError + platforms_dict = MagicMock() + platforms_dict.__contains__ = lambda self, x: x != "button" + platforms_dict.__iter__ = lambda self: iter(["light"]) + platforms_dict.__getitem__ = MagicMock(side_effect=asyncio.CancelledError()) + gateway_handler.hass.data["myhome"][mac]["platforms"] = platforms_dict + + call_count = 0 + + async def mock_get_next(): + nonlocal call_count + call_count += 1 + if call_count == 1: + return mock_event + gateway_handler._terminate_listener = True + return MagicMock(spec=OWNMessage) + + with patch.object(OWNEventSession, "__init__", return_value=None), \ + patch.object(OWNEventSession, "connect", new_callable=AsyncMock), \ + patch.object(OWNEventSession, "get_next", side_effect=mock_get_next), \ + patch.object(OWNEventSession, "close", new_callable=AsyncMock): + + gateway_handler.listening_worker = MagicMock() + with pytest.raises(asyncio.CancelledError): + await gateway_handler.listening_loop() + + @pytest.mark.asyncio + async def test_is_connected_flag(self, gateway_handler): + """is_connected should be True while listening and False after exit.""" + async def mock_get_next(): + assert gateway_handler.is_connected is True + gateway_handler._terminate_listener = True + return MagicMock(spec=OWNMessage) + + with patch.object(OWNEventSession, "__init__", return_value=None), \ + patch.object(OWNEventSession, "connect", new_callable=AsyncMock), \ + patch.object(OWNEventSession, "get_next", side_effect=mock_get_next), \ + patch.object(OWNEventSession, "close", new_callable=AsyncMock): + + gateway_handler.listening_worker = MagicMock() + await gateway_handler.listening_loop() + + assert gateway_handler.is_connected is False + + +class TestSendingLoop: + """Tests for the command sending worker.""" + + @pytest.mark.asyncio + async def test_send_queues_message(self, gateway_handler): + """send() should put message on the buffer.""" + mock_message = MagicMock() + await gateway_handler.send(mock_message) + + queued = gateway_handler.send_buffer.get_nowait() + assert queued["message"] is mock_message + assert queued["is_status_request"] is False + + @pytest.mark.asyncio + async def test_send_status_request_queues_message(self, gateway_handler): + """send_status_request() should put message on the buffer with is_status_request=True.""" + mock_message = MagicMock() + await gateway_handler.send_status_request(mock_message) + + queued = gateway_handler.send_buffer.get_nowait() + assert queued["message"] is mock_message + assert queued["is_status_request"] is True + + +class TestStop: + """Tests for the stop() method that cleanly shuts down workers.""" + + @pytest.fixture + def gateway_handler(self): + return make_gateway_handler() + + @pytest.mark.asyncio + async def test_stop_cancels_listening_worker(self, gateway_handler): + """stop() should cancel the listening worker and await it.""" + async def block_forever(): + await asyncio.sleep(999) + + task = asyncio.ensure_future(block_forever()) + gateway_handler.listening_worker = task + + await gateway_handler.stop() + + assert task.cancelled() + assert gateway_handler._terminate_listener is True + assert gateway_handler._terminate_sender is True + + @pytest.mark.asyncio + async def test_stop_cancels_all_sending_workers(self, gateway_handler): + """stop() should cancel all sending workers.""" + async def block_forever(): + await asyncio.sleep(999) + + tasks = [asyncio.ensure_future(block_forever()) for _ in range(3)] + gateway_handler.sending_workers = tasks + + await gateway_handler.stop() + + for task in tasks: + assert task.cancelled() + + @pytest.mark.asyncio + async def test_stop_handles_cancelled_error_from_workers(self, gateway_handler): + """stop() should handle CancelledError raised when awaiting cancelled tasks.""" + + async def raise_cancelled(): + raise asyncio.CancelledError() + + listening_task = asyncio.ensure_future(raise_cancelled()) + # Let it raise + await asyncio.sleep(0) + + gateway_handler.listening_worker = listening_task + + # Should not raise + await gateway_handler.stop() + + @pytest.mark.asyncio + async def test_stop_with_no_workers(self, gateway_handler): + """stop() should handle the case where no workers were started.""" + assert gateway_handler.listening_worker is None + assert gateway_handler.sending_workers == [] + + # Should not raise + await gateway_handler.stop()