From 8be6f4d0f0da7d78b7231f9b12fe553924dc3b06 Mon Sep 17 00:00:00 2001 From: Giacomo Pinato Date: Thu, 18 Jun 2026 11:10:48 +0200 Subject: [PATCH] Improve gateway resilience and worker lifecycle management Prevent the event listener from crashing the entire integration when it encounters an unconfigured device or unexpected message. Errors during message receive or dispatch are now logged and skipped, while asyncio.CancelledError still propagates for clean HA shutdown. Also adds proper worker lifecycle via stop() method (from PR #187) so removing and re-adding the integration no longer throws errors from pending tasks. Includes a test suite with 11 unit tests covering resilience, shutdown, and message queuing. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 83 +++++ custom_components/myhome/__init__.py | 10 +- custom_components/myhome/gateway.py | 462 ++++++++++++++------------- pytest.ini | 3 + requirements_test.txt | 3 + tests/__init__.py | 0 tests/conftest.py | 136 ++++++++ tests/test_gateway.py | 294 +++++++++++++++++ 8 files changed, 771 insertions(+), 220 deletions(-) create mode 100644 CLAUDE.md create mode 100644 pytest.ini create mode 100644 requirements_test.txt create mode 100644 tests/__init__.py create mode 100644 tests/conftest.py create mode 100644 tests/test_gateway.py 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()