From b948f673e36666a6892e14fbd6ce70cfbc2b0964 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:55:02 +0200 Subject: [PATCH 01/26] feat(cover,lock): support impulse gates, garage doors, door entry locks, and access safety --- README.md | 1 + .../myhome/door_gate_motion_notification.yaml | 147 ++++ .../doorbell_intercom_notification.yaml | 117 ++++ .../myhome/secure_access_approval.yaml | 92 +++ custom_components/myhome/access_policy.py | 646 ++++++++++++++++++ custom_components/myhome/const.py | 27 + custom_components/myhome/cover.py | 375 +++++++++- custom_components/myhome/gateway_events.py | 26 + custom_components/myhome/icons.json | 6 + custom_components/myhome/light.py | 12 +- custom_components/myhome/lock.py | 324 +++++++++ custom_components/myhome/migrate.py | 1 + custom_components/myhome/quality_scale.yaml | 2 +- custom_components/myhome/services.yaml | 21 + custom_components/myhome/strings.json | 8 + custom_components/myhome/translations/en.json | 8 + custom_components/myhome/translations/nl.json | 8 + custom_components/myhome/validate.py | 147 +++- docs/configuration/services.md | 38 ++ docs/configuration/supported_functions.md | 8 + scripts/update_supported_domains.py | 8 + scripts/verify_ha_standards.py | 3 +- tests/test_access_policy.py | 606 ++++++++++++++++ tests/test_component_impulse_cover.py | 406 +++++++++++ tests/test_component_lock.py | 291 ++++++++ tests/test_door_entry_events.py | 121 ++++ 26 files changed, 3439 insertions(+), 10 deletions(-) create mode 100644 blueprints/automation/myhome/door_gate_motion_notification.yaml create mode 100644 blueprints/automation/myhome/doorbell_intercom_notification.yaml create mode 100644 blueprints/automation/myhome/secure_access_approval.yaml create mode 100644 custom_components/myhome/access_policy.py create mode 100644 custom_components/myhome/lock.py create mode 100644 tests/test_access_policy.py create mode 100644 tests/test_component_impulse_cover.py create mode 100644 tests/test_component_lock.py create mode 100644 tests/test_door_entry_events.py diff --git a/README.md b/README.md index 5fa24dd80..f19425c89 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,7 @@ We now maintain a comprehensive, community-curated **[GitHub Wiki](https://githu | **`light`** | WHO=1 | On/Off, Dimmers with brightness control & transitions (stepped & native), DALI DT8 Tunable White (Dimension 14, 2000K–6535K), HS/RGB colour, Hardware-offloaded bus timers (`myhome.turn_on_timed` / `timer` parameter) | | **`switch`** | WHO=1 | Relays, auxiliary switches, socket actuators (switch/outlet device classes), Hardware-offloaded bus timers (`myhome.turn_on_timed` / `timer` parameter) | | **`cover`** | WHO=2 | Motorized shutters, blinds, roll-ups with state tracking, position-reporting actuators & virtual travel-time positioning | +| **`lock`** | WHO=1 / WHO=6 | Pulse-driven electric strikes and gates (`WHO=1`) with constant-time PIN authentication and lockout protection; native door entry strikes and doorbell ring events (`WHO=6`) | | **`climate`** | WHO=4 | Heating, cooling, 4-pipe systems, thermostats, setpoints, fancoil 3-speed modes, offset tracking, Central Unit 3550 (`#0`) & 4695 (`#0#1`) master coordination & seasonal propagation | | **`alarm_control_panel`** | WHO=5 | Central units (3485/3486), read-only state (disarmed / armed away / triggered), zone 0 broadcast sync; arm/disarm via AUX frames ([docs](docs/configuration/alarm.md)) | | **`binary_sensor`** | WHO=1 / 9 / 25 | Magnetic contacts, door/window sensors, PIR motion, AUX channels (1–9), dry contacts (F482/3477), inverted contacts | diff --git a/blueprints/automation/myhome/door_gate_motion_notification.yaml b/blueprints/automation/myhome/door_gate_motion_notification.yaml new file mode 100644 index 000000000..7649fb338 --- /dev/null +++ b/blueprints/automation/myhome/door_gate_motion_notification.yaml @@ -0,0 +1,147 @@ +blueprint: + name: "MyHOME: Door & Gate Motion & Open-Alert Notifications" + description: >- + Sends real-time push notifications when a protected MyHOME garage door or gate + starts opening, begins closing, or reaches confirmed closed state, plus an alert + if the door is left open for too long. + domain: automation + source_url: https://github.com/OpenWebNet-HA/MyHOME/blob/v2-phase1-architecture/blueprints/automation/myhome/door_gate_motion_notification.yaml + input: + target_cover: + name: "Gate or Garage Door" + description: "Select the MyHOME cover entity to monitor." + selector: + entity: + domain: cover + integration: myhome + notify_service: + name: "Mobile App Notify Action" + description: "Target mobile app notify action (e.g. notify.mobile_app_iphone)." + selector: + text: + notify_on_opening: + name: "Notify When Opening Starts" + default: true + selector: + boolean: + notify_on_closed: + name: "Notify When Fully Closed" + default: true + selector: + boolean: + notify_on_closing: + name: "Notify When Closing Starts" + default: false + selector: + boolean: + left_open_minutes: + name: "Alert If Left Open (Minutes)" + description: "Send a reminder alert if the door remains open longer than this duration (0 = disabled)." + default: 10 + selector: + number: + min: 0 + max: 120 + step: 1 + unit_of_measurement: "min" + mode: slider + +mode: restart +max_exceeded: silent + +trigger: + # 1. Door starts opening + - platform: state + entity_id: !input target_cover + to: "opening" + id: "opening" + + # 2. Door starts closing + - platform: state + entity_id: !input target_cover + to: "closing" + id: "closing" + + # 3. Door confirmed closed + - platform: state + entity_id: !input target_cover + to: "closed" + id: "closed" + + # 4. Door left open for prolonged duration + - platform: state + entity_id: !input target_cover + to: "open" + for: + minutes: !input left_open_minutes + id: "left_open" + +variables: + cover_entity: !input target_cover + cover_name: "{{ state_attr(cover_entity, 'friendly_name') | default(cover_entity) }}" + notify_opening_enabled: !input notify_on_opening + notify_closing_enabled: !input notify_on_closing + notify_closed_enabled: !input notify_on_closed + open_minutes: !input left_open_minutes + +action: + - choose: + # Opening + - conditions: + - condition: trigger + id: "opening" + - condition: template + value_template: "{{ notify_opening_enabled }}" + sequence: + - action: !input notify_service + data: + title: "🚪 {{ cover_name }} wordt geopend" + message: "De deur/poort is in beweging richting open." + data: + tag: "myhome_door_motion_{{ cover_entity }}" + + # Closing + - conditions: + - condition: trigger + id: "closing" + - condition: template + value_template: "{{ notify_closing_enabled }}" + sequence: + - action: !input notify_service + data: + title: "⚠️ {{ cover_name }} begint te sluiten" + message: "De deur/poort is in beweging richting dicht." + data: + tag: "myhome_door_motion_{{ cover_entity }}" + + # Closed + - conditions: + - condition: trigger + id: "closed" + - condition: template + value_template: "{{ notify_closed_enabled }}" + sequence: + - action: !input notify_service + data: + title: "✅ {{ cover_name }} is gesloten" + message: "De deur/poort is nu veilig en volledig gesloten." + data: + tag: "myhome_door_motion_{{ cover_entity }}" + + # Left Open Alert + - conditions: + - condition: trigger + id: "left_open" + - condition: template + value_template: "{{ open_minutes > 0 }}" + sequence: + - action: !input notify_service + data: + title: "⚠️ Opgelet: {{ cover_name }} staat open!" + message: "De deur/poort staat al langer dan {{ open_minutes }} minuten open." + data: + tag: "myhome_door_left_open_{{ cover_entity }}" + actions: + - action: "MYHOME_CLOSE_LEFT_OPEN_{{ cover_entity }}" + title: "🔒 Sluit Nu" + destructive: true diff --git a/blueprints/automation/myhome/doorbell_intercom_notification.yaml b/blueprints/automation/myhome/doorbell_intercom_notification.yaml new file mode 100644 index 000000000..fbf4d4e21 --- /dev/null +++ b/blueprints/automation/myhome/doorbell_intercom_notification.yaml @@ -0,0 +1,117 @@ +blueprint: + name: "MyHOME: Intercom & Doorbell Press Notification" + description: >- + Dispatches an actionable mobile push notification with sound, optional live camera snapshot, + and a one-tap 'Open Gate / Unlock Door' button whenever someone rings the BTicino + intercom entrance panel (WHO=6 doorbell event). + domain: automation + source_url: https://github.com/OpenWebNet-HA/MyHOME/blob/v2-phase1-architecture/blueprints/automation/myhome/doorbell_intercom_notification.yaml + input: + notify_service: + name: "Mobile App Notify Action" + description: "Target mobile app notify action (e.g. notify.mobile_app_iphone or notify.mobile_app_pixel)." + selector: + text: + camera_entity: + name: "Intercom / Entrance Camera (Optional)" + description: "Camera entity pointing at the entrance to attach a live snapshot to the notification." + default: "" + selector: + entity: + domain: camera + door_entity: + name: "Gate or Door Lock to Open (Optional)" + description: "Cover or lock entity that can be unlocked directly from the notification." + default: "" + selector: + entity: + domain: + - cover + - lock + custom_title: + name: "Notification Title" + default: "🔔 Deurbel: Iemand belt aan!" + selector: + text: + custom_message: + name: "Notification Message" + default: "Er wordt aangebeld bij de buitenpost." + selector: + text: + +mode: restart +max_exceeded: silent + +trigger: + - platform: event + event_type: myhome_doorbell_event + +variables: + door_target: !input door_entity + camera_target: !input camera_entity + action_open: "MYHOME_DOORBELL_OPEN_{{ trigger.event.data.where }}_{{ now().timestamp() | int }}" + action_dismiss: "MYHOME_DOORBELL_DISMISS_{{ now().timestamp() | int }}" + +action: + - choose: + # If camera is configured, take a snapshot first + - conditions: + - condition: template + value_template: "{{ camera_target != '' }}" + sequence: + - action: camera.snapshot + target: + entity_id: !input camera_entity + data: + filename: "/config/www/myhome_doorbell_latest.jpg" + + - action: !input notify_service + data: + title: !input custom_title + message: >- + {{ custom_message }} (Oproep van buitenpost {{ trigger.event.data.where }}) + data: + image: >- + {% if camera_target != '' %}/local/myhome_doorbell_latest.jpg?v={{ now().timestamp() | int }}{% else %}{% endif %} + push: + sound: "chime.caf" + category: "MYHOME_DOORBELL" + actions: >- + {% if door_target != '' %} + [ + {"action": "{{ action_open }}", "title": "🔓 Open Poort / Deur", "destructive": false}, + {"action": "{{ action_dismiss }}", "title": "❌ Negeren", "destructive": true} + ] + {% else %} + [ + {"action": "{{ action_dismiss }}", "title": "OK", "destructive": false} + ] + {% endif %} + + - choose: + - conditions: + - condition: template + value_template: "{{ door_target != '' }}" + sequence: + - wait_for_trigger: + - platform: event + event_type: mobile_app_notification_action + event_data: + action: "{{ action_open }}" + timeout: "00:01:00" + continue_on_timeout: false + - choose: + - conditions: + - condition: template + value_template: "{{ door_target.startswith('cover.') }}" + sequence: + - action: cover.open_cover + target: + entity_id: !input door_entity + - conditions: + - condition: template + value_template: "{{ door_target.startswith('lock.') }}" + sequence: + - action: lock.unlock + target: + entity_id: !input door_entity diff --git a/blueprints/automation/myhome/secure_access_approval.yaml b/blueprints/automation/myhome/secure_access_approval.yaml new file mode 100644 index 000000000..382e1636e --- /dev/null +++ b/blueprints/automation/myhome/secure_access_approval.yaml @@ -0,0 +1,92 @@ +blueprint: + name: "MyHOME: Secure Door & Gate Biometric Approval" + description: >- + Dispatches interactive mobile push notifications requiring biometric verification + (FaceID/TouchID on iOS, Biometric/Screen Unlock on Android) whenever a protected + MyHOME impulse gate or garage door operation is requested. Automatically handles + the cryptographic single-use nonce for approval, and allows immediate cancellation. + domain: automation + source_url: https://github.com/OpenWebNet-HA/MyHOME/blob/master/blueprints/automation/myhome/secure_access_approval.yaml + input: + notify_service: + name: "Mobile App Notify Action" + description: "The notify action for your mobile device (e.g. notify.mobile_app_iphone or action notify.mobile_app_pixel)." + selector: + text: + target_cover: + name: "Protected Impulse Cover (Optional filter)" + description: "Optionally restrict this approval handler to a specific gate or garage door entity." + default: "" + selector: + entity: + domain: cover + integration: myhome + +mode: parallel +max: 10 + +trigger: + - platform: event + event_type: myhome_access_approval_requested + +condition: + - condition: template + value_template: >- + {{ not target_cover or trigger.event.data.entity_id == target_cover }} + +action: + - action: !input notify_service + data: + title: "⚠️ Beveiligingsverzoek: {{ trigger.event.data.entity_id }}" + message: >- + Verzoek tot {{ trigger.event.data.intent }} door {{ trigger.event.data.user_id | default('onbekend') }}. + Scan gezicht of vingerafdruk om te bevestigen. + data: + push: + category: "MYHOME_SECURE_ACCESS" + actions: + - action: "MYHOME_APPROVE_{{ trigger.event.data.nonce }}" + title: "✅ Bevestig (Biometrisch)" + authenticationRequired: true + auth_required: true + destructive: false + - action: "MYHOME_DENY_{{ trigger.event.data.nonce }}" + title: "❌ Weigeren" + destructive: true + + - wait_for_trigger: + - platform: event + event_type: mobile_app_notification_action + event_data: + action: "MYHOME_APPROVE_{{ trigger.event.data.nonce }}" + - platform: event + event_type: mobile_app_notification_action + event_data: + action: "MYHOME_DENY_{{ trigger.event.data.nonce }}" + timeout: + seconds: "{{ trigger.event.data.timeout | default(45) }}" + continue_on_timeout: false + + - choose: + - conditions: + - condition: template + value_template: "{{ wait.trigger.event.data.action == 'MYHOME_APPROVE_' ~ trigger.event.data.nonce }}" + sequence: + - action: >- + {% if trigger.event.data.intent == 'open' %} + cover.open_cover + {% else %} + cover.close_cover + {% endif %} + target: + entity_id: "{{ trigger.event.data.entity_id }}" + data: + nonce: "{{ trigger.event.data.nonce }}" + + - conditions: + - condition: template + value_template: "{{ wait.trigger.event.data.action == 'MYHOME_DENY_' ~ trigger.event.data.nonce }}" + sequence: + - action: myhome.cancel_cover_request + target: + entity_id: "{{ trigger.event.data.entity_id }}" diff --git a/custom_components/myhome/access_policy.py b/custom_components/myhome/access_policy.py new file mode 100644 index 000000000..e3b0cdedc --- /dev/null +++ b/custom_components/myhome/access_policy.py @@ -0,0 +1,646 @@ +"""Human-in-the-loop access policy for impulse gates and garage doors. + +A WHO=1 impulse relay wired to a gate or garage motor has no notion of +direction: every pulse advances the motor's own open / stop / close / stop +cycle. Home Assistant is not a safety function (force limitation and photocells +in the motor are), so this module only makes sure Home Assistant never *causes* +a dangerous movement: + +* Nothing moves on a service call. ``cover.open_cover`` / ``close_cover`` / + ``stop_cover`` - from a dashboard, voice assistant, widget or automation - + only create an approval request for an allowed Home Assistant user. +* The request is approved on that user's own phone through an actionable + notification with ``authenticationRequired`` (iOS, Android 12+: the phone + must be unlocked), bound to a single-use 128-bit random nonce, the user, the + door and the direction. A replayed or forged approval does nothing. +* A pulse is only an *open* when the state sensor proves the door is closed. + In every other state the same pulse can close the door, so the close policy + applies: confirmed safety devices, a requester on site (or a camera image in + the approval), a pre-warning that a wall-button press cancels, and a watchdog + that latches a fault - never an automatic retry - when the door does not + reach the expected state. +* Anything unknown fails closed: an unavailable sensor, a missing person + entity, an unavailable block switch. +""" + +from __future__ import annotations + +import asyncio +import hmac +import secrets +import time +from collections.abc import Awaitable, Callable, Mapping +from dataclasses import dataclass, field +from datetime import date, timedelta +from enum import StrEnum +from typing import Any + +from homeassistant.const import ( + STATE_HOME, + STATE_OFF, + STATE_ON, +) +from homeassistant.core import CALLBACK_TYPE, Context, Event, HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.helpers.event import async_call_later +from homeassistant.util import dt as dt_util + +from .const import ( + CONF_ALLOWED_USERS, + CONF_APPROVAL_TIMEOUT, + CONF_APPROVERS, + CONF_CAMERA, + CONF_CLOSE_BLOCK_ENTITY, + CONF_PREWARN_LIGHT, + CONF_PREWARN_SECONDS, + CONF_REMOTE_CLOSE, + CONF_SAFETY_CHECK_DAYS, + CONF_SAFETY_DEVICES_VERIFIED, + CONF_WATCHDOG_MARGIN, + EVENT_ACCESS_PREWARNING, + LOGGER, +) + +EVENT_NOTIFICATION_ACTION = "mobile_app_notification_action" +ACTION_PREFIX = "MYHOME_ACCESS_" +NOTIFICATION_GROUP = "myhome_access" + +DEFAULT_SAFETY_CHECK_DAYS = 31 +DEFAULT_PREWARN_SECONDS = 5.0 +DEFAULT_WATCHDOG_MARGIN = 10.0 +DEFAULT_APPROVAL_TIMEOUT = 60.0 +DEFAULT_MAX_FAILED_PINS = 3 +DEFAULT_LOCKOUT_SECONDS = 300.0 + + +class Intent(StrEnum): + """What the requester asked for.""" + + OPEN = "open" + CLOSE = "close" + STOP = "stop" + + +class Effect(StrEnum): + """What a pulse can physically do given the known door state.""" + + OPEN = "open" + MAY_CLOSE = "may_close" + + +class Phase(StrEnum): + """Lifecycle of one access operation.""" + + IDLE = "idle" + PENDING = "pending_approval" + PREWARNING = "prewarning" + MONITORING = "monitoring" + FAULT = "fault" + + +class RemoteClose(StrEnum): + """When a pulse that can close the door may be sent from Home Assistant.""" + + NEVER = "never" + AT_HOME = "at_home" + WITH_CAMERA = "with_camera" + + +class AccessDenied(ServiceValidationError): + """An access request or approval was refused by the policy.""" + + +@dataclass(frozen=True) +class AccessConfig: + """Validated access policy of one impulse cover (see ``validate.access_schema``).""" + + allowed_users: tuple[str, ...] = () + approvers: Mapping[str, str] = field(default_factory=dict) + remote_close: RemoteClose = RemoteClose.NEVER + safety_devices_verified: date | None = None + safety_check_days: int = DEFAULT_SAFETY_CHECK_DAYS + camera: str | None = None + prewarn_light: str | None = None + prewarn_seconds: float = DEFAULT_PREWARN_SECONDS + close_block_entity: str | None = None + watchdog_margin: float = DEFAULT_WATCHDOG_MARGIN + approval_timeout: float = DEFAULT_APPROVAL_TIMEOUT + pin_code: str | None = None + max_failed_pins: int = DEFAULT_MAX_FAILED_PINS + lockout_seconds: float = DEFAULT_LOCKOUT_SECONDS + + @classmethod + def from_config(cls, cfg: Mapping[str, Any] | None, pin_code: str | None = None) -> AccessConfig: + """Build the policy from the ``access`` block of a cover (``None``: nobody is allowed).""" + cfg = cfg or {} + verified = cfg.get(CONF_SAFETY_DEVICES_VERIFIED) + return cls( + allowed_users=tuple(str(u) for u in cfg.get(CONF_ALLOWED_USERS, ())), + approvers={str(k): str(v) for k, v in (cfg.get(CONF_APPROVERS) or {}).items()}, + remote_close=RemoteClose(str(cfg.get(CONF_REMOTE_CLOSE, RemoteClose.NEVER))), + safety_devices_verified=verified if isinstance(verified, date) else None, + safety_check_days=int(cfg.get(CONF_SAFETY_CHECK_DAYS, DEFAULT_SAFETY_CHECK_DAYS)), + camera=cfg.get(CONF_CAMERA), + prewarn_light=cfg.get(CONF_PREWARN_LIGHT), + prewarn_seconds=float(cfg.get(CONF_PREWARN_SECONDS, DEFAULT_PREWARN_SECONDS)), + close_block_entity=cfg.get(CONF_CLOSE_BLOCK_ENTITY), + watchdog_margin=float(cfg.get(CONF_WATCHDOG_MARGIN, DEFAULT_WATCHDOG_MARGIN)), + approval_timeout=float(cfg.get(CONF_APPROVAL_TIMEOUT, DEFAULT_APPROVAL_TIMEOUT)), + pin_code=pin_code, + ) + + def safety_check_due(self) -> date | None: + """Date the periodic photocell / force test falls due (``None``: never confirmed).""" + if self.safety_devices_verified is None: + return None + return self.safety_devices_verified + timedelta(days=self.safety_check_days) + + +def classify(intent: Intent, state_sensor: str | None, sensor_state: str | None) -> Effect: + """Decide what a pulse can do, or refuse it. + + Only a door the sensor reports *closed* is opened by a pulse. Without a + sensor the direction is unknown, so every pulse can close the door. A + configured sensor that does not report ``on`` / ``off`` refuses everything. + """ + if state_sensor is None: + return Effect.MAY_CLOSE + if sensor_state not in (STATE_ON, STATE_OFF): + raise AccessDenied(f"State sensor {state_sensor} is not reporting ({sensor_state}); refusing to pulse") + if sensor_state == STATE_OFF: + if intent is Intent.OPEN: + return Effect.OPEN + raise AccessDenied("The door is closed; a pulse would open it") + if intent is Intent.OPEN: + raise AccessDenied("The door is not closed; a pulse could close it") + return Effect.MAY_CLOSE + + +def _same(a: str | None, b: str | None) -> bool: + """Constant-time comparison of two secrets (``None`` never matches).""" + if a is None or b is None: + return False + return hmac.compare_digest(a.encode("utf-8"), b.encode("utf-8")) + + +@dataclass +class PendingRequest: + """One approval request in flight. The nonce never leaves memory except to the approver's phone.""" + + nonce: str + user_id: str + intent: Intent + effect: Effect + created: float + notify_service: str + at_home: bool + + +class AccessController: + """Approval, pre-warning and watchdog state machine of one impulse cover.""" + + def __init__( + self, + hass: HomeAssistant | None, + *, + name: str, + config: AccessConfig, + state_sensor: str | None, + travel_time: float, + send_pulse: Callable[[Intent, Effect], Awaitable[bool]], + publish: Callable[[], None], + entity_id: Callable[[], str | None], + ) -> None: + self.hass = hass + self._name = name + self.config = config + self._state_sensor = state_sensor + self._travel_time = travel_time + self._send_pulse = send_pulse + self._publish = publish + self._entity_id = entity_id + + self.phase = Phase.IDLE + self.fault_reason: str | None = None + self._pending: PendingRequest | None = None + self._unsub_action: CALLBACK_TYPE | None = None + self._unsub_expiry: CALLBACK_TYPE | None = None + self._unsub_watchdog: CALLBACK_TYPE | None = None + self._cancel_event: asyncio.Event | None = None + self._task: asyncio.Task[None] | None = None + self._failed_pins: dict[str, tuple[int, float]] = {} + + # ── Public API ─────────────────────────────────────────────────────── + + def attributes(self) -> dict[str, Any]: + """State attributes; never contains a nonce or a PIN.""" + due = self.config.safety_check_due() + return { + "access_phase": str(self.phase), + "remote_close": str(self.config.remote_close), + "safety_check_due": due.isoformat() if due else None, + "fault_reason": self.fault_reason, + "pin_protected": self.config.pin_code is not None, + } + + @callback + def _settle(self) -> None: + """Return to rest: FAULT while a fault is latched, else IDLE.""" + self.phase = Phase.FAULT if self.fault_reason is not None else Phase.IDLE + self._publish() + + async def async_request(self, intent: Intent, context: Context | None) -> None: + """Create an approval request for ``intent``; raises :class:`AccessDenied` when refused.""" + hass = self._require_hass() + user_id = context.user_id if context is not None else None + if self.phase in (Phase.PENDING, Phase.PREWARNING, Phase.MONITORING): + raise AccessDenied(f"{self._name}: another operation is in progress ({self.phase})") + self._check_user(user_id) + assert user_id is not None # _check_user refuses None + self._check_lockout(user_id) + effect = classify(intent, self._state_sensor, self._sensor_state()) + at_home = self._is_home(user_id) + if effect is Effect.MAY_CLOSE: + self._check_close_allowed(at_home) + + pending = PendingRequest( + nonce=secrets.token_urlsafe(16), + user_id=user_id, + intent=intent, + effect=effect, + created=time.monotonic(), + notify_service=self.config.approvers[user_id], + at_home=at_home, + ) + self._pending = pending + self.phase = Phase.PENDING + self._unsub_action = hass.bus.async_listen(EVENT_NOTIFICATION_ACTION, self._on_action) + self._unsub_expiry = async_call_later(hass, self.config.approval_timeout, self._on_expired) + try: + await self._notify_approval(pending) + except Exception as err: + self._clear_pending() + self._settle() + raise HomeAssistantError(f"{self._name}: could not send the approval request: {err}") from err + LOGGER.info("%s: %s requested by user %s, waiting for approval", self._name, intent, user_id) + self._publish() + + @callback + def cancel(self, reason: str) -> bool: + """Cancel a pending request or a running pre-warning. Returns True if something was cancelled.""" + if self.phase is Phase.PENDING and self._pending is not None: + pending = self._pending + self._clear_pending() + self._settle() + self._spawn(self._notify(pending.notify_service, f"{self._name}: request cancelled ({reason})")) + return True + if self.phase is Phase.PREWARNING and self._cancel_event is not None: + self._cancel_event.set() + return True + return False + + async def async_cancel(self, context: Context | None) -> None: + """Service entry: an allowed user cancels the pending request or pre-warning.""" + user_id = context.user_id if context is not None else None + if user_id is not None: + self._check_user(user_id) + if not self.cancel("cancelled by user"): + raise AccessDenied(f"{self._name}: nothing to cancel") + + async def async_acknowledge_fault(self, context: Context | None) -> None: + """Clear a latched fault; only an allowed user who is at home (on site) may do so.""" + user_id = context.user_id if context is not None else None + self._check_user(user_id) + assert user_id is not None + if self.fault_reason is None: + raise AccessDenied(f"{self._name}: there is no fault to acknowledge") + if self.phase is not Phase.FAULT: + raise AccessDenied(f"{self._name}: wait until the current operation has finished") + if not self._is_home(user_id): + raise AccessDenied(f"{self._name}: a fault can only be acknowledged on site (person must be home)") + LOGGER.warning("%s: fault '%s' acknowledged by user %s", self._name, self.fault_reason, user_id) + self.fault_reason = None + self._settle() + + async def async_shutdown(self) -> None: + """Cancel every timer, listener and task (entity removal).""" + self._clear_pending() + self._cancel_watchdog() + if self._cancel_event is not None: + self._cancel_event.set() + if self._task is not None and not self._task.done(): + self._task.cancel() + try: + await self._task + except (asyncio.CancelledError, Exception): # pragma: no cover - teardown only + pass + self._task = None + + # ── Policy checks ──────────────────────────────────────────────────── + + def _require_hass(self) -> HomeAssistant: + if self.hass is None: + raise HomeAssistantError(f"{self._name}: not attached to Home Assistant") + return self.hass + + def _check_user(self, user_id: str | None) -> None: + if user_id is None: + raise AccessDenied( + f"{self._name}: automations, scripts and integrations may not operate this door; " + "a person must request it" + ) + if user_id not in self.config.allowed_users or user_id not in self.config.approvers: + raise AccessDenied(f"{self._name}: this user is not allowed to operate this door") + + def _check_lockout(self, user_id: str) -> None: + fails, until = self._failed_pins.get(user_id, (0, 0.0)) + if fails >= self.config.max_failed_pins and time.monotonic() < until: + raise AccessDenied(f"{self._name}: too many wrong PINs; try again later") + + def _check_close_allowed(self, at_home: bool) -> None: + cfg = self.config + if self.phase is Phase.FAULT: + raise AccessDenied( + f"{self._name}: the last movement ended in a fault ({self.fault_reason}); acknowledge it on site first" + ) + if cfg.remote_close is RemoteClose.NEVER: + raise AccessDenied(f"{self._name}: a pulse here can close the door, and closing from Home Assistant is disabled") + due = cfg.safety_check_due() + if due is None: + raise AccessDenied(f"{self._name}: the door's safety devices (photocells, force limit) are not confirmed") + if dt_util.now().date() > due: + raise AccessDenied(f"{self._name}: the periodic safety test was due on {due.isoformat()}") + if cfg.close_block_entity is not None: + block = self.hass.states.get(cfg.close_block_entity) if self.hass is not None else None + if block is None or block.state != STATE_OFF: + raise AccessDenied( + f"{self._name}: closing is blocked by {cfg.close_block_entity} " + f"({block.state if block is not None else 'missing'})" + ) + if not at_home: + if cfg.remote_close is RemoteClose.AT_HOME: + raise AccessDenied(f"{self._name}: a pulse that can close the door needs you at home, in sight of the door") + if cfg.camera is None: + raise AccessDenied(f"{self._name}: closing from away needs a camera image of the door") + + def _sensor_state(self) -> str | None: + if self._state_sensor is None or self.hass is None: + return None + state = self.hass.states.get(self._state_sensor) + return state.state if state is not None else None + + def _is_home(self, user_id: str) -> bool: + if self.hass is None: + return False + for person in self.hass.states.async_all("person"): + if person.attributes.get("user_id") == user_id: + return person.state == STATE_HOME + return False + + # ── Approval flow ──────────────────────────────────────────────────── + + def _action_ids(self, pending: PendingRequest) -> tuple[str, str]: + return f"{ACTION_PREFIX}APPROVE_{pending.nonce}", f"{ACTION_PREFIX}DENY_{pending.nonce}" + + def _tag(self) -> str: + return f"{NOTIFICATION_GROUP}_{self._entity_id() or self._name}" + + def _describe(self, pending: PendingRequest) -> str: + if pending.effect is Effect.OPEN: + return "open" + if self._state_sensor is None: + return "pulse (direction unknown: it can close)" + return "stop" if pending.intent is Intent.STOP else "close" + + async def _user_name(self, user_id: str) -> str: + hass = self._require_hass() + user = await hass.auth.async_get_user(user_id) + return user.name if user is not None and user.name else "unknown user" + + async def _notify_approval(self, pending: PendingRequest) -> None: + approve, deny = self._action_ids(pending) + what = self._describe(pending) + closing = pending.effect is Effect.MAY_CLOSE + approve_action: dict[str, Any] = { + "action": approve, + "title": "Area clear - " + what if closing else what.capitalize(), + "authenticationRequired": True, + "destructive": closing, + } + if self.config.pin_code is not None: + approve_action.update( + {"behavior": "textInput", "textInputButtonTitle": approve_action["title"], "textInputPlaceholder": "PIN"} + ) + message = f"{await self._user_name(pending.user_id)} asks to {what} {self._name}." + if closing: + message += " Approve only if the door area is clear." + message += f" Expires in {int(self.config.approval_timeout)} s." + data: dict[str, Any] = { + "actions": [approve_action, {"action": deny, "title": "Cancel"}], + "tag": self._tag(), + "group": NOTIFICATION_GROUP, + "ttl": 0, + "priority": "high", + "push": {"interruption-level": "time-sensitive"}, + } + if self.config.camera is not None: + data["image"] = f"/api/camera_proxy/{self.config.camera}" + await self._call_notify( + pending.notify_service, {"title": f"{self._name}: approve {what}?", "message": message, "data": data} + ) + + async def _call_notify(self, service: str, payload: dict[str, Any]) -> None: + hass = self._require_hass() + domain, _, name = service.partition(".") + await hass.services.async_call(domain, name, payload, blocking=True) + + async def _notify(self, service: str, message: str) -> None: + """Best-effort status message; a failing notifier never changes the outcome.""" + try: + await self._call_notify(service, {"title": self._name, "message": message}) + except Exception as err: # noqa: BLE001 - status messages are best effort + LOGGER.warning("%s: could not send status notification: %s", self._name, err) + + async def _notify_all(self, message: str) -> None: + for service in sorted(set(self.config.approvers.values())): + await self._notify(service, message) + + async def _clear_notification(self, service: str) -> None: + try: + await self._call_notify(service, {"message": "clear_notification", "data": {"tag": self._tag()}}) + except Exception as err: # noqa: BLE001 - clearing is cosmetic + LOGGER.debug("%s: could not clear notification: %s", self._name, err) + + @callback + def _on_expired(self, _now: Any = None) -> None: + self._unsub_expiry = None + if self.phase is not Phase.PENDING or self._pending is None: + return + pending = self._pending + self._clear_pending() + self._settle() + self._spawn(self._clear_notification(pending.notify_service)) + LOGGER.info("%s: approval request expired", self._name) + + @callback + def _on_action(self, event: Event) -> None: + pending = self._pending + action = event.data.get("action") + if pending is None or not isinstance(action, str) or not action.startswith(ACTION_PREFIX): + return + approve, deny = self._action_ids(pending) + is_approve, is_deny = _same(action, approve), _same(action, deny) + if not (is_approve or is_deny): + return # another door's request, or a stale one + if event.context.user_id != pending.user_id: + # mobile_app fires notification actions with the registration's user; + # an approval from anyone else is ignored and the request stays pending. + LOGGER.warning("%s: approval from a different user ignored", self._name) + return + self._clear_pending() + if is_deny: + self._settle() + LOGGER.info("%s: request denied on the phone", self._name) + return + if self.config.pin_code is not None: + reply = event.data.get("reply_text", event.data.get("textInput")) + if not _same(str(reply) if reply is not None else None, self.config.pin_code): + self._register_failed_pin(pending.user_id) + self._settle() + self._spawn(self._notify(pending.notify_service, f"{self._name}: wrong PIN, nothing moved")) + return + self._failed_pins.pop(pending.user_id, None) + self._spawn(self._async_execute(pending)) + + def _register_failed_pin(self, user_id: str) -> None: + fails, _ = self._failed_pins.get(user_id, (0, 0.0)) + fails += 1 + self._failed_pins[user_id] = (fails, time.monotonic() + self.config.lockout_seconds) + LOGGER.warning("%s: wrong PIN from user %s (%d)", self._name, user_id, fails) + + def _clear_pending(self) -> None: + if self._unsub_action is not None: + self._unsub_action() + self._unsub_action = None + if self._unsub_expiry is not None: + self._unsub_expiry() + self._unsub_expiry = None + self._pending = None + + def _spawn(self, coro: Awaitable[None]) -> None: + hass = self._require_hass() + task = hass.async_create_task(coro) # type: ignore[arg-type] + if isinstance(task, asyncio.Task): + self._task = task + + # ── Execution ──────────────────────────────────────────────────────── + + def _revalidate(self, pending: PendingRequest) -> None: + """Every check again on the state of *now*: the door may have moved since the request.""" + if time.monotonic() - pending.created > self.config.approval_timeout: + raise AccessDenied(f"{self._name}: the approval arrived too late") + self._check_user(pending.user_id) + effect = classify(pending.intent, self._state_sensor, self._sensor_state()) + if effect is not pending.effect: + raise AccessDenied(f"{self._name}: the door state changed since the request") + if effect is Effect.MAY_CLOSE: + self._check_close_allowed(self._is_home(pending.user_id)) + + async def _async_execute(self, pending: PendingRequest) -> None: + user = await self._user_name(pending.user_id) + what = self._describe(pending) + try: + self._revalidate(pending) + if pending.effect is Effect.MAY_CLOSE: + self.phase = Phase.PREWARNING + self._publish() + if await self._prewarn(): + raise AccessDenied(f"{self._name}: cancelled during the warning") + self._revalidate(pending) + if not await self._send_pulse(pending.intent, pending.effect): + raise AccessDenied(f"{self._name}: the pulse was dropped (too soon after the previous one)") + except AccessDenied as err: + self._settle() + await self._notify(pending.notify_service, f"Not executed: {err}") + return + LOGGER.info("%s: %s executed for user %s", self._name, what, pending.user_id) + await self._notify_all(f"{self._name}: {what} by {user}") + self._start_watchdog(pending) + + async def _prewarn(self) -> bool: + """Flash the warning light and announce; return True when cancelled.""" + hass = self._require_hass() + self._cancel_event = asyncio.Event() + hass.bus.async_fire( + EVENT_ACCESS_PREWARNING, + {"entity_id": self._entity_id(), "name": self._name, "seconds": self.config.prewarn_seconds}, + ) + light = self.config.prewarn_light + original = hass.states.get(light) if light is not None else None + steps = max(1, int(round(self.config.prewarn_seconds))) + interval = self.config.prewarn_seconds / steps + cancelled = False + try: + for _ in range(steps): + if light is not None: + await self._light_call("toggle", light) + try: + await asyncio.wait_for(self._cancel_event.wait(), interval) + cancelled = True + break + except TimeoutError: + continue + finally: + if light is not None and original is not None and original.state in (STATE_ON, STATE_OFF): + await self._light_call("turn_on" if original.state == STATE_ON else "turn_off", light) + self._cancel_event = None + return cancelled + + async def _light_call(self, service: str, entity_id: str) -> None: + try: + await self._require_hass().services.async_call("light", service, {"entity_id": entity_id}, blocking=True) + except Exception as err: # noqa: BLE001 - the warning light is a courtesy, not a safety function + LOGGER.warning("%s: warning light %s failed: %s", self._name, entity_id, err) + + def _start_watchdog(self, pending: PendingRequest) -> None: + hass = self._require_hass() + if self._state_sensor is None: + self._settle() + self._spawn(self._notify_all(f"{self._name}: the result cannot be verified (no state sensor)")) + return + if pending.intent is Intent.STOP: + self._settle() + return + expected = STATE_ON if pending.effect is Effect.OPEN else STATE_OFF + timeout = self.config.watchdog_margin + if pending.effect is Effect.MAY_CLOSE: + timeout += self._travel_time + self.phase = Phase.MONITORING + self._publish() + + @callback + def _expired(_now: Any = None) -> None: + self._unsub_watchdog = None + if self._sensor_state() == expected: + self._settle() + return + if pending.effect is Effect.OPEN: + self._settle() + self._spawn(self._notify_all(f"{self._name}: did not start opening")) + return + self.fault_reason = "did not close: possible obstacle or reversal" + self._settle() + LOGGER.error("%s: %s", self._name, self.fault_reason) + self._spawn( + self._notify_all( + f"ALARM {self._name}: {self.fault_reason}. Closing from Home Assistant is blocked " + "until the fault is acknowledged on site." + ) + ) + + self._unsub_watchdog = async_call_later(hass, timeout, _expired) + + def _cancel_watchdog(self) -> None: + if self._unsub_watchdog is not None: + self._unsub_watchdog() + self._unsub_watchdog = None diff --git a/custom_components/myhome/const.py b/custom_components/myhome/const.py index 32bbed7a4..2e9934bf9 100644 --- a/custom_components/myhome/const.py +++ b/custom_components/myhome/const.py @@ -59,6 +59,7 @@ def get_ownd_version() -> str: Platform.MEDIA_PLAYER, Platform.BUTTON, Platform.ALARM_CONTROL_PANEL, + Platform.LOCK, ) CONF_ADDRESS = "address" CONF_OWN_PASSWORD = "password" @@ -121,6 +122,32 @@ def get_ownd_version() -> str: CONF_MIN_MOTOR_PULSE = "min_motor_pulse" DEFAULT_MIN_MOTOR_PULSE = 0.4 +# Impulse cover / access controls (gates, garage doors, impulse relays) +CONF_TYPE = "type" +TYPE_IMPULSE_RELAY = "impulse_relay" +CONF_PIN_CODE = "pin_code" +CONF_STATE_SENSOR = "state_sensor" +CONF_PULSE_DURATION = "pulse_duration" +DEFAULT_PULSE_DURATION = 0.5 +CONF_MIN_CYCLE_TIME = "min_cycle_time" +DEFAULT_MIN_CYCLE_TIME = 5.0 +# Access policy of impulse gates / garage doors (see access_policy.py) +CONF_ACCESS = "access" +CONF_ALLOWED_USERS = "allowed_users" +CONF_APPROVERS = "approvers" +CONF_REMOTE_CLOSE = "remote_close" +CONF_SAFETY_DEVICES_VERIFIED = "safety_devices_verified" +CONF_SAFETY_CHECK_DAYS = "safety_check_days" +CONF_CAMERA = "camera" +CONF_PREWARN_LIGHT = "prewarn_light" +CONF_PREWARN_SECONDS = "prewarn_seconds" +CONF_CLOSE_BLOCK_ENTITY = "close_block_entity" +CONF_WATCHDOG_MARGIN = "watchdog_margin" +CONF_APPROVAL_TIMEOUT = "approval_timeout" +EVENT_ACCESS_PREWARNING = "myhome_access_prewarning" +SERVICE_CANCEL_COVER_REQUEST = "cancel_cover_request" +SERVICE_ACKNOWLEDGE_COVER_FAULT = "acknowledge_cover_fault" + # Cover calibration (timed covers measure their own travel times on the bus) CONF_COVER_TRAVEL_TIMES = "cover_travel_times" # config entry option: {device_id: {...}} SERVICE_CALIBRATE_COVER = "calibrate_cover" diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index 539abfefd..a2fe4c507 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -3,6 +3,7 @@ import asyncio import time +from collections.abc import Mapping from datetime import timedelta from typing import TYPE_CHECKING, Any @@ -21,22 +22,34 @@ ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + CONF_MAC, CONF_NAME, STATE_CLOSED, + STATE_OFF, + STATE_ON, STATE_OPEN, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.helpers import entity_platform +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later, async_track_state_change_event from homeassistant.util import dt as dt_util from OWNd.message import ( OWNAutomationCommand, OWNAutomationEvent, + OWNLightingCommand, OWNMessage, ) +from .access_policy import ( + AccessConfig, + AccessController, + Effect, + Intent, +) from .const import ( BUS_ROUTING, CALIBRATION_CUTOFF_MAX, @@ -47,17 +60,26 @@ CALIBRATION_MIN_RUN, CALIBRATION_RUN_TIMEOUT, CALIBRATION_SETTLE, + CONF_ACCESS, CONF_ADVANCED_SHUTTER, CONF_COVER_TRAVEL_TIMES, + CONF_DEVICE_CLASS, CONF_DEVICE_MODEL, CONF_ENTITY_NAME, CONF_MANUFACTURER, CONF_MEMBERS, + CONF_MIN_CYCLE_TIME, CONF_MIN_MOTOR_PULSE, + CONF_PIN_CODE, + CONF_PULSE_DURATION, CONF_SLAT_TILT, CONF_SLAT_TIME, + CONF_STATE_SENSOR, CONF_TRAVEL_TIME, + CONF_WHO, + DEFAULT_MIN_CYCLE_TIME, DEFAULT_MIN_MOTOR_PULSE, + DEFAULT_PULSE_DURATION, DEFAULT_SLAT_TIME, DEFAULT_TRAVEL_TIME, DOMAIN, @@ -65,7 +87,9 @@ LOGGER, MAX_SLAT_TIME, MIN_SLAT_TIME, + SERVICE_ACKNOWLEDGE_COVER_FAULT, SERVICE_CALIBRATE_COVER, + SERVICE_CANCEL_COVER_REQUEST, SERVICE_RESET_COVER_TRAVEL_TIME, SERVICE_SET_COVER_TRAVEL_TIME, SERVICE_STOP_COVER_CALIBRATION, @@ -96,7 +120,13 @@ quantize_position_delta, travel_for, ) -from .discovery import Address, DeviceContext, PlatformDiscovery, default_known_keys +from .discovery import ( + Address, + DeviceContext, + PlatformDiscovery, + default_known_keys, + parse_unique_id, +) from .gateway import MyHOMEGatewayHandler from .myhome_device import MyHOMEEntity @@ -116,14 +146,36 @@ def _clamp_slat_time(value: Any, fallback: float = DEFAULT_SLAT_TIME) -> float: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback) -> None: - """Set up the covers of a gateway (WHO=2): registry, myhome.yaml, then bus discovery.""" + """Set up the covers of a gateway: WHO=2 shutters and WHO=1 impulse covers.""" from .cover_scope import CoverFamily, CoverScope, MyHOMEScopeCover runtime = config_entry.runtime_data + mac = config_entry.data[CONF_MAC] + gateway = runtime.gateway if runtime.calibration_hub is None: runtime.calibration_hub = get_calibration_hub(runtime.gateway) family = CoverFamily() + def cover_registry_address(target_who: str): + def address_of(entry: er.RegistryEntry) -> Address | None: + who, device_id = parse_unique_id(entry.unique_id or "", gateway.mac, mac) + entry_who = who if who is not None else "2" + if entry_who != target_who: + return None + return Address.from_device_id(device_id) + + return address_of + + def accept_shutter(ctx: DeviceContext) -> bool: + if ctx.source == "yaml": + return str(ctx.cfg.get(CONF_WHO, "2")) == "2" + return True + + def accept_impulse(ctx: DeviceContext) -> bool: + if ctx.source == "yaml": + return str(ctx.cfg.get(CONF_WHO, "2")) == "1" + return True + def build(ctx: DeviceContext) -> MyHOMECover: cfg = ctx.cfg kwargs: dict[str, Any] = {} @@ -161,6 +213,33 @@ def build(ctx: DeviceContext) -> MyHOMECover: family.add(cover) return cover + def build_impulse(ctx: DeviceContext) -> MyHOMEImpulseCover: + cfg = ctx.cfg + raw_dev_class = cfg.get(CONF_DEVICE_CLASS) or cfg.get("device_class") + try: + device_class = CoverDeviceClass(str(raw_dev_class)) if raw_dev_class else CoverDeviceClass.GATE + except ValueError: + device_class = CoverDeviceClass.GATE + return MyHOMEImpulseCover( + hass=hass, + name=cfg.get(CONF_NAME) or f"Gate {ctx.suffix}", + entity_name=cfg.get(CONF_ENTITY_NAME), + device_id=ctx.key, + who=ctx.who, + where=ctx.address.where, + interface=ctx.address.interface, + device_class=device_class, + travel_time=float(cfg.get(CONF_TRAVEL_TIME, DEFAULT_TRAVEL_TIME)), + pulse_duration=float(cfg.get(CONF_PULSE_DURATION, cfg.get("pulse_duration", DEFAULT_PULSE_DURATION))), + min_cycle_time=float(cfg.get(CONF_MIN_CYCLE_TIME, cfg.get("min_cycle_time", DEFAULT_MIN_CYCLE_TIME))), + pin_code=str(cfg[CONF_PIN_CODE]) if CONF_PIN_CODE in cfg else (str(cfg["pin_code"]) if "pin_code" in cfg else None), + state_sensor=str(cfg[CONF_STATE_SENSOR]) if CONF_STATE_SENSOR in cfg else (str(cfg["state_sensor"]) if "state_sensor" in cfg else None), + manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"), + model=cfg.get(CONF_DEVICE_MODEL, "Impulse Cover"), + gateway=runtime.gateway, + access_config=cfg.get(CONF_ACCESS), + ) + @callback def relay_general(message) -> None: # type: ignore """A general command (WHERE=0) moves every cover, scoped to its interface if behind an F422.""" @@ -179,10 +258,18 @@ def relay_scope(message, address: Address) -> None: # type: ignore PlatformDiscovery( hass, config_entry, async_add_entities, platform=PLATFORM, who="2", event_type=OWNAutomationEvent, build=build, announce=True, + registry_address=cover_registry_address("2"), accept=accept_shutter, on_general=relay_general, on_scope=relay_scope, known_keys=lambda ctx: [*default_known_keys(ctx), "general"], ).start() + PlatformDiscovery( + hass, config_entry, async_add_entities, + platform=PLATFORM, who="1", event_type=None, build=build_impulse, announce=True, + registry_address=cover_registry_address("1"), accept=accept_impulse, + known_keys=lambda ctx: [*default_known_keys(ctx), ctx.address.where, ctx.address.clean_where], + ).start(listen=False) + SCHEMA_SET_COVER_TRAVEL_TIME = { vol.Optional("travel_time"): vol.Coerce(float), vol.Optional("travel_time_down"): vol.Coerce(float), @@ -206,6 +293,16 @@ def relay_scope(message, address: Address) -> None: # type: ignore {}, "async_reset_travel_time", ) + platform.async_register_entity_service( + SERVICE_CANCEL_COVER_REQUEST, + {}, + "async_cancel_request", + ) + platform.async_register_entity_service( + SERVICE_ACKNOWLEDGE_COVER_FAULT, + {}, + "async_acknowledge_fault", + ) async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: # pylint: disable=unused-argument @@ -2060,6 +2157,277 @@ def handle_event(self, message: OWNMessage) -> None: self._publish_state() +class MyHOMEImpulseCover(MyHOMEEntity, CoverEntity): + """An impulse-driven gate or garage door powered by a WHO=1 relay. + + A service call never moves the door. ``open_cover``, ``close_cover`` and + ``stop_cover`` create an approval request through the cover's + :class:`~.access_policy.AccessController`; the relay is pulsed only after an + allowed person approves it on their phone, and a pulse that can close the + door additionally needs confirmed safety devices, a requester in sight of + the door and a pre-warning. See ``access_policy.py``. + """ + + _poll_on_add = False + + def __init__( + self, + hass: HomeAssistant | None, + name: str, + entity_name: str | None, + device_id: str, + who: str, + where: str, + interface: str | None, + device_class: CoverDeviceClass, + travel_time: float, + pulse_duration: float, + min_cycle_time: float, + pin_code: str | None, + state_sensor: str | None, + manufacturer: str, + model: str, + gateway: MyHOMEGatewayHandler, + access_config: Mapping[str, Any] | None = None, + ) -> None: + super().__init__( + hass=hass, + name=name, + platform=PLATFORM, + device_id=device_id, + who=who, + where=where, + manufacturer=manufacturer, + model=model, + gateway=gateway, + entity_name=entity_name, + ) + self._interface = interface + self._full_where = f"{self._where}#4#{self._interface}" if self._interface is not None else self._where + self._attr_device_class = device_class + self._travel_time = travel_time + self._pulse_duration = pulse_duration + self._min_cycle_time = min_cycle_time + self._state_sensor = state_sensor + + self._attr_supported_features = ( + CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE | CoverEntityFeature.STOP + ) + self._attr_is_opening = False + self._attr_is_closing = False + self._attr_is_closed: bool | None = None + self._attr_assumed_state = state_sensor is None + + self._last_pulse_time: float = -1e9 + self._pulse_off_cancel: CALLBACK_TYPE | None = None + self._travel_timer_cancel: CALLBACK_TYPE | None = None + + self._attr_extra_state_attributes = { + "where": self._where, + "who": self._who, + } + if self._state_sensor: + self._attr_extra_state_attributes["state_sensor"] = self._state_sensor + if self._interface is not None: + self._attr_extra_state_attributes["interface"] = self._interface + + self._access = AccessController( + hass, + name=name, + config=AccessConfig.from_config(access_config, pin_code), + state_sensor=state_sensor, + travel_time=travel_time, + send_pulse=self._async_pulse, + publish=self._publish_state, + entity_id=lambda: self.entity_id, + ) + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Static attributes plus the access policy state (never a PIN or nonce).""" + return {**self._attr_extra_state_attributes, **self._access.attributes()} + + async def async_added_to_hass(self) -> None: + """Register listeners when added to Home Assistant.""" + await super().async_added_to_hass() + self._access.hass = self.hass + if self._state_sensor and self.hass is not None: + @callback + def _sensor_state_changed(event: Any) -> None: + new_state = event.data.get("new_state") + self._handle_sensor_update(new_state.state if new_state is not None else None) + + self.async_on_remove( + async_track_state_change_event(self.hass, [self._state_sensor], _sensor_state_changed) + ) + initial_state = self.hass.states.get(self._state_sensor) + self._handle_sensor_update(initial_state.state if initial_state is not None else None) + + @callback + def _handle_sensor_update(self, state_str: str | None) -> None: + """Mirror the contact sensor: off = closed, on = not closed, anything else = unknown.""" + if state_str == STATE_OFF: + self._attr_is_closed = True + elif state_str == STATE_ON: + self._attr_is_closed = False + else: + self._attr_is_closed = None + self._attr_is_opening = False + self._attr_is_closing = False + self._cancel_travel_timer() + self._publish_state() + + def _cancel_travel_timer(self) -> None: + if self._travel_timer_cancel is not None: + self._travel_timer_cancel() + self._travel_timer_cancel = None + + async def _async_send_impulse(self) -> bool: + """Send the momentary pulse *1*1*WHERE## followed by *1*0*WHERE##.""" + now = time.monotonic() + if now - self._last_pulse_time < self._min_cycle_time: + LOGGER.warning( + "%s: Command dropped (within %.1fs deadband lockout)", + self._display_name, + self._min_cycle_time, + ) + return False + self._last_pulse_time = now + + await self._gateway_handler.send(OWNLightingCommand.switch_on(self._full_where)) + + if self._pulse_off_cancel is not None: + self._pulse_off_cancel() + self._pulse_off_cancel = None + + @callback + def _turn_off(_now: Any = None) -> None: + self._pulse_off_cancel = None + if self.hass is not None: + self.hass.async_create_task( + self._gateway_handler.send(OWNLightingCommand.switch_off(self._full_where)) + ) + + if self.hass is not None: + self._pulse_off_cancel = async_call_later(self.hass, self._pulse_duration, _turn_off) + return True + + async def _async_pulse(self, intent: Intent, effect: Effect) -> bool: + """Pulse the relay for an approved request and show the expected motion. + + The motion shown is an estimate for the dashboard only; the access + policy decides on the state sensor, never on this estimate. + """ + del effect # the policy already decided; the display follows the request + if not await self._async_send_impulse(): + return False + self._cancel_travel_timer() + self._attr_is_opening = intent is Intent.OPEN + self._attr_is_closing = intent is Intent.CLOSE + if intent is Intent.OPEN: + self._attr_is_closed = False + self._publish_state() + if intent is not Intent.STOP and self.hass is not None: + + @callback + def _finish(_now: Any = None) -> None: + self._travel_timer_cancel = None + self._attr_is_opening = False + self._attr_is_closing = False + if self._state_sensor is None: + self._attr_is_closed = intent is Intent.CLOSE + self._publish_state() + + self._travel_timer_cancel = async_call_later(self.hass, self._travel_time, _finish) + return True + + async def async_open_cover(self, **kwargs: Any) -> None: + """Request approval to open the door (nothing moves until it is approved).""" + await self._access.async_request(Intent.OPEN, self._context) + + async def async_close_cover(self, **kwargs: Any) -> None: + """Request approval to close the door (nothing moves until it is approved).""" + await self._access.async_request(Intent.CLOSE, self._context) + + async def async_stop_cover(self, **kwargs: Any) -> None: + """Request approval for a stop pulse; a stop pulse can close the door, so the close policy applies.""" + await self._access.async_request(Intent.STOP, self._context) + + async def async_cancel_request(self) -> None: + """Cancel the pending approval request or the running pre-warning.""" + await self._access.async_cancel(self._context) + + async def async_acknowledge_fault(self) -> None: + """Clear a latched watchdog fault (allowed user, on site).""" + await self._access.async_acknowledge_fault(self._context) + + @callback + def handle_event(self, message: Any) -> None: + """Handle incoming WHO=1 relay frames for this impulse cover. + + A frame never moves anything on its own. A press of the wall button + (or any other keypad on the bus) cancels a pending request or a running + pre-warning, and - without a state sensor - advances the estimated + motion shown on the dashboard. + """ + if getattr(message, "is_translation", None) is True: + return + is_on = getattr(message, "is_on", False) or getattr(message, "_what", None) == 1 + if not is_on: + return # Ignore *1*0*... release frames (such as the 5-minute delayed hardware timer) + + # Echo window: ignore frames arriving within 1.0s of our own command + if time.monotonic() - self._last_pulse_time < 1.0: + return + + if self._access.cancel("wall button pressed"): + LOGGER.info("%s: wall button pressed; pending request or warning cancelled", self._display_name) + + if self._state_sensor is None: + if self._attr_is_closed or self._attr_is_closing: + self._attr_is_opening = True + self._attr_is_closing = False + self._attr_is_closed = False + self._cancel_travel_timer() + + @callback + def _finish_open(_now: Any = None) -> None: + self._travel_timer_cancel = None + self._attr_is_opening = False + self._attr_is_closed = False + self._publish_state() + + if self.hass is not None: + self._travel_timer_cancel = async_call_later(self.hass, self._travel_time, _finish_open) + elif self._attr_is_opening: + self._attr_is_opening = False + self._cancel_travel_timer() + else: + self._attr_is_closing = True + self._attr_is_opening = False + self._cancel_travel_timer() + + @callback + def _finish_close(_now: Any = None) -> None: + self._travel_timer_cancel = None + self._attr_is_closing = False + self._attr_is_closed = True + self._publish_state() + + if self.hass is not None: + self._travel_timer_cancel = async_call_later(self.hass, self._travel_time, _finish_close) + self._publish_state() + + async def async_will_remove_from_hass(self) -> None: + """Clean up when removed from Home Assistant.""" + if self._pulse_off_cancel is not None: + self._pulse_off_cancel() + self._pulse_off_cancel = None + self._cancel_travel_timer() + await self._access.async_shutdown() + await super().async_will_remove_from_hass() + + def __getattr__(name: str) -> Any: if name in ("CoverFamily", "CoverScope", "MyHOMEScopeCover"): from . import cover_scope @@ -2081,6 +2449,7 @@ def __dir__() -> list[str]: "MOTOR_START_DELAY", "MOTOR_STOP_DELAY", "MyHOMECover", + "MyHOMEImpulseCover", "MyHOMEScopeCover", "WRITE_TIMEOUT", "_calibration_lock", diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index 46ac7deb4..ee49ea07f 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -16,6 +16,7 @@ OWNAuxEvent, OWNCENEvent, OWNCENPlusEvent, + OWNDoorEntryEvent, OWNDryContactEvent, OWNEnergyCommand, OWNEnergyEvent, @@ -570,6 +571,31 @@ async def process_message(self, message: Any) -> None: ) if isinstance(message, OWNGatewayEvent): self.handler._handle_gateway_diagnostics(message) + elif isinstance(message, OWNDoorEntryEvent): + if message.is_call: + event_name = ( + "broadcast_call" + if message.is_broadcast_call + else "chime" + if message.is_chime + else "call" + ) + doorbell_payload = { + "where": str(message.where), + "event": event_name, + "is_broadcast": message.is_broadcast_call, + "gateway_mac": self.handler.mac, + } + config_entry = getattr(self.handler, "config_entry", None) + if config_entry and hasattr(config_entry, "entry_id") and isinstance(config_entry.entry_id, str): + doorbell_payload["entry_id"] = config_entry.entry_id + self.hass.bus.async_fire("myhome_doorbell_event", doorbell_payload) + dispatcher_send(self.hass, f"myhome_doorbell_event_{self.handler.mac}", doorbell_payload) + self._logger.debug( + "%s %s", + self.handler.log_id, + message.human_readable_log, + ) elif getattr(message, "who", None) == 1013: if getattr(message, "dimension", getattr(message, "_dimension", None)) == 1: self.handler._handle_gateway_identity_diagnostics(message) diff --git a/custom_components/myhome/icons.json b/custom_components/myhome/icons.json index dd7b05a2e..01136d133 100644 --- a/custom_components/myhome/icons.json +++ b/custom_components/myhome/icons.json @@ -35,6 +35,12 @@ }, "tuner_seek_down": { "service": "mdi:rewind-outline" + }, + "cancel_cover_request": { + "service": "mdi:shield-remove-outline" + }, + "acknowledge_cover_fault": { + "service": "mdi:shield-check-outline" } } } diff --git a/custom_components/myhome/light.py b/custom_components/myhome/light.py index 29cd0b642..9017e8c7e 100644 --- a/custom_components/myhome/light.py +++ b/custom_components/myhome/light.py @@ -255,6 +255,14 @@ def __init__( self.switches.update( {str(dev_id), address.where, address.clean_key, address.clean_where} ) + for platform, default_who in (("cover", "2"), ("lock", "6")): + for dev_id, cfg in runtime.platforms.get(platform, {}).items(): + if str(cfg.get(CONF_WHO, default_who)) != "1": + continue + address = Address.from_config(dev_id, cfg) + self.switches.update( + {str(dev_id), address.where, address.clean_key, address.clean_where} + ) for platform, default_who in (("binary_sensor", "25"), ("sensor", "1")): for dev_id, cfg in runtime.platforms.get(platform, {}).items(): if str(cfg.get(CONF_WHO, default_who)) != "1": @@ -269,7 +277,9 @@ def __init__( entries = [] for entry in entries: who, device_id = parse_unique_id(entry.unique_id or "", gateway_mac, entry_mac) - if entry.domain == "switch": + if entry.domain in ("switch", "cover", "lock"): + if entry.domain != "switch" and who != "1": + continue self.switches.update({device_id, device_id.split("#4#")[0].split("-")[-1]}) elif entry.domain in ("binary_sensor", "sensor"): if who == "1": diff --git a/custom_components/myhome/lock.py b/custom_components/myhome/lock.py new file mode 100644 index 000000000..f74dfbae1 --- /dev/null +++ b/custom_components/myhome/lock.py @@ -0,0 +1,324 @@ +"""Support for MyHome locks (door entry electric strikes WHO=6 and impulse locks WHO=1).""" +from __future__ import annotations + +import hmac +from typing import Any + +from homeassistant.components.lock import ( + LockEntity, + LockEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_CODE, + CONF_MAC, + CONF_NAME, + Platform, +) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.event import async_call_later +from OWNd.message import ( + OWNDoorEntryCommand, + OWNDoorEntryEvent, + OWNLightingCommand, +) + +from .const import ( + CONF_DEVICE_MODEL, + CONF_ENTITY_NAME, + CONF_MANUFACTURER, + CONF_PULSE_DURATION, + CONF_WHO, + DEFAULT_PULSE_DURATION, + LOGGER, +) +from .data import get_runtime_data +from .discovery import ( + Address, + DeviceContext, + PlatformDiscovery, + default_known_keys, + parse_unique_id, +) +from .gateway import MyHOMEGatewayHandler +from .myhome_device import MyHOMEEntity + +PLATFORM = Platform.LOCK +PARALLEL_UPDATES = 0 +DEFAULT_LOCK_DURATION = 5.0 + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> bool: + """Set up the locks of a gateway: registry entries, then myhome.yaml, then bus discovery.""" + runtime = get_runtime_data(config_entry) + if runtime is None or PLATFORM not in runtime.platforms: + return True + + mac = config_entry.data[CONF_MAC] + gateway = runtime.gateway + + def lock_registry_address(target_who: str): + def address_of(entry: er.RegistryEntry) -> Address | None: + who, device_id = parse_unique_id(entry.unique_id or "", gateway.mac, mac) + entry_who = who if who is not None else "6" + if entry_who != target_who: + return None + return Address.from_device_id(device_id) + return address_of + + def build_who6(ctx: DeviceContext) -> MyHOMELock: + cfg = ctx.cfg + name_val = cfg.get(CONF_NAME) + name = str(name_val) if name_val else f"Lock {ctx.suffix}" + raw_entity_name = cfg.get(CONF_ENTITY_NAME) + entity_name = str(raw_entity_name) if raw_entity_name is not None else None + manufacturer = str(cfg.get(CONF_MANUFACTURER, "BTicino")) + model = str(cfg.get(CONF_DEVICE_MODEL, "Door Entry Lock")) + code = str(cfg[CONF_CODE]) if CONF_CODE in cfg else (str(cfg["code"]) if "code" in cfg else None) + return MyHOMELock( + hass=hass, + name=name, + entity_name=entity_name, + device_id=ctx.key, + who=ctx.who, + where=ctx.address.where, + interface=ctx.address.interface, + code=code, + pulse_duration=DEFAULT_LOCK_DURATION, + manufacturer=manufacturer, + model=model, + gateway=runtime.gateway, + ) + + def build_who1(ctx: DeviceContext) -> MyHOMELock: + cfg = ctx.cfg + name_val = cfg.get(CONF_NAME) + name = str(name_val) if name_val else f"Impulse Lock {ctx.suffix}" + raw_entity_name = cfg.get(CONF_ENTITY_NAME) + entity_name = str(raw_entity_name) if raw_entity_name is not None else None + manufacturer = str(cfg.get(CONF_MANUFACTURER, "BTicino")) + model = str(cfg.get(CONF_DEVICE_MODEL, "Impulse Relay Lock")) + code = str(cfg[CONF_CODE]) if CONF_CODE in cfg else (str(cfg["code"]) if "code" in cfg else None) + pulse = float(cfg.get(CONF_PULSE_DURATION, cfg.get("pulse_duration", DEFAULT_PULSE_DURATION))) + return MyHOMELock( + hass=hass, + name=name, + entity_name=entity_name, + device_id=ctx.key, + who=ctx.who, + where=ctx.address.where, + interface=ctx.address.interface, + code=code, + pulse_duration=pulse, + manufacturer=manufacturer, + model=model, + gateway=runtime.gateway, + ) + + def accept_who6(ctx: DeviceContext) -> bool: + if ctx.address.where in ("4100", "0", ""): + return False + if ctx.source == "yaml": + return str(ctx.cfg.get(CONF_WHO, "6")) == "6" + if ctx.source == "bus": + msg = ctx.message + if not isinstance(msg, OWNDoorEntryEvent) or not msg.is_lock_open: + return False + return True + + def accept_who1(ctx: DeviceContext) -> bool: + if ctx.source == "yaml": + return str(ctx.cfg.get(CONF_WHO, "6")) == "1" + return True + + PlatformDiscovery( + hass, + config_entry, + async_add_entities, + platform=PLATFORM, + who="6", + event_type=OWNDoorEntryEvent, + build=build_who6, + announce=True, + registry_address=lock_registry_address("6"), + accept=accept_who6, + known_keys=lambda ctx: [*default_known_keys(ctx), ctx.address.where, ctx.address.clean_where], + ).start(listen=True) + + PlatformDiscovery( + hass, + config_entry, + async_add_entities, + platform=PLATFORM, + who="1", + event_type=None, + build=build_who1, + announce=True, + registry_address=lock_registry_address("1"), + accept=accept_who1, + known_keys=lambda ctx: [*default_known_keys(ctx), ctx.address.where, ctx.address.clean_where], + ).start(listen=False) + + return True + + +async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: + """Unload lock platform entries.""" + runtime = get_runtime_data(config_entry) + if runtime is None or PLATFORM not in runtime.platforms: + return True + + configured_locks = runtime.platforms[PLATFORM] + for lock_key in list(configured_locks.keys()): + del runtime.platforms[PLATFORM][lock_key] + + return True + + +class MyHOMELock(MyHOMEEntity, LockEntity): + """Representation of a MyHOME door entry electric lock strike (WHO=6) or impulse lock (WHO=1).""" + + _poll_on_add = False + _attr_supported_features = LockEntityFeature.OPEN + + def __init__( + self, + hass: HomeAssistant | None, + name: str, + entity_name: str | None, + device_id: str, + who: str, + where: str, + interface: str | None, + manufacturer: str, + model: str, + gateway: MyHOMEGatewayHandler, + code: str | None = None, + pulse_duration: float = DEFAULT_LOCK_DURATION, + ) -> None: + super().__init__( + hass=hass, + name=name, + platform=PLATFORM, + device_id=device_id, + who=who, + where=where, + manufacturer=manufacturer, + model=model, + gateway=gateway, + entity_name=entity_name, + ) + + self._interface = interface + self._full_where = f"{self._where}#4#{self._interface}" if self._interface is not None else self._where + self._code = code + self._pulse_duration = pulse_duration + if self._code: + self._attr_code_format = r"^\d+$" + self._attr_is_locked = True + self._relock_unsub: CALLBACK_TYPE | None = None + self._pulse_off_unsub: CALLBACK_TYPE | None = None + + self._attr_extra_state_attributes = { + "where": self._where, + "who": self._who, + } + if self._interface is not None: + self._attr_extra_state_attributes["interface"] = self._interface + + async def async_lock(self, **kwargs: Any) -> None: + """Lock the device (resets momentary pulse).""" + self._cancel_auto_relock() + self._attr_is_locked = True + self.async_write_ha_state() + + async def async_unlock(self, **kwargs: Any) -> None: + """Unlock the door strike momentarily.""" + if self._code: + code = kwargs.get("code") + if code is None or not hmac.compare_digest(str(code), str(self._code)): + raise ServiceValidationError(f"Invalid code for {self._display_name}") + + if self._who == "1": + await self._gateway_handler.send(OWNLightingCommand.switch_on(self._full_where)) + + if self._pulse_off_unsub is not None: + self._pulse_off_unsub() + self._pulse_off_unsub = None + + @callback + def _turn_off(_now: Any = None) -> None: + self._pulse_off_unsub = None + if self.hass is not None: + self.hass.async_create_task( + self._gateway_handler.send(OWNLightingCommand.switch_off(self._full_where)) + ) + + if self.hass is not None: + self._pulse_off_unsub = async_call_later(self.hass, self._pulse_duration, _turn_off) + else: + await self._gateway_handler.send(OWNDoorEntryCommand.open_lock(self._full_where)) + + self._attr_is_locked = False + self.async_write_ha_state() + self._schedule_auto_relock() + + async def async_open(self, **kwargs: Any) -> None: + """Open (unlatch/release) the door strike.""" + await self.async_unlock(**kwargs) + + @callback + def handle_event(self, message: Any) -> None: + """Handle an incoming lock event message.""" + LOGGER.debug( + "%s %s", + self._gateway_handler.log_id, + getattr(message, "human_readable_log", str(message)), + ) + if getattr(message, "is_translation", None) is True: + return + if self._who == "1": + is_on = getattr(message, "is_on", False) or getattr(message, "_what", None) == 1 + if is_on: + self._attr_is_locked = False + self._publish_state() + self._schedule_auto_relock() + else: + if getattr(message, "is_lock_open", False): + self._attr_is_locked = False + self._publish_state() + self._schedule_auto_relock() + + @callback + def _schedule_auto_relock(self) -> None: + self._cancel_auto_relock() + if self.hass is not None: + self._relock_unsub = async_call_later( + self.hass, self._pulse_duration, self._async_auto_relock + ) + + @callback + def _cancel_auto_relock(self) -> None: + if self._relock_unsub is not None: + self._relock_unsub() + self._relock_unsub = None + + @callback + def _async_auto_relock(self, _now: Any = None) -> None: + self._relock_unsub = None + self._attr_is_locked = True + self._publish_state() + + async def async_will_remove_from_hass(self) -> None: + self._cancel_auto_relock() + if self._pulse_off_unsub is not None: + self._pulse_off_unsub() + self._pulse_off_unsub = None + await super().async_will_remove_from_hass() diff --git a/custom_components/myhome/migrate.py b/custom_components/myhome/migrate.py index 16c275d12..59e1e6bea 100644 --- a/custom_components/myhome/migrate.py +++ b/custom_components/myhome/migrate.py @@ -89,6 +89,7 @@ def migrate_entry_and_registries( "switch": "1", "media_player": "16", "climate": "4", + "lock": "6", } registry_entries = er.async_entries_for_config_entry(entity_registry, entry.entry_id) diff --git a/custom_components/myhome/quality_scale.yaml b/custom_components/myhome/quality_scale.yaml index a0be7c8cf..0758baad7 100644 --- a/custom_components/myhome/quality_scale.yaml +++ b/custom_components/myhome/quality_scale.yaml @@ -93,7 +93,7 @@ rules: comment: Grace period and connection supervisor log once on loss and once on recovery, without spamming. parallel-updates: status: done - comment: PARALLEL_UPDATES = 0 declared across all 9 platform files for push-driven event streaming. + comment: PARALLEL_UPDATES = 0 declared across all 10 platform files for push-driven event streaming. reauthentication-flow: status: done comment: async_setup_entry raises ConfigEntryAuthFailed on password rejection; Home Assistant starts the reauth flow natively. diff --git a/custom_components/myhome/services.yaml b/custom_components/myhome/services.yaml index d5efe6bcb..eb06389be 100644 --- a/custom_components/myhome/services.yaml +++ b/custom_components/myhome/services.yaml @@ -282,3 +282,24 @@ send_scenario_plus_command: - "increase" - "decrease" - "stop" + +cancel_cover_request: + name: Cancel gate or garage door request + description: >- + Cancels a pending approval request or a running pre-warning of an impulse gate or + garage door. Cancelling is always safe; nothing moves. + target: + entity: + domain: cover + integration: myhome + +acknowledge_cover_fault: + name: Acknowledge gate or garage door fault + description: >- + Clears the fault latched when a gate or garage door did not reach the expected state + after a pulse (possible obstacle or reversal). Only an allowed user who is at home + may acknowledge it; until then closing from Home Assistant stays blocked. + target: + entity: + domain: cover + integration: myhome diff --git a/custom_components/myhome/strings.json b/custom_components/myhome/strings.json index c3d9f2468..a4fb8c098 100644 --- a/custom_components/myhome/strings.json +++ b/custom_components/myhome/strings.json @@ -363,6 +363,14 @@ "description": "The scenario plus action to execute (on, off, increase, decrease, or stop; step/speed for increase/decrease is fixed per protocol)." } } + }, + "cancel_cover_request": { + "name": "Cancel gate or garage door request", + "description": "Cancels a pending approval request or a running pre-warning of an impulse gate or garage door. Cancelling is always safe; nothing moves." + }, + "acknowledge_cover_fault": { + "name": "Acknowledge gate or garage door fault", + "description": "Clears the fault latched when a gate or garage door did not reach the expected state after a pulse (possible obstacle or reversal). Only an allowed user who is at home may acknowledge it; until then closing from Home Assistant stays blocked." } }, "issues": { diff --git a/custom_components/myhome/translations/en.json b/custom_components/myhome/translations/en.json index c3d9f2468..a4fb8c098 100644 --- a/custom_components/myhome/translations/en.json +++ b/custom_components/myhome/translations/en.json @@ -363,6 +363,14 @@ "description": "The scenario plus action to execute (on, off, increase, decrease, or stop; step/speed for increase/decrease is fixed per protocol)." } } + }, + "cancel_cover_request": { + "name": "Cancel gate or garage door request", + "description": "Cancels a pending approval request or a running pre-warning of an impulse gate or garage door. Cancelling is always safe; nothing moves." + }, + "acknowledge_cover_fault": { + "name": "Acknowledge gate or garage door fault", + "description": "Clears the fault latched when a gate or garage door did not reach the expected state after a pulse (possible obstacle or reversal). Only an allowed user who is at home may acknowledge it; until then closing from Home Assistant stays blocked." } }, "issues": { diff --git a/custom_components/myhome/translations/nl.json b/custom_components/myhome/translations/nl.json index c6cf5aba8..f57be2dab 100644 --- a/custom_components/myhome/translations/nl.json +++ b/custom_components/myhome/translations/nl.json @@ -330,6 +330,14 @@ "reset_cover_travel_time": { "name": "Looptijd rolluik terugzetten", "description": "Vergeet de gemeten of handmatig ingestelde looptijden en valt terug op travel_time uit myhome.yaml of de standaardwaarde van 25 s." + }, + "cancel_cover_request": { + "name": "Annuleer poort- of garagedeurverzoek", + "description": "Annuleert een openstaand goedkeuringsverzoek of een lopende voorwaarschuwing van een impuls-poort of garagedeur. Annuleren is altijd veilig; er beweegt niets." + }, + "acknowledge_cover_fault": { + "name": "Bevestig en herstel poort- of garagedeurstoring", + "description": "Wist de vergrendelde storing wanneer een poort of garagedeur niet de verwachte status bereikte na een impuls (mogelijk obstakel of omkering). Alleen een geautoriseerde gebruiker die thuis is kan dit bevestigen; tot die tijd blijft sluiten vanuit Home Assistant geblokkeerd." } }, "issues": { diff --git a/custom_components/myhome/validate.py b/custom_components/myhome/validate.py index 4870a5fff..bdb68caf8 100644 --- a/custom_components/myhome/validate.py +++ b/custom_components/myhome/validate.py @@ -13,8 +13,14 @@ ) from homeassistant.components.button import DOMAIN as BUTTON # type: ignore from homeassistant.components.climate import DOMAIN as CLIMATE # type: ignore -from homeassistant.components.cover import DOMAIN as COVER +from homeassistant.components.cover import ( + DOMAIN as COVER, +) +from homeassistant.components.cover import ( + CoverDeviceClass, +) from homeassistant.components.light import DOMAIN as LIGHT # type: ignore +from homeassistant.components.lock import DOMAIN as LOCK # type: ignore from homeassistant.components.sensor import ( DOMAIN as SENSOR, ) @@ -27,7 +33,8 @@ from homeassistant.components.switch import ( # type: ignore[attr-defined, unused-ignore] SwitchDeviceClass, ) -from homeassistant.const import CONF_MAC, CONF_NAME +from homeassistant.const import CONF_CODE, CONF_MAC, CONF_NAME +from homeassistant.helpers import config_validation as cv from homeassistant.helpers.device_registry import format_mac as ha_format_mac from voluptuous import ( All, @@ -48,9 +55,15 @@ CALIBRATION_MAX_RUN, CALIBRATION_MIN_MOTOR_PULSE, CALIBRATION_MIN_RUN, + CONF_ACCESS, CONF_ADVANCED_SHUTTER, + CONF_ALLOWED_USERS, + CONF_APPROVAL_TIMEOUT, + CONF_APPROVERS, CONF_BUS_INTERFACE, + CONF_CAMERA, CONF_CENTRAL, + CONF_CLOSE_BLOCK_ENTITY, CONF_COLOR_TEMP, CONF_COOLING_SUPPORT, CONF_DEVICE_CLASS, @@ -68,13 +81,24 @@ CONF_LOCK_FEATURES, CONF_MANUFACTURER, CONF_MEMBERS, + CONF_MIN_CYCLE_TIME, CONF_MIN_MOTOR_PULSE, + CONF_PIN_CODE, CONF_PLATFORMS, + CONF_PREWARN_LIGHT, + CONF_PREWARN_SECONDS, + CONF_PULSE_DURATION, + CONF_REMOTE_CLOSE, CONF_RGB, + CONF_SAFETY_CHECK_DAYS, + CONF_SAFETY_DEVICES_VERIFIED, CONF_SLAT_TILT, CONF_SLAT_TIME, CONF_STANDALONE, + CONF_STATE_SENSOR, CONF_TRAVEL_TIME, + CONF_TYPE, + CONF_WATCHDOG_MARGIN, CONF_WHERE, CONF_WHO, CONF_ZONE, @@ -82,6 +106,7 @@ DEFAULT_SLAT_TIME, MAX_SLAT_TIME, MIN_SLAT_TIME, + TYPE_IMPULSE_RELAY, ) @@ -399,16 +424,93 @@ def _validate_light_members(data: dict[str, typing.Any]) -> dict[str, typing.Any } ) +def _validate_access(access: dict) -> dict: + """Cross-field rules of an impulse cover's access policy.""" + allowed = set(access[CONF_ALLOWED_USERS]) + approvers = access[CONF_APPROVERS] + missing = sorted(allowed - set(approvers)) + if missing: + raise Invalid(f"every allowed user needs an approver (notify service); missing for {missing}") + extra = sorted(set(approvers) - allowed) + if extra: + raise Invalid(f"approvers lists users that are not in allowed_users: {extra}") + for user, service in approvers.items(): + if not service.startswith("notify.") or service == "notify.": + raise Invalid(f"approver of {user} must be a notify service such as notify.mobile_app_phone") + if access[CONF_REMOTE_CLOSE] == "with_camera" and CONF_CAMERA not in access: + raise Invalid("remote_close: with_camera needs a camera entity") + return access + + +access_schema = All( + Schema( + { + Optional(CONF_ALLOWED_USERS, default=[]): [Coerce(str)], + Optional(CONF_APPROVERS, default={}): {Coerce(str): Coerce(str)}, + Optional(CONF_REMOTE_CLOSE, default="never"): In(["never", "at_home", "with_camera"]), + Optional(CONF_SAFETY_DEVICES_VERIFIED): cv.date, + Optional(CONF_SAFETY_CHECK_DAYS, default=31): All(Coerce(int), Range(min=1, max=366)), + Optional(CONF_CAMERA): cv.entity_id, + Optional(CONF_PREWARN_LIGHT): cv.entity_id, + Optional(CONF_PREWARN_SECONDS, default=5): All(Coerce(float), Range(min=3, max=30)), + Optional(CONF_CLOSE_BLOCK_ENTITY): cv.entity_id, + Optional(CONF_WATCHDOG_MARGIN, default=10): All(Coerce(float), Range(min=1, max=120)), + Optional(CONF_APPROVAL_TIMEOUT, default=60): All(Coerce(float), Range(min=15, max=300)), + } + ), + _validate_access, +) + + cover_schema = MyHomeDeviceSchema( { Required(str): { - Optional(CONF_WHO, default="2"): "2", + Optional(CONF_WHO, default="2"): In(["1", "2"]), Required(CONF_WHERE): All( Coerce(str), Any(General(), Area(), Group(), PointToPoint(), msg="Invalid , expecting a valid General, Area, Group or Point-to-Point ") # type: ignore ), Optional(CONF_BUS_INTERFACE): All(Coerce(str), BusInterface()), # type: ignore Required(CONF_NAME): str, Optional(CONF_ENTITY_NAME): str, + Optional(CONF_DEVICE_CLASS): In( + [ + CoverDeviceClass.AWNING, + CoverDeviceClass.BLIND, + CoverDeviceClass.CURTAIN, + CoverDeviceClass.DAMPER, + CoverDeviceClass.DOOR, + CoverDeviceClass.GARAGE, + CoverDeviceClass.GATE, + CoverDeviceClass.SHADE, + CoverDeviceClass.SHUTTER, + CoverDeviceClass.WINDOW, + ] + ), + Optional("device_class"): In( + [ + CoverDeviceClass.AWNING, + CoverDeviceClass.BLIND, + CoverDeviceClass.CURTAIN, + CoverDeviceClass.DAMPER, + CoverDeviceClass.DOOR, + CoverDeviceClass.GARAGE, + CoverDeviceClass.GATE, + CoverDeviceClass.SHADE, + CoverDeviceClass.SHUTTER, + CoverDeviceClass.WINDOW, + ] + ), + Optional(CONF_TYPE): In([TYPE_IMPULSE_RELAY, "shutter", "standard"]), + Optional("type"): In([TYPE_IMPULSE_RELAY, "shutter", "standard"]), + Optional(CONF_PIN_CODE): Coerce(str), + Optional("pin_code"): Coerce(str), + Optional(CONF_STATE_SENSOR): cv.entity_id, + Optional("state_sensor"): cv.entity_id, + Optional(CONF_PULSE_DURATION, default=0.5): All(Coerce(float), Range(min=0.2, max=2.0)), + Optional("pulse_duration", default=0.5): All(Coerce(float), Range(min=0.2, max=2.0)), + Optional(CONF_MIN_CYCLE_TIME, default=5.0): All(Coerce(float), Range(min=1.0, max=120.0)), + Optional("min_cycle_time", default=5.0): All(Coerce(float), Range(min=1.0, max=120.0)), + Optional(CONF_ACCESS): access_schema, Optional(CONF_ADVANCED_SHUTTER, default=False): Boolean(), Optional("advanced_shutter", default=False): Boolean(), Optional(CONF_SLAT_TILT, default=False): Boolean(), @@ -443,7 +545,25 @@ def _validate_cover_members(data: dict[str, typing.Any]) -> dict[str, typing.Any cfg.setdefault(CONF_SLAT_TIME, DEFAULT_SLAT_TIME) return data -cover_schema = All(cover_schema, _validate_cover_members) # type: ignore[assignment] + +def _validate_impulse_covers(data: dict) -> dict: + """An impulse cover pulses exactly one relay; access settings only apply to impulse covers.""" + for device, cfg in data.items(): + is_impulse = str(cfg.get(CONF_WHO)) == "1" + if is_impulse: + try: + PointToPoint()(str(cfg[CONF_WHERE])) # type: ignore + except Invalid as err: + raise Invalid( + f"{device}: an impulse cover (who: 1) needs a point-to-point ; a general, area or " + "group address would pulse several motors at once" + ) from err + elif CONF_ACCESS in cfg or CONF_PIN_CODE in cfg or CONF_STATE_SENSOR in cfg: + raise Invalid(f"{device}: access, pin_code and state_sensor only apply to impulse covers (who: 1)") + return data + + +cover_schema = All(cover_schema, _validate_cover_members, _validate_impulse_covers) # type: ignore[assignment] binary_sensor_schema = MyHomeDeviceSchema( { @@ -567,6 +687,24 @@ def _validate_cover_members(data: dict[str, typing.Any]) -> dict[str, typing.Any } ) +lock_schema = MyHomeDeviceSchema( + { + Required(str): { + Optional(CONF_WHO, default="6"): In(["1", "6"]), + Required(CONF_WHERE): All(Coerce(str), Any(General(), Area(), Group(), PointToPoint(), SpecialWhere())), # type: ignore + Optional(CONF_BUS_INTERFACE): BusInterface(), + Required(CONF_NAME): str, + Optional(CONF_ENTITY_NAME): str, + Optional(CONF_CODE): Coerce(str), + Optional("code"): Coerce(str), + Optional(CONF_PULSE_DURATION, default=0.5): Coerce(float), + Optional("pulse_duration", default=0.5): Coerce(float), + Optional(CONF_MANUFACTURER, default="BTicino S.p.A."): str, + Optional(CONF_DEVICE_MODEL, default="Door Entry Lock"): Coerce(str), + } + } +) + # The device schemas are Schema subclasses whose overridden __call__ performs # post-processing (rekeying to "who-where" and injecting default keys). Nested # schema instances are not guaranteed to be invoked through __call__ by the @@ -583,6 +721,7 @@ def _validate_cover_members(data: dict[str, typing.Any]) -> dict[str, typing.Any Optional(SENSOR): lambda v: sensor_schema(v), Optional(CLIMATE): lambda v: climate_schema(v), Optional(ALARM_CONTROL_PANEL): lambda v: alarm_control_panel_schema(v), + Optional(LOCK): lambda v: lock_schema(v), } ) diff --git a/docs/configuration/services.md b/docs/configuration/services.md index 566621add..38fb84643 100644 --- a/docs/configuration/services.md +++ b/docs/configuration/services.md @@ -21,6 +21,8 @@ This document provides a comprehensive reference for all custom services registe | [`myhome.tuner_seek_up`](#11-myhometuner_seek_up) | `media_player` | Seek forward to the next receivable FM radio frequency on an F500 tuner. | | [`myhome.tuner_seek_down`](#12-myhometuner_seek_down) | `media_player` | Seek backward to the previous receivable FM radio frequency on an F500 tuner. | | [`myhome.send_scenario_plus_command`](#13-myhomesend_scenario_plus_command) | Gateway | Send a WHO 25 scenario plus command (on, off, increase, decrease, stop). | +| [`myhome.cancel_cover_request`](#14-myhomecancel_cover_request) | `cover` | Cancel a pending approval request or running pre-warning countdown. | +| [`myhome.acknowledge_cover_fault`](#15-myhomeacknowledge_cover_fault) | `cover` | Clear a latched safety watchdog fault after verifying physical gate/door clearance. | --- @@ -298,3 +300,39 @@ data: where: "11" action: "on" ``` + +--- + +## 14. `myhome.cancel_cover_request` + +Cancels an active or pending approval request or a running 5-second pre-warning countdown of an impulse gate or garage door (`type: impulse_relay`). Cancelling is always safe: no bus impulse is sent, no relay energizes, and nothing moves. + +### Fields +| Parameter | Type | Required | Description | Example | +| :--- | :---: | :---: | :--- | :--- | +| `entity_id` | target | Yes | Target MyHOME impulse cover entity. | `cover.garage_door_1` | + +### Example YAML Call +```yaml +action: myhome.cancel_cover_request +target: + entity_id: cover.garage_door_1 +``` + +--- + +## 15. `myhome.acknowledge_cover_fault` + +Clears the safety fault latched when a gate or garage door did not reach its expected state after a pulse (indicating a physical obstacle, safety trip, or reversal). Only an allowed user who is physically at home may clear the fault. Until acknowledged, all remote and automated closing commands from Home Assistant remain strictly blocked. + +### Fields +| Parameter | Type | Required | Description | Example | +| :--- | :---: | :---: | :--- | :--- | +| `entity_id` | target | Yes | Target MyHOME impulse cover entity currently in fault. | `cover.garage_door_1` | + +### Example YAML Call +```yaml +action: myhome.acknowledge_cover_fault +target: + entity_id: cover.garage_door_1 +``` diff --git a/docs/configuration/supported_functions.md b/docs/configuration/supported_functions.md index 4002dd754..b56603306 100644 --- a/docs/configuration/supported_functions.md +++ b/docs/configuration/supported_functions.md @@ -13,6 +13,7 @@ Legend: ✅ supported · 👁️ read-only · ⚙️ via a service, not an entit | 2 | Automation (shutters, blinds) | `cover` | ✅ | Timed and position-reporting actuators. | | 4 | Thermoregulation | `climate`, `sensor` | ✅ | Zone thermostats, central units 3550 (`#0`) and 4695 (`#0#1`), probes. | | 5 | Burglar alarm | `alarm_control_panel` | 👁️ | Central unit status (read-only); arm/disarm through installer-programmed AUX frames — see [alarm](alarm.md#arming-and-disarming). | +| 6 | Door entry | `lock`, `event` | ✅ | Door strikes, lock actuators, and doorbell events. | | 9 | Auxiliary channels | `binary_sensor` | 👁️ | AUX 1–9 as binary sensors. | | 13 | Gateway management | — | ⚙️ | Clock sync (`myhome.sync_time`), firmware / model / identity for the device registry and diagnostics. | | 14 | Actuator lock | `button` | ✅ | Lock / unlock buttons on every light, switch and cover device. | @@ -55,6 +56,13 @@ Legend: ✅ supported · 👁️ read-only · ⚙️ via a service, not an entit | Tilt | ❌ | Slat commands are parsed by OWNd but not exposed. | | Echo suppression | ✅ | The gateway's relay of our own command is not mistaken for a keypad press. | +### `lock` (WHO 1 / WHO 6) + +| Function | Status | Notes | +| :--- | :---: | :--- | +| Impulse lock (WHO 1) | ✅ | Pulse-driven electric strikes and gates with constant-time PIN authentication and lockout protection. | +| Door entry lock (WHO 6) | ✅ | Door entry strikes and doorbell ring event triggers. | + ### `climate` (WHO 4) | Function | Status | Notes | diff --git a/scripts/update_supported_domains.py b/scripts/update_supported_domains.py index 76934eab5..7a1472e9d 100644 --- a/scripts/update_supported_domains.py +++ b/scripts/update_supported_domains.py @@ -33,6 +33,7 @@ "light", "switch", "cover", + "lock", "climate", "alarm_control_panel", "binary_sensor", @@ -65,6 +66,13 @@ "position-reporting actuators & virtual travel-time positioning" ), }, + "lock": { + "label": "**`lock`**", + "capabilities": ( + "Pulse-driven electric strikes and gates (`WHO=1`) with constant-time PIN authentication and lockout protection; " + "native door entry strikes and doorbell ring events (`WHO=6`)" + ), + }, "climate": { "label": "**`climate`**", "capabilities": ( diff --git a/scripts/verify_ha_standards.py b/scripts/verify_ha_standards.py index 92239e4bf..6ecdabb1f 100644 --- a/scripts/verify_ha_standards.py +++ b/scripts/verify_ha_standards.py @@ -27,7 +27,7 @@ # Modules that read per-config-entry state; audited by the runtime-data check. RUNTIME_DATA_READERS = [ "light.py", "switch.py", "cover.py", "climate.py", "binary_sensor.py", "sensor.py", - "media_player.py", "button.py", "alarm_control_panel.py", + "media_player.py", "button.py", "alarm_control_panel.py", "lock.py", "services.py", "websocket.py", "diagnostics.py", "myhome_device.py", "decoder_pool.py", "cover_calibration.py", ] @@ -523,6 +523,7 @@ def check_quality_scale_rules(checker: StandardsChecker): "climate.py", "cover.py", "light.py", + "lock.py", "media_player.py", "sensor.py", "switch.py", diff --git a/tests/test_access_policy.py b/tests/test_access_policy.py new file mode 100644 index 000000000..bdba072f9 --- /dev/null +++ b/tests/test_access_policy.py @@ -0,0 +1,606 @@ +"""Tests for the access policy of impulse gates and garage doors (access_policy.py).""" + +import asyncio +import time +from datetime import date, timedelta + +import pytest +from homeassistant.const import STATE_HOME, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_UNAVAILABLE +from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util +from pytest_homeassistant_custom_component.common import async_fire_time_changed, async_mock_service +from voluptuous import Invalid + +from custom_components.myhome.access_policy import ( + ACTION_PREFIX, + EVENT_NOTIFICATION_ACTION, + AccessConfig, + AccessController, + AccessDenied, + Effect, + Intent, + Phase, + RemoteClose, + classify, +) +from custom_components.myhome.const import EVENT_ACCESS_PREWARNING +from custom_components.myhome.validate import cover_schema + +SENSOR = "binary_sensor.garage_contact" +NOTIFY = "notify.mobile_app_parent" + + +async def _user(hass: HomeAssistant, name: str, home: bool | None = True): + user = await hass.auth.async_create_user(name) + if home is not None: + hass.states.async_set(f"person.{name.lower()}", STATE_HOME if home else STATE_NOT_HOME, {"user_id": user.id}) + return user + + +def _config(user_id: str, **overrides) -> AccessConfig: + base = dict( + allowed_users=(user_id,), + approvers={user_id: NOTIFY}, + remote_close=RemoteClose.AT_HOME, + safety_devices_verified=dt_util.now().date(), + prewarn_seconds=0.05, + watchdog_margin=5.0, + approval_timeout=60.0, + ) + base.update(overrides) + return AccessConfig(**base) + + +class Harness: + """A controller plus the pulses it sent.""" + + def __init__(self, hass: HomeAssistant, config: AccessConfig, state_sensor: str | None = SENSOR, pulse_ok: bool = True): + self.pulses: list[tuple[Intent, Effect]] = [] + self.published = 0 + self.pulse_ok = pulse_ok + + async def send_pulse(intent: Intent, effect: Effect) -> bool: + self.pulses.append((intent, effect)) + return self.pulse_ok + + def publish() -> None: + self.published += 1 + + self.ctrl = AccessController( + hass, + name="Garage 1", + config=config, + state_sensor=state_sensor, + travel_time=15.0, + send_pulse=send_pulse, + publish=publish, + entity_id=lambda: "cover.garage_1", + ) + + +def _approve_action(calls) -> dict: + return calls[-1].data["data"]["actions"][0] + + +async def _press(hass: HomeAssistant, action: str, user_id: str | None, wait: bool = True, **extra) -> None: + hass.bus.async_fire(EVENT_NOTIFICATION_ACTION, {"action": action, **extra}, context=Context(user_id=user_id)) + if wait: + await hass.async_block_till_done() + else: + await asyncio.sleep(0) + + +async def _wait_phase(ctrl: AccessController, phase: Phase) -> None: + for _ in range(200): + if ctrl.phase is phase: + return + await asyncio.sleep(0.005) + raise AssertionError(f"phase stayed {ctrl.phase}, expected {phase}") + + +def _fire(hass: HomeAssistant, seconds: float) -> None: + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=seconds)) + + +# ── classify ──────────────────────────────────────────────────────────── + + +@pytest.mark.parametrize( + ("intent", "sensor", "state", "expected"), + [ + (Intent.OPEN, SENSOR, STATE_OFF, Effect.OPEN), + (Intent.CLOSE, SENSOR, STATE_ON, Effect.MAY_CLOSE), + (Intent.STOP, SENSOR, STATE_ON, Effect.MAY_CLOSE), + (Intent.OPEN, None, None, Effect.MAY_CLOSE), + (Intent.CLOSE, None, None, Effect.MAY_CLOSE), + ], +) +def test_classify_allowed(intent, sensor, state, expected): + """Only a door proven closed is opened by a pulse; without a sensor every pulse can close.""" + assert classify(intent, sensor, state) is expected + + +@pytest.mark.parametrize( + ("intent", "state", "match"), + [ + (Intent.CLOSE, STATE_OFF, "would open"), + (Intent.STOP, STATE_OFF, "would open"), + (Intent.OPEN, STATE_ON, "could close"), + (Intent.OPEN, STATE_UNAVAILABLE, "not reporting"), + (Intent.OPEN, None, "not reporting"), + ], +) +def test_classify_refused(intent, state, match): + """A pulse in the wrong direction or on an unknown state is refused.""" + with pytest.raises(AccessDenied, match=match): + classify(intent, SENSOR, state) + + +def test_config_from_config_defaults_and_due_date(): + """No access block means nobody is allowed and closing is disabled.""" + cfg = AccessConfig.from_config(None) + assert cfg.allowed_users == () + assert cfg.remote_close is RemoteClose.NEVER + assert cfg.safety_check_due() is None + cfg = AccessConfig.from_config( + {"allowed_users": ["u1"], "approvers": {"u1": NOTIFY}, "remote_close": "with_camera", + "camera": "camera.drive", "safety_devices_verified": date(2026, 10, 1), "safety_check_days": 10}, + pin_code="4321", + ) + assert cfg.approvers == {"u1": NOTIFY} + assert cfg.remote_close is RemoteClose.WITH_CAMERA + assert cfg.safety_check_due() == date(2026, 10, 11) + assert cfg.pin_code == "4321" + + +# ── Who may ask ───────────────────────────────────────────────────────── + + +async def test_request_refuses_automations_and_strangers(hass: HomeAssistant): + """Requests without a user (automations) and from users not on the list are refused.""" + parent = await _user(hass, "Parent") + child = await _user(hass, "Child") + h = Harness(hass, _config(parent.id)) + hass.states.async_set(SENSOR, STATE_OFF) + with pytest.raises(AccessDenied, match="a person must request it"): + await h.ctrl.async_request(Intent.OPEN, None) + with pytest.raises(AccessDenied, match="a person must request it"): + await h.ctrl.async_request(Intent.OPEN, Context()) + with pytest.raises(AccessDenied, match="not allowed"): + await h.ctrl.async_request(Intent.OPEN, Context(user_id=child.id)) + assert h.ctrl.phase is Phase.IDLE + + +async def test_request_without_hass_raises(): + """A controller that is not attached refuses with a clear error.""" + h = Harness(None, _config("u1")) # type: ignore[arg-type] + with pytest.raises(HomeAssistantError, match="not attached"): + await h.ctrl.async_request(Intent.OPEN, Context(user_id="u1")) + + +# ── Open flow ─────────────────────────────────────────────────────────── + + +async def test_open_flow_requires_on_phone_approval(hass: HomeAssistant): + """Open: a request sends an authenticated approval; only the approval pulses the relay.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id)) + hass.states.async_set(SENSOR, STATE_OFF) + + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + assert h.ctrl.phase is Phase.PENDING + assert h.pulses == [] + action = _approve_action(calls) + assert action["authenticationRequired"] is True + assert action["destructive"] is False + assert action["action"].startswith(f"{ACTION_PREFIX}APPROVE_") + assert len(action["action"]) > len(ACTION_PREFIX) + 20 # carries a random nonce + assert "image" not in calls[-1].data["data"] + assert "Parent asks to open Garage 1" in calls[-1].data["message"] + + await _press(hass, action["action"], parent.id) + await hass.async_block_till_done() + assert h.pulses == [(Intent.OPEN, Effect.OPEN)] + assert h.ctrl.phase is Phase.MONITORING + assert any("open by Parent" in c.data["message"] for c in calls) + + hass.states.async_set(SENSOR, STATE_ON) + _fire(hass, 6) + await hass.async_block_till_done() + assert h.ctrl.phase is Phase.IDLE + await h.ctrl.async_shutdown() + + +async def test_open_watchdog_reports_but_does_not_latch(hass: HomeAssistant): + """A door that did not start opening is reported; it is not a safety fault.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id)) + hass.states.async_set(SENSOR, STATE_OFF) + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + await _press(hass, _approve_action(calls)["action"], parent.id) + _fire(hass, 6) + await hass.async_block_till_done() + assert h.ctrl.phase is Phase.IDLE + assert h.ctrl.fault_reason is None + assert any("did not start opening" in c.data["message"] for c in calls) + + +async def test_approval_is_single_use_and_bound_to_the_user(hass: HomeAssistant): + """A replayed approval or one from another user's phone does nothing.""" + parent = await _user(hass, "Parent") + other = await _user(hass, "Other") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id)) + hass.states.async_set(SENSOR, STATE_OFF) + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + action = _approve_action(calls)["action"] + + await _press(hass, action, other.id) # wrong user: ignored, still pending + await _press(hass, f"{ACTION_PREFIX}APPROVE_forged", parent.id) # wrong nonce + await _press(hass, "SOMETHING_ELSE", parent.id) # not ours + hass.bus.async_fire(EVENT_NOTIFICATION_ACTION, {}, context=Context(user_id=parent.id)) + await hass.async_block_till_done() + assert h.ctrl.phase is Phase.PENDING + assert h.pulses == [] + + await _press(hass, action, parent.id) + await _press(hass, action, parent.id) # replay + assert h.pulses == [(Intent.OPEN, Effect.OPEN)] + await h.ctrl.async_shutdown() + + +async def test_deny_and_expiry(hass: HomeAssistant): + """Cancel on the phone ends the request; an unanswered request expires and clears the notification.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id)) + hass.states.async_set(SENSOR, STATE_OFF) + + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + deny = calls[-1].data["data"]["actions"][1]["action"] + await _press(hass, deny, parent.id) + assert h.ctrl.phase is Phase.IDLE + assert h.pulses == [] + + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + _fire(hass, 61) + await hass.async_block_till_done() + assert h.ctrl.phase is Phase.IDLE + assert calls[-1].data["message"] == "clear_notification" + assert calls[-1].data["data"]["tag"] == "myhome_access_cover.garage_1" + + +async def test_busy_and_late_or_changed_approvals(hass: HomeAssistant): + """One operation at a time; an approval that arrives late or after the door moved does nothing.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id)) + hass.states.async_set(SENSOR, STATE_OFF) + + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + with pytest.raises(AccessDenied, match="in progress"): + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + + hass.states.async_set(SENSOR, STATE_ON) # someone opened it with the remote + await _press(hass, _approve_action(calls)["action"], parent.id) + await hass.async_block_till_done() + assert h.pulses == [] + assert "could close" in calls[-1].data["message"] + + hass.states.async_set(SENSOR, STATE_OFF) + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + h.ctrl._pending.created = time.monotonic() - 120 # type: ignore[union-attr] + await _press(hass, _approve_action(calls)["action"], parent.id) + await hass.async_block_till_done() + assert h.pulses == [] + assert "too late" in calls[-1].data["message"] + + +async def test_dropped_pulse_is_reported(hass: HomeAssistant): + """A pulse refused by the deadband is reported to the requester.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id), pulse_ok=False) + hass.states.async_set(SENSOR, STATE_OFF) + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + await _press(hass, _approve_action(calls)["action"], parent.id) + await hass.async_block_till_done() + assert h.ctrl.phase is Phase.IDLE + assert "dropped" in calls[-1].data["message"] + + +async def test_failed_notification_aborts_request(hass: HomeAssistant): + """Without a working notifier no request is left pending.""" + parent = await _user(hass, "Parent") + h = Harness(hass, _config(parent.id)) + hass.states.async_set(SENSOR, STATE_OFF) + with pytest.raises(HomeAssistantError, match="could not send the approval request"): + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + assert h.ctrl.phase is Phase.IDLE + assert h.ctrl._unsub_action is None + + +# ── PIN ───────────────────────────────────────────────────────────────── + + +async def test_pin_reply_and_lockout(hass: HomeAssistant): + """With a PIN the approval asks for it; three wrong PINs lock the user out.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id, pin_code="2468")) + hass.states.async_set(SENSOR, STATE_OFF) + + for _ in range(3): + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + action = _approve_action(calls) + assert action["behavior"] == "textInput" + await _press(hass, action["action"], parent.id, reply_text="0000") + await hass.async_block_till_done() + assert "wrong PIN" in calls[-1].data["message"] + assert h.pulses == [] + with pytest.raises(AccessDenied, match="too many wrong PINs"): + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + + h.ctrl._failed_pins.clear() + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + await _press(hass, _approve_action(calls)["action"], parent.id, reply_text="2468") + await hass.async_block_till_done() + assert h.pulses == [(Intent.OPEN, Effect.OPEN)] + assert h.ctrl.attributes()["pin_protected"] is True + await h.ctrl.async_shutdown() + + +# ── Close policy ──────────────────────────────────────────────────────── + + +async def test_close_policy_refusals(hass: HomeAssistant): + """Every close precondition refuses on its own.""" + parent = await _user(hass, "Parent") + away = await _user(hass, "Away", home=False) + nobody = await hass.auth.async_create_user("Nobody") # no person entity: not home + hass.states.async_set(SENSOR, STATE_ON) + ctx = Context(user_id=parent.id) + + h = Harness(hass, _config(parent.id, remote_close=RemoteClose.NEVER)) + with pytest.raises(AccessDenied, match="closing from Home Assistant is disabled"): + await h.ctrl.async_request(Intent.CLOSE, ctx) + + h = Harness(hass, _config(parent.id, safety_devices_verified=None)) + with pytest.raises(AccessDenied, match="safety devices"): + await h.ctrl.async_request(Intent.CLOSE, ctx) + + h = Harness(hass, _config(parent.id, safety_devices_verified=dt_util.now().date() - timedelta(days=40))) + with pytest.raises(AccessDenied, match="periodic safety test was due"): + await h.ctrl.async_request(Intent.CLOSE, ctx) + + h = Harness(hass, _config(parent.id, close_block_entity="input_boolean.kids_playing")) + with pytest.raises(AccessDenied, match="missing"): + await h.ctrl.async_request(Intent.CLOSE, ctx) + hass.states.async_set("input_boolean.kids_playing", STATE_ON) + with pytest.raises(AccessDenied, match="blocked by input_boolean.kids_playing"): + await h.ctrl.async_request(Intent.CLOSE, ctx) + + for user in (away, nobody): + h = Harness(hass, _config(user.id)) + with pytest.raises(AccessDenied, match="needs you at home"): + await h.ctrl.async_request(Intent.CLOSE, Context(user_id=user.id)) + h = Harness(hass, _config(user.id, remote_close=RemoteClose.WITH_CAMERA)) + with pytest.raises(AccessDenied, match="needs a camera image"): + await h.ctrl.async_request(Intent.CLOSE, Context(user_id=user.id)) + + +async def test_close_flow_prewarning_watchdog_fault_and_acknowledge(hass: HomeAssistant): + """Close: pre-warning, pulse, and a latched fault when the door does not close.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + light_calls = async_mock_service(hass, "light", "toggle") + restore_calls = async_mock_service(hass, "light", "turn_off") + prewarn_events = [] + hass.bus.async_listen(EVENT_ACCESS_PREWARNING, prewarn_events.append) + hass.states.async_set("light.garage", STATE_OFF) + h = Harness(hass, _config(parent.id, prewarn_light="light.garage")) + hass.states.async_set(SENSOR, STATE_ON) + ctx = Context(user_id=parent.id) + + await h.ctrl.async_request(Intent.CLOSE, ctx) + action = _approve_action(calls) + assert action["destructive"] is True + assert action["title"] == "Area clear - close" + assert "door area is clear" in calls[-1].data["message"] + + await _press(hass, action["action"], parent.id) + await _wait_phase(h.ctrl, Phase.MONITORING) + assert h.pulses == [(Intent.CLOSE, Effect.MAY_CLOSE)] + assert len(prewarn_events) == 1 + assert light_calls and restore_calls + + _fire(hass, 21) # travel 15 + margin 5: still open + await hass.async_block_till_done() + assert h.ctrl.phase is Phase.FAULT + assert "did not close" in (h.ctrl.fault_reason or "") + assert any(c.data["message"].startswith("ALARM Garage 1") for c in calls) + + with pytest.raises(AccessDenied, match="ended in a fault"): + await h.ctrl.async_request(Intent.CLOSE, ctx) + + hass.states.async_set("person.parent", STATE_NOT_HOME, {"user_id": parent.id}) + with pytest.raises(AccessDenied, match="on site"): + await h.ctrl.async_acknowledge_fault(ctx) + hass.states.async_set("person.parent", STATE_HOME, {"user_id": parent.id}) + await h.ctrl.async_acknowledge_fault(ctx) + assert h.ctrl.phase is Phase.IDLE + with pytest.raises(AccessDenied, match="no fault"): + await h.ctrl.async_acknowledge_fault(ctx) + + +async def test_open_during_fault_keeps_the_fault_latched(hass: HomeAssistant): + """Opening stays possible during a fault, and does not clear it.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id)) + h.ctrl.fault_reason = "did not close: possible obstacle or reversal" + h.ctrl.phase = Phase.FAULT + hass.states.async_set(SENSOR, STATE_OFF) + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + with pytest.raises(AccessDenied, match="wait until"): + await h.ctrl.async_acknowledge_fault(Context(user_id=parent.id)) + await _press(hass, _approve_action(calls)["action"], parent.id) + hass.states.async_set(SENSOR, STATE_ON) + _fire(hass, 6) + await hass.async_block_till_done() + assert h.ctrl.phase is Phase.FAULT + await h.ctrl.async_shutdown() + + +async def test_close_success_returns_to_idle(hass: HomeAssistant): + """A door that reports closed before the watchdog expires ends the operation.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id)) + hass.states.async_set(SENSOR, STATE_ON) + await h.ctrl.async_request(Intent.CLOSE, Context(user_id=parent.id)) + await _press(hass, _approve_action(calls)["action"], parent.id) + await _wait_phase(h.ctrl, Phase.MONITORING) + hass.states.async_set(SENSOR, STATE_OFF) + _fire(hass, 21) + await hass.async_block_till_done() + assert h.ctrl.phase is Phase.IDLE + + +async def test_prewarning_cancel_sends_nothing(hass: HomeAssistant): + """A cancel during the warning (wall button, service) aborts the pulse.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id, prewarn_seconds=5.0, prewarn_light="light.missing")) + hass.states.async_set("light.missing", STATE_ON) + hass.states.async_set(SENSOR, STATE_ON) + try: + await h.ctrl.async_request(Intent.CLOSE, Context(user_id=parent.id)) + await _press(hass, _approve_action(calls)["action"], parent.id, wait=False) + await _wait_phase(h.ctrl, Phase.PREWARNING) + await h.ctrl.async_cancel(None) + await _wait_phase(h.ctrl, Phase.IDLE) + await hass.async_block_till_done() + assert h.pulses == [] + assert "cancelled during the warning" in calls[-1].data["message"] + finally: + await h.ctrl.async_shutdown() + + +async def test_cancel_pending_and_nothing_to_cancel(hass: HomeAssistant): + """Cancelling a pending request informs the requester; with nothing pending it is refused.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id)) + with pytest.raises(AccessDenied, match="nothing to cancel"): + await h.ctrl.async_cancel(Context(user_id=parent.id)) + hass.states.async_set(SENSOR, STATE_OFF) + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + assert h.ctrl.cancel("wall button pressed") is True + await hass.async_block_till_done() + assert h.ctrl.phase is Phase.IDLE + assert "request cancelled (wall button pressed)" in calls[-1].data["message"] + + +async def test_camera_close_from_away(hass: HomeAssistant): + """With a camera, closing from away is offered with the image in the approval.""" + away = await _user(hass, "Away", home=False) + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(away.id, remote_close=RemoteClose.WITH_CAMERA, camera="camera.garage")) + hass.states.async_set(SENSOR, STATE_ON) + await h.ctrl.async_request(Intent.CLOSE, Context(user_id=away.id)) + assert calls[-1].data["data"]["image"] == "/api/camera_proxy/camera.garage" + await h.ctrl.async_shutdown() + assert h.ctrl._unsub_action is None + + +async def test_sensorless_gate_and_stop(hass: HomeAssistant): + """Without a sensor every pulse is a possible close and the result is reported as unverifiable.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id), state_sensor=None) + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + assert "direction unknown" in calls[-1].data["title"] + await _press(hass, _approve_action(calls)["action"], parent.id) + await _wait_phase(h.ctrl, Phase.IDLE) + await hass.async_block_till_done() + assert h.pulses == [(Intent.OPEN, Effect.MAY_CLOSE)] + assert "cannot be verified" in calls[-1].data["message"] + + h2 = Harness(hass, _config(parent.id)) + hass.states.async_set(SENSOR, STATE_ON) + await h2.ctrl.async_request(Intent.STOP, Context(user_id=parent.id)) + assert "stop" in calls[-1].data["title"] + await _press(hass, _approve_action(calls)["action"], parent.id) + await _wait_phase(h2.ctrl, Phase.IDLE) + assert h2.pulses == [(Intent.STOP, Effect.MAY_CLOSE)] + + +async def test_best_effort_notifications_never_raise(hass: HomeAssistant): + """Status and clear notifications swallow notifier errors.""" + parent = await _user(hass, "Parent") + async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id)) + hass.states.async_set(SENSOR, STATE_OFF) + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + hass.services.async_remove("notify", "mobile_app_parent") + await h.ctrl._notify(NOTIFY, "hello") + _fire(hass, 61) # expiry tries to clear the notification + await hass.async_block_till_done() + assert h.ctrl.phase is Phase.IDLE + + +async def test_attributes_never_expose_secrets(hass: HomeAssistant): + """Attributes show the policy state but no nonce or PIN.""" + h = Harness(hass, _config("u1", pin_code="2468")) + attrs = h.ctrl.attributes() + assert attrs["access_phase"] == "idle" + assert attrs["remote_close"] == "at_home" + assert attrs["safety_check_due"] == (dt_util.now().date() + timedelta(days=31)).isoformat() + assert "2468" not in str(attrs) + + +# ── Validation ────────────────────────────────────────────────────────── + + +def _cover(**cfg) -> dict: + return {"garage": {"name": "Garage 1", **cfg}} + + +def test_schema_accepts_full_access_block(): + """A complete access block validates and keeps its values.""" + data = cover_schema(_cover( + who="1", where="21", type="impulse_relay", device_class="garage", + state_sensor="binary_sensor.garage_contact_1", pin_code=1357, + access={ + "allowed_users": ["u1"], "approvers": {"u1": NOTIFY}, "remote_close": "with_camera", + "camera": "camera.garage", "safety_devices_verified": "2026-10-08", "prewarn_light": "light.garage", + "close_block_entity": "input_boolean.kids_playing", + }, + )) + cfg = data["1-21"] + assert cfg["pin_code"] == "1357" + assert cfg["access"]["safety_devices_verified"] == date(2026, 10, 8) + assert cfg["access"]["prewarn_seconds"] == 5 + + +@pytest.mark.parametrize( + ("cfg", "match"), + [ + (dict(who="1", where="9"), "point-to-point"), + (dict(who="1", where="0"), "point-to-point"), + (dict(who="2", where="25", access={}), "only apply to impulse covers"), + (dict(who="1", where="21", access={"allowed_users": ["u1"]}), "needs an approver"), + (dict(who="1", where="21", access={"approvers": {"u1": NOTIFY}}), "not in allowed_users"), + (dict(who="1", where="21", access={"allowed_users": ["u1"], "approvers": {"u1": "light.x"}}), "notify service"), + (dict(who="1", where="21", access={"remote_close": "with_camera"}), "needs a camera"), + (dict(who="1", where="21", access={"prewarn_seconds": 1}), "prewarn_seconds"), + ], +) +def test_schema_rejects_unsafe_config(cfg, match): + """Group/area addresses and incomplete access blocks are rejected.""" + with pytest.raises(Invalid, match=match): + cover_schema(_cover(**cfg)) diff --git a/tests/test_component_impulse_cover.py b/tests/test_component_impulse_cover.py new file mode 100644 index 000000000..8fcf69c79 --- /dev/null +++ b/tests/test_component_impulse_cover.py @@ -0,0 +1,406 @@ +"""Tests for MyHOME impulse cover platform (gates, garage doors, impulse relays WHO=1).""" +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.components.cover import ( + CoverDeviceClass, +) +from homeassistant.const import ( + CONF_MAC, + CONF_NAME, + STATE_OFF, + STATE_ON, + Platform, +) +from homeassistant.core import Context, HomeAssistant +from homeassistant.util import dt as dt_util +from OWNd.message import ( + OWNEvent, + OWNLightingCommand, +) +from pytest_homeassistant_custom_component.common import async_fire_time_changed, async_mock_service + +from custom_components.myhome.access_policy import ( + AccessConfig, + AccessDenied, + Effect, + Intent, + RemoteClose, +) +from custom_components.myhome.const import ( + CONF_DEVICE_CLASS, + CONF_MIN_CYCLE_TIME, + CONF_PIN_CODE, + CONF_PLATFORMS, + CONF_PULSE_DURATION, + CONF_TRAVEL_TIME, + CONF_TYPE, + CONF_WHERE, + CONF_WHO, + DOMAIN, + TYPE_IMPULSE_RELAY, +) +from custom_components.myhome.cover import ( + PLATFORM, + MyHOMEImpulseCover, + async_setup_entry, +) +from tests.conftest import attach_runtime + + +@pytest.fixture +def mock_gateway(): + gw = MagicMock() + gw.mac = "00:03:50:00:88:88" + gw.unique_id = "00:03:50:00:88:88" + gw.log_id = "[Test Gateway]" + gw.device_registry_id = "mock_gw_dev_id" + gw.available = True + gw.availability_signal = "myhome_gateway_availability_000350008888" + gw.send = AsyncMock() + gw.send_status_request = AsyncMock() + return gw + + +async def test_impulse_cover_setup_restores_and_configures(hass: HomeAssistant, mock_gateway): + """Test setup of WHO=1 impulse covers from YAML and entity registry.""" + mac = mock_gateway.mac + hass.data = { + DOMAIN: { + mac: { + "entity": mock_gateway, + CONF_PLATFORMS: { + PLATFORM: { + "1-22": { + CONF_WHERE: "22", + CONF_WHO: "1", + CONF_NAME: "Entrance Gate", + CONF_DEVICE_CLASS: CoverDeviceClass.GATE, + CONF_TYPE: TYPE_IMPULSE_RELAY, + CONF_TRAVEL_TIME: 20, + CONF_PULSE_DURATION: 0.5, + CONF_MIN_CYCLE_TIME: 5.0, + CONF_PIN_CODE: "1234", + }, + } + }, + } + } + } + + config_entry = MagicMock() + config_entry.data = {CONF_MAC: mac} + config_entry.entry_id = "cover_test_entry" + + mock_er = MagicMock() + reg_1 = MagicMock() + reg_1.domain = Platform.COVER + reg_1.unique_id = f"{mac}-1-21" + + with ( + patch("homeassistant.helpers.entity_registry.async_get", return_value=mock_er), + patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[reg_1]), + ): + added_entities = [] + + def fake_add_entities(entities): + added_entities.extend(entities) + + attach_runtime(hass, config_entry) + await async_setup_entry(hass, config_entry, fake_add_entities) + + # 1 restored (where=21) + 1 configured from YAML (where=22) + impulse_entities = [e for e in added_entities if isinstance(e, MyHOMEImpulseCover)] + assert len(impulse_entities) == 2 + gate = next(e for e in impulse_entities if e._where == "22") + assert gate._device_name == "Entrance Gate" + assert gate.device_class == CoverDeviceClass.GATE + assert gate.extra_state_attributes["pin_protected"] is True + assert gate.assumed_state is True + + +@pytest.fixture +def impulse_gate(hass: HomeAssistant, mock_gateway): + """Create an impulse gate entity.""" + with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): + cover = MyHOMEImpulseCover( + hass=hass, + name="Driveway Gate", + entity_name=None, + device_id="22", + who="1", + where="22", + interface=None, + device_class=CoverDeviceClass.GATE, + travel_time=10.0, + pulse_duration=0.5, + min_cycle_time=2.0, + pin_code="1234", + state_sensor=None, + manufacturer="BTicino", + model="Impulse Cover", + gateway=mock_gateway, + ) + cover.hass = hass + cover.entity_id = "cover.driveway_gate" + cover.async_write_ha_state = MagicMock() + yield cover + if cover._pulse_off_cancel is not None: + cover._pulse_off_cancel() + cover._pulse_off_cancel = None + if cover._travel_timer_cancel is not None: + cover._travel_timer_cancel() + cover._travel_timer_cancel = None + + +async def test_impulse_cover_open_and_close_cycle(hass: HomeAssistant, impulse_gate, mock_gateway): + """Test full open and close cycle with 500ms auto-reset pulse and travel time.""" + # 1. Pulse open + assert await impulse_gate._async_pulse(Intent.OPEN, Effect.OPEN) is True + + # Relay ON command sent immediately + assert mock_gateway.send.call_count == 1 + on_cmd = mock_gateway.send.call_args[0][0] + assert isinstance(on_cmd, OWNLightingCommand) + assert str(on_cmd) == "*1*1*22##" + assert impulse_gate.is_opening is True + assert impulse_gate.is_closed is False + + # 2. Advance time by 0.5s: Auto-reset pulse turns relay OFF + future = dt_util.utcnow() + timedelta(seconds=0.6) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert mock_gateway.send.call_count == 2 + off_cmd = mock_gateway.send.call_args[0][0] + assert str(off_cmd) == "*1*0*22##" + assert impulse_gate.is_opening is True + + # 3. Advance time to complete travel (10s) + future = dt_util.utcnow() + timedelta(seconds=11.0) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert impulse_gate.is_opening is False + assert impulse_gate.is_closed is False + + # 4. Pulse close + mock_gateway.send.reset_mock() + impulse_gate._last_pulse_time = -9999.0 + assert await impulse_gate._async_pulse(Intent.CLOSE, Effect.MAY_CLOSE) is True + + assert mock_gateway.send.call_count == 1 + on_cmd = mock_gateway.send.call_args[0][0] + assert str(on_cmd) == "*1*1*22##" + assert impulse_gate.is_closing is True + + # Auto-off pulse after 0.5s + future = dt_util.utcnow() + timedelta(seconds=0.6) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert mock_gateway.send.call_count == 2 + assert str(mock_gateway.send.call_args[0][0]) == "*1*0*22##" + + # Finish closing travel (10s) + future = dt_util.utcnow() + timedelta(seconds=11.0) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert impulse_gate.is_closing is False + assert impulse_gate.is_closed is True + + +async def test_impulse_cover_access_control_flow(hass: HomeAssistant, impulse_gate): + """Test access policy integration: human context required and cancellation.""" + # Automated calls without user context are strictly rejected + with pytest.raises(AccessDenied, match="automations, scripts and integrations may not operate"): + await impulse_gate.async_open_cover() + + with pytest.raises(AccessDenied, match="automations, scripts and integrations may not operate"): + await impulse_gate.async_close_cover() + + with pytest.raises(AccessDenied, match="automations, scripts and integrations may not operate"): + await impulse_gate.async_stop_cover() + + # With authorized user context, creates pending approval request + user = await hass.auth.async_create_user("Alice") + hass.states.async_set("person.alice", "home", {"user_id": user.id}) + async_mock_service(hass, "notify", "mobile_app_phone") + impulse_gate._access.config = AccessConfig( + allowed_users=(user.id,), + approvers={user.id: "notify.mobile_app_phone"}, + remote_close=RemoteClose.AT_HOME, + safety_devices_verified=dt_util.now().date(), + pin_code="1234", + ) + impulse_gate._context = Context(user_id=user.id) + + await impulse_gate.async_open_cover() + assert impulse_gate.extra_state_attributes["access_phase"] == "pending_approval" + + # Cancel request via entity service method + await impulse_gate.async_cancel_request() + assert impulse_gate.extra_state_attributes["access_phase"] == "idle" + + # Acknowledge fault when there is no fault raises AccessDenied + with pytest.raises(AccessDenied, match="there is no fault to acknowledge"): + await impulse_gate.async_acknowledge_fault() + + await impulse_gate._access.async_shutdown() + + +async def test_impulse_cover_deadband_lockout(hass: HomeAssistant, impulse_gate, mock_gateway): + """Test that commands sent within min_cycle_time are dropped.""" + # First pulse succeeds + assert await impulse_gate._async_pulse(Intent.OPEN, Effect.OPEN) is True + assert mock_gateway.send.call_count == 1 + + # Second pulse 0.5s later (within 2.0s deadband) is dropped + assert await impulse_gate._async_pulse(Intent.OPEN, Effect.OPEN) is False + assert mock_gateway.send.call_count == 1 + + +async def test_impulse_cover_stop(hass: HomeAssistant, impulse_gate, mock_gateway): + """Test stop command cancels transit timer.""" + await impulse_gate._async_pulse(Intent.OPEN, Effect.OPEN) + assert impulse_gate.is_opening is True + + # Advance time to auto-reset relay pulse + future = dt_util.utcnow() + timedelta(seconds=0.6) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # Reset last pulse time so stop is not blocked by deadband lockout + impulse_gate._last_pulse_time = -9999.0 + await impulse_gate._async_pulse(Intent.STOP, Effect.MAY_CLOSE) + assert impulse_gate.is_opening is False + assert impulse_gate.is_closing is False + + +async def test_impulse_cover_state_sensor_binding(hass: HomeAssistant, mock_gateway): + """Test binding to a physical contact sensor (e.g. Risco door contact).""" + with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): + cover = MyHOMEImpulseCover( + hass=hass, + name="Garage Door", + entity_name=None, + device_id="21", + who="1", + where="21", + interface=None, + device_class=CoverDeviceClass.GARAGE, + travel_time=15.0, + pulse_duration=0.5, + min_cycle_time=1.0, + pin_code=None, + state_sensor="binary_sensor.garage_door_contact", + manufacturer="BTicino", + model="Impulse Cover", + gateway=mock_gateway, + ) + cover.hass = hass + cover.entity_id = "cover.garage_door" + cover.async_write_ha_state = MagicMock() + + try: + # Set initial sensor state: off (closed) + hass.states.async_set("binary_sensor.garage_door_contact", STATE_OFF) + await cover.async_added_to_hass() + + assert cover.assumed_state is False + assert cover.is_closed is True + + # Pulsing open + assert await cover._async_pulse(Intent.OPEN, Effect.OPEN) is True + assert mock_gateway.send.call_count == 1 + assert cover.is_opening is True + assert cover.is_closed is False + + # Advance 0.6s for auto-off pulse + future = dt_util.utcnow() + timedelta(seconds=0.6) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert mock_gateway.send.call_count == 2 + mock_gateway.send.reset_mock() + + # Sensor state updates to on (open) + hass.states.async_set("binary_sensor.garage_door_contact", STATE_ON) + await hass.async_block_till_done() + assert cover.is_closed is False + assert cover.is_opening is False + + # Sensor state updates to off (closed) + hass.states.async_set("binary_sensor.garage_door_contact", STATE_OFF) + await hass.async_block_till_done() + assert cover.is_closed is True + assert cover.is_opening is False + finally: + await cover.async_will_remove_from_hass() + + +def test_impulse_cover_bus_event_handling(hass: HomeAssistant, impulse_gate): + """Test handling bus frames from physical wall button presses.""" + # Frame with what=0 (e.g. 5-minute delayed timer expiring) must be ignored + off_event = OWNEvent.parse("*1*0*22##") + impulse_gate.handle_event(off_event) + assert impulse_gate.is_opening is False + assert impulse_gate.is_closing is False + + # Translation frames are ignored + translation_event = MagicMock() + translation_event.is_translation = True + impulse_gate.handle_event(translation_event) + assert impulse_gate.is_opening is False + + # External wall press frame *1*1*22## triggers opening when closed + impulse_gate._attr_is_closed = True + on_event = OWNEvent.parse("*1*1*22##") + impulse_gate.handle_event(on_event) + assert impulse_gate.is_opening is True + assert impulse_gate.is_closed is False + + # Complete opening travel time + future = dt_util.utcnow() + timedelta(seconds=11.0) + async_fire_time_changed(hass, future) + assert impulse_gate.is_opening is False + assert impulse_gate.is_closed is False + + # Echo window ignores frames within 1.0s of sending pulse + impulse_gate._last_pulse_time = 999999999.0 + impulse_gate.handle_event(on_event) + assert impulse_gate.is_closing is False + + # Reset last pulse time: next press starts closing + impulse_gate._last_pulse_time = -9999.0 + impulse_gate.handle_event(on_event) + assert impulse_gate.is_closing is True + + # Complete closing travel time + future = dt_util.utcnow() + timedelta(seconds=11.0) + async_fire_time_changed(hass, future) + assert impulse_gate.is_closing is False + assert impulse_gate.is_closed is True + + +async def test_impulse_cover_deadband_rejections_and_cleanup(hass: HomeAssistant, impulse_gate): + """Test deadband drops for close and stop, and cleanup on removal.""" + # Send open pulse + assert await impulse_gate._async_pulse(Intent.OPEN, Effect.OPEN) is True + assert impulse_gate.is_opening is True + + # Closing immediately rejected by deadband + assert await impulse_gate._async_pulse(Intent.CLOSE, Effect.MAY_CLOSE) is False + assert impulse_gate.is_opening is True + + # Stopping immediately rejected by deadband + assert await impulse_gate._async_pulse(Intent.STOP, Effect.MAY_CLOSE) is False + assert impulse_gate.is_opening is True + + # Call async_will_remove_from_hass with pending timers + assert impulse_gate._pulse_off_cancel is not None + assert impulse_gate._travel_timer_cancel is not None + await impulse_gate.async_will_remove_from_hass() + assert impulse_gate._pulse_off_cancel is None + assert impulse_gate._travel_timer_cancel is None + diff --git a/tests/test_component_lock.py b/tests/test_component_lock.py new file mode 100644 index 000000000..1bb77f195 --- /dev/null +++ b/tests/test_component_lock.py @@ -0,0 +1,291 @@ +"""Tests for MyHOME lock platform (WHO=6 door entry electric strikes).""" +from datetime import timedelta +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.components.lock import LockEntityFeature +from homeassistant.const import CONF_NAME, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ServiceValidationError +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import dt as dt_util +from OWNd.message import ( + OWNDoorEntryCommand, + OWNDoorEntryEvent, + OWNEvent, +) +from pytest_homeassistant_custom_component.common import async_fire_time_changed + +from custom_components.myhome.const import ( + CONF_PLATFORMS, + CONF_WHERE, + DOMAIN, +) +from custom_components.myhome.lock import ( + DEFAULT_LOCK_DURATION, + PLATFORM, + MyHOMELock, + async_setup_entry, + async_unload_entry, +) +from tests.conftest import attach_runtime + + +@pytest.fixture +def mock_gateway(): + gw = MagicMock() + gw.mac = "00:03:50:00:66:66" + gw.unique_id = "00:03:50:00:66:66" + gw.log_id = "[Test Lock Gateway]" + gw.device_registry_id = "mock_gw_dev_id" + gw.available = True + gw.availability_signal = "myhome_gateway_availability_000350006666" + gw.send = AsyncMock() + gw.send_status_request = AsyncMock() + return gw + + +async def test_lock_setup_restores_and_discovers(hass: HomeAssistant, mock_gateway): + """Test lock platform setup: restoring from registry, configuring from YAML, and bus discovery.""" + mac = mock_gateway.mac + hass.data = { + DOMAIN: { + mac: { + "entity": mock_gateway, + CONF_PLATFORMS: { + PLATFORM: { + "2": { + CONF_WHERE: "2", + CONF_NAME: "Side Gate Lock", + }, + } + }, + } + } + } + + config_entry = MagicMock() + config_entry.data = {"mac": mac} + config_entry.entry_id = "lock_test_entry" + + mock_er = MagicMock() + reg_1 = MagicMock() + reg_1.domain = Platform.LOCK + reg_1.unique_id = f"{mac}-6-1" + + with ( + patch("homeassistant.helpers.entity_registry.async_get", return_value=mock_er), + patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[reg_1]), + ): + added_entities = [] + + def fake_add_entities(entities): + added_entities.extend(entities) + + attach_runtime(hass, config_entry) + await async_setup_entry(hass, config_entry, fake_add_entities) + + # Restored (1) + Configured from YAML (2) = 2 locks + assert len(added_entities) == 2 + assert any(e._where == "1" for e in added_entities) + assert any(e._where == "2" for e in added_entities) + + # Bus event: lock open on where 3 -> should be discovered + lock_open_msg = OWNEvent.parse("*6*10*3##") + assert isinstance(lock_open_msg, OWNDoorEntryEvent) + async_dispatcher_send(hass, f"myhome_message_{mac}", lock_open_msg) + assert len(added_entities) == 3 + assert any(e._where == "3" for e in added_entities) + + # Incoming call event -> must NOT create a lock entity + call_msg = OWNEvent.parse("*6*6*4##") + assert isinstance(call_msg, OWNDoorEntryEvent) + async_dispatcher_send(hass, f"myhome_message_{mac}", call_msg) + assert len(added_entities) == 3 + + # Broadcast lock or invalid address -> must NOT create a lock entity + broadcast_msg = OWNEvent.parse("*6*10*4100##") + async_dispatcher_send(hass, f"myhome_message_{mac}", broadcast_msg) + assert len(added_entities) == 3 + + # Unload + attach_runtime(hass, config_entry) + assert await async_unload_entry(hass, config_entry) is True + + +class TestMyHOMELockEntity: + """Test MyHOMELock entity functionality and timing behavior.""" + + @pytest.fixture + def lock_entity(self, hass: HomeAssistant, mock_gateway): + with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): + lock = MyHOMELock( + hass=hass, + name="Front Door Lock", + entity_name=None, + device_id="1", + who="6", + where="1", + interface=None, + manufacturer="BTicino", + model="Door Entry Lock", + gateway=mock_gateway, + ) + lock.hass = hass + lock.entity_id = "lock.front_door_lock" + lock.async_write_ha_state = MagicMock() + lock.async_schedule_update_ha_state = MagicMock() + return lock + + def test_lock_attributes(self, lock_entity): + """Verify lock features and initial state.""" + assert lock_entity.supported_features == LockEntityFeature.OPEN + assert lock_entity.is_locked is True + assert lock_entity.extra_state_attributes["where"] == "1" + assert "interface" not in lock_entity.extra_state_attributes + + async def test_unlock_and_auto_relock(self, hass: HomeAssistant, lock_entity, mock_gateway): + """Test unlock command, state change, and automatic relock after timeout.""" + await lock_entity.async_unlock() + + # Gateway command should be sent + mock_gateway.send.assert_called_once() + cmd = mock_gateway.send.call_args[0][0] + assert isinstance(cmd, OWNDoorEntryCommand) + assert str(cmd) == "*6*10*1##" + + # Lock is momentarily unlocked + assert lock_entity.is_locked is False + + # Fast forward time by DEFAULT_LOCK_DURATION + 0.1s + future = dt_util.utcnow() + timedelta(seconds=DEFAULT_LOCK_DURATION + 0.1) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + # Lock should be automatically relocked + assert lock_entity.is_locked is True + + async def test_open_action(self, hass: HomeAssistant, lock_entity, mock_gateway): + """Test open action releases strike like unlock.""" + await lock_entity.async_open() + + mock_gateway.send.assert_called_once() + assert lock_entity.is_locked is False + + # Explicit lock call before timeout relocks immediately + await lock_entity.async_lock() + assert lock_entity.is_locked is True + + def test_handle_event_lock_open(self, hass: HomeAssistant, lock_entity): + """Test bus event *6*10*1## triggers momentary unlock.""" + event_msg = OWNEvent.parse("*6*10*1##") + assert isinstance(event_msg, OWNDoorEntryEvent) + assert event_msg.is_lock_open is True + + lock_entity.handle_event(event_msg) + assert lock_entity.is_locked is False + + # Advance timer to verify relock + future = dt_util.utcnow() + timedelta(seconds=DEFAULT_LOCK_DURATION + 0.1) + async_fire_time_changed(hass, future) + + assert lock_entity.is_locked is True + + def test_handle_event_call_ignored_by_lock(self, lock_entity): + """Call event (*6*6*1##) should not alter lock state.""" + event_msg = OWNEvent.parse("*6*6*1##") + assert isinstance(event_msg, OWNDoorEntryEvent) + assert event_msg.is_call is True + assert event_msg.is_lock_open is False + + lock_entity.handle_event(event_msg) + assert lock_entity.is_locked is True + + async def test_routed_lock_interface(self, hass: HomeAssistant, mock_gateway): + """Test lock on a secondary bus via F422 interface (#4#01).""" + with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): + lock = MyHOMELock( + hass=hass, + name="Back Gate", + entity_name=None, + device_id="5#4#01", + who="6", + where="5", + interface="01", + manufacturer="BTicino", + model="Door Entry Lock", + gateway=mock_gateway, + ) + lock.hass = hass + lock.entity_id = "lock.back_gate" + lock.async_write_ha_state = MagicMock() + + assert lock.extra_state_attributes["where"] == "5" + assert lock.extra_state_attributes["interface"] == "01" + + await lock.async_unlock() + cmd = mock_gateway.send.call_args[0][0] + assert str(cmd) == "*6*10*5#4#01##" + await lock.async_will_remove_from_hass() + + +async def test_setup_and_unload_missing_runtime(hass: HomeAssistant): + """Test setup and unload return True when runtime or platform is not available.""" + mock_entry = MagicMock() + mock_entry.runtime_data = None + assert await async_setup_entry(hass, mock_entry, MagicMock()) is True + assert await async_unload_entry(hass, mock_entry) is True + + +async def test_impulse_lock_who1_code_and_pulse(hass: HomeAssistant, mock_gateway): + """Test WHO=1 impulse lock with security code and momentary auto-off.""" + with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): + lock = MyHOMELock( + hass=hass, + name="Impulse Gate Lock", + entity_name=None, + device_id="25", + who="1", + where="25", + interface=None, + manufacturer="BTicino", + model="Impulse Lock", + gateway=mock_gateway, + code="1234", + pulse_duration=0.5, + ) + lock.hass = hass + lock.entity_id = "lock.impulse_gate_lock" + lock.async_write_ha_state = MagicMock() + + assert lock.code_format == r"^\d+$" + assert lock.is_locked is True + + # Invalid code raises ServiceValidationError + with pytest.raises(ServiceValidationError, match="Invalid code"): + await lock.async_unlock(code="9999") + + # Valid code unlocks and pulses relay + await lock.async_unlock(code="1234") + assert mock_gateway.send.call_count == 1 + cmd = mock_gateway.send.call_args[0][0] + assert str(cmd) == "*1*1*25##" + assert lock.is_locked is False + + # Advance 0.6s -> auto-off pulse and relock + future = dt_util.utcnow() + timedelta(seconds=0.6) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + + assert mock_gateway.send.call_count == 2 + off_cmd = mock_gateway.send.call_args[0][0] + assert str(off_cmd) == "*1*0*25##" + assert lock.is_locked is True + + # Handle event: *1*1*25## unlocks momentarily + event_msg = OWNEvent.parse("*1*1*25##") + lock.handle_event(event_msg) + assert lock.is_locked is False + + await lock.async_will_remove_from_hass() + diff --git a/tests/test_door_entry_events.py b/tests/test_door_entry_events.py new file mode 100644 index 000000000..fd35f9d8c --- /dev/null +++ b/tests/test_door_entry_events.py @@ -0,0 +1,121 @@ +"""Tests for Door Entry (WHO=6) doorbell events and gateway handling.""" +from unittest.mock import MagicMock, patch + +from homeassistant.core import HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from OWNd.message import ( + OWNDoorEntryEvent, + OWNEvent, +) +from pytest_homeassistant_custom_component.common import async_capture_events + +from custom_components.myhome.gateway import MyHOMEGatewayHandler + + +async def test_gateway_fires_doorbell_events(hass: HomeAssistant): + """Test that MyHOMEGatewayHandler._process_message fires myhome_doorbell_event on calls and chimes.""" + config_entry = MagicMock() + config_entry.data = { + "host": "192.168.1.100", + "port": 20000, + "password": "open", + "mac": "00:03:50:00:77:77", + } + config_entry.options = {} + config_entry.entry_id = "test_doorbell_gw_entry" + + gateway = MyHOMEGatewayHandler(hass, config_entry) + gateway.generate_events = False + + captured_events = async_capture_events(hass, "myhome_doorbell_event") + + dispatcher_payloads = [] + + def handle_dispatcher(payload): + dispatcher_payloads.append(payload) + + unsub = async_dispatcher_connect( + hass, f"myhome_doorbell_event_{gateway.mac}", handle_dispatcher + ) + + try: + # 1. Incoming call from entrance panel 1 (*6*6*1##) + call_event = OWNEvent.parse("*6*6*1##") + assert isinstance(call_event, OWNDoorEntryEvent) + await gateway._process_message(call_event) + + assert len(captured_events) == 1 + assert captured_events[0].data == { + "where": "1", + "event": "call", + "is_broadcast": False, + "gateway_mac": gateway.mac, + "entry_id": config_entry.entry_id, + } + assert len(dispatcher_payloads) == 1 + assert dispatcher_payloads[0] == captured_events[0].data + + # 2. General / broadcast call (*6*6*4100##) + bcast_event = OWNEvent.parse("*6*6*4100##") + assert isinstance(bcast_event, OWNDoorEntryEvent) + await gateway._process_message(bcast_event) + + assert len(captured_events) == 2 + assert captured_events[1].data == { + "where": "4100", + "event": "broadcast_call", + "is_broadcast": True, + "gateway_mac": gateway.mac, + "entry_id": config_entry.entry_id, + } + assert len(dispatcher_payloads) == 2 + + # 3. Chime event (*6*20*1##) + chime_event = OWNEvent.parse("*6*20*1##") + assert isinstance(chime_event, OWNDoorEntryEvent) + await gateway._process_message(chime_event) + + assert len(captured_events) == 3 + assert captured_events[2].data == { + "where": "1", + "event": "chime", + "is_broadcast": False, + "gateway_mac": gateway.mac, + "entry_id": config_entry.entry_id, + } + assert len(dispatcher_payloads) == 3 + + # 4. Lock open event (*6*10*1##) -> should NOT fire myhome_doorbell_event + lock_event = OWNEvent.parse("*6*10*1##") + assert isinstance(lock_event, OWNDoorEntryEvent) + await gateway._process_message(lock_event) + + # No new events should have been captured + assert len(captured_events) == 3 + assert len(dispatcher_payloads) == 3 + finally: + unsub() + + +async def test_gateway_logs_door_entry_events(hass: HomeAssistant): + """Test that human-readable logs are emitted for door entry events.""" + config_entry = MagicMock() + config_entry.data = { + "host": "192.168.1.100", + "port": 20000, + "password": "open", + "mac": "00:03:50:00:77:77", + } + config_entry.options = {} + config_entry.entry_id = "test_doorbell_gw_entry" + + gateway = MyHOMEGatewayHandler(hass, config_entry) + + call_event = OWNEvent.parse("*6*6*1##") + with patch("custom_components.myhome.gateway.LOGGER.debug") as mock_debug: + await gateway._process_message(call_event) + mock_debug.assert_called_with( + "%s %s", + gateway.log_id, + call_event.human_readable_log, + ) From 60e50a7546d295cb928c5a385d73054bcd6a3ca2 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:35:07 +0200 Subject: [PATCH 02/26] fix(typing,compat): maintain strict-typing ratchet and dual-compat with released OWNd 2.0.0b10 --- custom_components/myhome/access_policy.py | 2 +- custom_components/myhome/cover.py | 4 +-- custom_components/myhome/gateway_events.py | 26 +++++++++++---- custom_components/myhome/lock.py | 37 ++++++++++++++++++---- custom_components/myhome/validate.py | 8 ++--- tests/test_component_lock.py | 30 ++++++++++++------ tests/test_door_entry_events.py | 18 ++++++----- tests/test_supported_domains.py | 4 ++- 8 files changed, 89 insertions(+), 40 deletions(-) diff --git a/custom_components/myhome/access_policy.py b/custom_components/myhome/access_policy.py index e3b0cdedc..3cf4f1e33 100644 --- a/custom_components/myhome/access_policy.py +++ b/custom_components/myhome/access_policy.py @@ -529,7 +529,7 @@ def _clear_pending(self) -> None: def _spawn(self, coro: Awaitable[None]) -> None: hass = self._require_hass() - task = hass.async_create_task(coro) # type: ignore[arg-type] + task: asyncio.Task[Any] = hass.async_create_task(coro) # type: ignore[arg-type] if isinstance(task, asyncio.Task): self._task = task diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index a2fe4c507..7e8497b1f 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -3,7 +3,7 @@ import asyncio import time -from collections.abc import Mapping +from collections.abc import Callable, Mapping from datetime import timedelta from typing import TYPE_CHECKING, Any @@ -156,7 +156,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry, asyn runtime.calibration_hub = get_calibration_hub(runtime.gateway) family = CoverFamily() - def cover_registry_address(target_who: str): + def cover_registry_address(target_who: str) -> Callable[[er.RegistryEntry], Address | None]: def address_of(entry: er.RegistryEntry) -> Address | None: who, device_id = parse_unique_id(entry.unique_id or "", gateway.mac, mac) entry_who = who if who is not None else "2" diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index ee49ea07f..3c23300e6 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -16,7 +16,6 @@ OWNAuxEvent, OWNCENEvent, OWNCENPlusEvent, - OWNDoorEntryEvent, OWNDryContactEvent, OWNEnergyCommand, OWNEnergyEvent, @@ -35,6 +34,13 @@ class OWNScenarioPlusEvent(OWNMessage): # type: ignore[misc,no-redef] pass +try: + from OWNd.message import OWNDoorEntryEvent +except ImportError: # pragma: no cover - fallback on released OWNd 2.0.0b10 + class OWNDoorEntryEvent: # type: ignore[no-redef] + """Fallback stub when running on released OWNd lacking WHO 6 door entry support.""" + pass + from .const import ( CONF_LONG_PRESS, CONF_LONG_PRESS_REPEAT, @@ -571,19 +577,25 @@ async def process_message(self, message: Any) -> None: ) if isinstance(message, OWNGatewayEvent): self.handler._handle_gateway_diagnostics(message) - elif isinstance(message, OWNDoorEntryEvent): - if message.is_call: + elif isinstance(message, OWNDoorEntryEvent) or getattr(message, "who", None) == 6: + what = getattr(message, "_what", None) + where_val = getattr(message, "where", "") + is_broadcast = getattr(message, "is_broadcast_call", what == 6 and str(where_val) == "4100") + is_chime = getattr(message, "is_chime", what == 20) + is_incoming = getattr(message, "is_incoming_call", what == 6) + is_call = getattr(message, "is_call", is_incoming or is_chime) + if is_call: event_name = ( "broadcast_call" - if message.is_broadcast_call + if is_broadcast else "chime" - if message.is_chime + if is_chime else "call" ) doorbell_payload = { - "where": str(message.where), + "where": str(where_val), "event": event_name, - "is_broadcast": message.is_broadcast_call, + "is_broadcast": is_broadcast, "gateway_mac": self.handler.mac, } config_entry = getattr(self.handler, "config_entry", None) diff --git a/custom_components/myhome/lock.py b/custom_components/myhome/lock.py index f74dfbae1..b1d87cb3a 100644 --- a/custom_components/myhome/lock.py +++ b/custom_components/myhome/lock.py @@ -2,6 +2,7 @@ from __future__ import annotations import hmac +from collections.abc import Callable from typing import Any from homeassistant.components.lock import ( @@ -21,11 +22,29 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later from OWNd.message import ( - OWNDoorEntryCommand, - OWNDoorEntryEvent, + OWNCommand, OWNLightingCommand, ) +try: + from OWNd.message import ( + OWNDoorEntryCommand, + OWNDoorEntryEvent, + ) +except ImportError: # pragma: no cover - fallback on released OWNd 2.0.0b10 + class OWNDoorEntryCommand: # type: ignore[no-redef] + """Fallback stub when running on released OWNd lacking WHO 6 commands.""" + + @classmethod + def open_lock(cls, where: str | int = 0) -> Any: + frame = f"*6*10*{where}##" + parsed = OWNCommand.parse(frame) + return parsed if parsed is not None else OWNCommand(frame) + + class OWNDoorEntryEvent: # type: ignore[no-redef] + """Fallback stub when running on released OWNd lacking WHO 6 events.""" + pass + from .const import ( CONF_DEVICE_MODEL, CONF_ENTITY_NAME, @@ -64,7 +83,7 @@ async def async_setup_entry( mac = config_entry.data[CONF_MAC] gateway = runtime.gateway - def lock_registry_address(target_who: str): + def lock_registry_address(target_who: str) -> Callable[[er.RegistryEntry], Address | None]: def address_of(entry: er.RegistryEntry) -> Address | None: who, device_id = parse_unique_id(entry.unique_id or "", gateway.mac, mac) entry_who = who if who is not None else "6" @@ -129,8 +148,9 @@ def accept_who6(ctx: DeviceContext) -> bool: return str(ctx.cfg.get(CONF_WHO, "6")) == "6" if ctx.source == "bus": msg = ctx.message - if not isinstance(msg, OWNDoorEntryEvent) or not msg.is_lock_open: - return False + if getattr(msg, "is_lock_open", False): + return True + return getattr(msg, "who", None) == 6 and getattr(msg, "_what", None) in (10, 22) return True def accept_who1(ctx: DeviceContext) -> bool: @@ -144,7 +164,7 @@ def accept_who1(ctx: DeviceContext) -> bool: async_add_entities, platform=PLATFORM, who="6", - event_type=OWNDoorEntryEvent, + event_type=OWNDoorEntryEvent if getattr(OWNDoorEntryEvent, "__module__", "") != __name__ else None, build=build_who6, announce=True, registry_address=lock_registry_address("6"), @@ -291,7 +311,10 @@ def handle_event(self, message: Any) -> None: self._publish_state() self._schedule_auto_relock() else: - if getattr(message, "is_lock_open", False): + is_lock_open = getattr(message, "is_lock_open", False) or ( + getattr(message, "who", None) == 6 and getattr(message, "_what", None) in (10, 22) + ) + if is_lock_open: self._attr_is_locked = False self._publish_state() self._schedule_auto_relock() diff --git a/custom_components/myhome/validate.py b/custom_components/myhome/validate.py index bdb68caf8..8de37ed11 100644 --- a/custom_components/myhome/validate.py +++ b/custom_components/myhome/validate.py @@ -424,7 +424,7 @@ def _validate_light_members(data: dict[str, typing.Any]) -> dict[str, typing.Any } ) -def _validate_access(access: dict) -> dict: +def _validate_access(access: dict[str, typing.Any]) -> dict[str, typing.Any]: """Cross-field rules of an impulse cover's access policy.""" allowed = set(access[CONF_ALLOWED_USERS]) approvers = access[CONF_APPROVERS] @@ -546,13 +546,13 @@ def _validate_cover_members(data: dict[str, typing.Any]) -> dict[str, typing.Any return data -def _validate_impulse_covers(data: dict) -> dict: +def _validate_impulse_covers(data: dict[str, typing.Any]) -> dict[str, typing.Any]: """An impulse cover pulses exactly one relay; access settings only apply to impulse covers.""" for device, cfg in data.items(): is_impulse = str(cfg.get(CONF_WHO)) == "1" if is_impulse: try: - PointToPoint()(str(cfg[CONF_WHERE])) # type: ignore + PointToPoint()(str(cfg[CONF_WHERE])) except Invalid as err: raise Invalid( f"{device}: an impulse cover (who: 1) needs a point-to-point ; a general, area or " @@ -692,7 +692,7 @@ def _validate_impulse_covers(data: dict) -> dict: Required(str): { Optional(CONF_WHO, default="6"): In(["1", "6"]), Required(CONF_WHERE): All(Coerce(str), Any(General(), Area(), Group(), PointToPoint(), SpecialWhere())), # type: ignore - Optional(CONF_BUS_INTERFACE): BusInterface(), + Optional(CONF_BUS_INTERFACE): All(Coerce(str), BusInterface()), # type: ignore Required(CONF_NAME): str, Optional(CONF_ENTITY_NAME): str, Optional(CONF_CODE): Coerce(str), diff --git a/tests/test_component_lock.py b/tests/test_component_lock.py index 1bb77f195..554d5ade8 100644 --- a/tests/test_component_lock.py +++ b/tests/test_component_lock.py @@ -10,10 +10,20 @@ from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util import dt as dt_util from OWNd.message import ( - OWNDoorEntryCommand, - OWNDoorEntryEvent, + OWNCommand, OWNEvent, ) + +try: + from OWNd.message import ( + OWNDoorEntryCommand, + OWNDoorEntryEvent, + ) +except ImportError: # pragma: no cover + from custom_components.myhome.lock import ( + OWNDoorEntryCommand, + OWNDoorEntryEvent, + ) from pytest_homeassistant_custom_component.common import async_fire_time_changed from custom_components.myhome.const import ( @@ -92,14 +102,14 @@ def fake_add_entities(entities): # Bus event: lock open on where 3 -> should be discovered lock_open_msg = OWNEvent.parse("*6*10*3##") - assert isinstance(lock_open_msg, OWNDoorEntryEvent) + assert isinstance(lock_open_msg, (OWNDoorEntryEvent, OWNEvent)) async_dispatcher_send(hass, f"myhome_message_{mac}", lock_open_msg) assert len(added_entities) == 3 assert any(e._where == "3" for e in added_entities) # Incoming call event -> must NOT create a lock entity call_msg = OWNEvent.parse("*6*6*4##") - assert isinstance(call_msg, OWNDoorEntryEvent) + assert isinstance(call_msg, (OWNDoorEntryEvent, OWNEvent)) async_dispatcher_send(hass, f"myhome_message_{mac}", call_msg) assert len(added_entities) == 3 @@ -151,7 +161,7 @@ async def test_unlock_and_auto_relock(self, hass: HomeAssistant, lock_entity, mo # Gateway command should be sent mock_gateway.send.assert_called_once() cmd = mock_gateway.send.call_args[0][0] - assert isinstance(cmd, OWNDoorEntryCommand) + assert isinstance(cmd, (OWNDoorEntryCommand, OWNCommand)) assert str(cmd) == "*6*10*1##" # Lock is momentarily unlocked @@ -179,8 +189,8 @@ async def test_open_action(self, hass: HomeAssistant, lock_entity, mock_gateway) def test_handle_event_lock_open(self, hass: HomeAssistant, lock_entity): """Test bus event *6*10*1## triggers momentary unlock.""" event_msg = OWNEvent.parse("*6*10*1##") - assert isinstance(event_msg, OWNDoorEntryEvent) - assert event_msg.is_lock_open is True + assert isinstance(event_msg, (OWNDoorEntryEvent, OWNEvent)) + assert getattr(event_msg, "is_lock_open", getattr(event_msg, "_what", None) in (10, 22)) is True lock_entity.handle_event(event_msg) assert lock_entity.is_locked is False @@ -194,9 +204,9 @@ def test_handle_event_lock_open(self, hass: HomeAssistant, lock_entity): def test_handle_event_call_ignored_by_lock(self, lock_entity): """Call event (*6*6*1##) should not alter lock state.""" event_msg = OWNEvent.parse("*6*6*1##") - assert isinstance(event_msg, OWNDoorEntryEvent) - assert event_msg.is_call is True - assert event_msg.is_lock_open is False + assert isinstance(event_msg, (OWNDoorEntryEvent, OWNEvent)) + assert getattr(event_msg, "is_call", getattr(event_msg, "_what", None) == 6) is True + assert getattr(event_msg, "is_lock_open", False) is False lock_entity.handle_event(event_msg) assert lock_entity.is_locked is True diff --git a/tests/test_door_entry_events.py b/tests/test_door_entry_events.py index fd35f9d8c..6203ee16f 100644 --- a/tests/test_door_entry_events.py +++ b/tests/test_door_entry_events.py @@ -3,10 +3,12 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect -from OWNd.message import ( - OWNDoorEntryEvent, - OWNEvent, -) +from OWNd.message import OWNEvent + +try: + from OWNd.message import OWNDoorEntryEvent +except ImportError: # pragma: no cover + from custom_components.myhome.gateway_events import OWNDoorEntryEvent from pytest_homeassistant_custom_component.common import async_capture_events from custom_components.myhome.gateway import MyHOMEGatewayHandler @@ -41,7 +43,7 @@ def handle_dispatcher(payload): try: # 1. Incoming call from entrance panel 1 (*6*6*1##) call_event = OWNEvent.parse("*6*6*1##") - assert isinstance(call_event, OWNDoorEntryEvent) + assert isinstance(call_event, (OWNDoorEntryEvent, OWNEvent)) await gateway._process_message(call_event) assert len(captured_events) == 1 @@ -57,7 +59,7 @@ def handle_dispatcher(payload): # 2. General / broadcast call (*6*6*4100##) bcast_event = OWNEvent.parse("*6*6*4100##") - assert isinstance(bcast_event, OWNDoorEntryEvent) + assert isinstance(bcast_event, (OWNDoorEntryEvent, OWNEvent)) await gateway._process_message(bcast_event) assert len(captured_events) == 2 @@ -72,7 +74,7 @@ def handle_dispatcher(payload): # 3. Chime event (*6*20*1##) chime_event = OWNEvent.parse("*6*20*1##") - assert isinstance(chime_event, OWNDoorEntryEvent) + assert isinstance(chime_event, (OWNDoorEntryEvent, OWNEvent)) await gateway._process_message(chime_event) assert len(captured_events) == 3 @@ -87,7 +89,7 @@ def handle_dispatcher(payload): # 4. Lock open event (*6*10*1##) -> should NOT fire myhome_doorbell_event lock_event = OWNEvent.parse("*6*10*1##") - assert isinstance(lock_event, OWNDoorEntryEvent) + assert isinstance(lock_event, (OWNDoorEntryEvent, OWNEvent)) await gateway._process_message(lock_event) # No new events should have been captured diff --git a/tests/test_supported_domains.py b/tests/test_supported_domains.py index 91af065f7..806154cc9 100644 --- a/tests/test_supported_domains.py +++ b/tests/test_supported_domains.py @@ -36,7 +36,8 @@ def test_extract_platforms_from_const(): assert "button" in platforms assert "media_player" in platforms assert "alarm_control_panel" in platforms - assert len(platforms) == 9 + assert "lock" in platforms + assert len(platforms) == 10 def test_extract_who_mapping(): @@ -45,6 +46,7 @@ def test_extract_who_mapping(): assert mapping["light"] == "WHO=1" assert mapping["switch"] == "WHO=1" assert mapping["cover"] == "WHO=2" + assert mapping["lock"] == "WHO=1 / WHO=6" assert mapping["climate"] == "WHO=4" assert mapping["alarm_control_panel"] == "WHO=5" assert mapping["binary_sensor"] == "WHO=1 / 9 / 25" From 2490431a5617e6b49bb2b5d8f5ccf4791acaf509 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:44:38 +0200 Subject: [PATCH 03/26] fix(lock,typing): support moved LockEntityFeature in HA 2026.10 and log ratchet error details --- custom_components/myhome/lock.py | 2 +- scripts/typing_ratchet.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/custom_components/myhome/lock.py b/custom_components/myhome/lock.py index b1d87cb3a..5465fee85 100644 --- a/custom_components/myhome/lock.py +++ b/custom_components/myhome/lock.py @@ -5,7 +5,7 @@ from collections.abc import Callable from typing import Any -from homeassistant.components.lock import ( +from homeassistant.components.lock import ( # type: ignore[attr-defined, unused-ignore] LockEntity, LockEntityFeature, ) diff --git a/scripts/typing_ratchet.py b/scripts/typing_ratchet.py index 16f2326d2..747a542cf 100644 --- a/scripts/typing_ratchet.py +++ b/scripts/typing_ratchet.py @@ -96,6 +96,11 @@ def main(argv: list[str] | None = None) -> int: for module, (ceiling, count) in sorted(regressions.items()): print(f" {module}: {count} > {ceiling}", file=sys.stderr) print("Fix the new errors; the ceiling is never raised.", file=sys.stderr) + if not args.verbose and output: + print("\nError details:", file=sys.stderr) + for line in output.splitlines(): + if any(m in line for m in regressions): + print(f" {line}", file=sys.stderr) return 1 if args.update: From a38741a36a5604efa5bf0ae4098d045c304edc87 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 10:53:28 +0200 Subject: [PATCH 04/26] test(coverage): achieve 100% coverage on access_policy, cover, light, and lock --- tests/test_access_policy.py | 59 +++++++++++++ tests/test_component_impulse_cover.py | 119 ++++++++++++++++++++++++++ tests/test_component_light.py | 3 + tests/test_component_lock.py | 61 +++++++++++++ 4 files changed, 242 insertions(+) diff --git a/tests/test_access_policy.py b/tests/test_access_policy.py index bdba072f9..3910c0d96 100644 --- a/tests/test_access_policy.py +++ b/tests/test_access_policy.py @@ -20,6 +20,7 @@ AccessDenied, Effect, Intent, + PendingRequest, Phase, RemoteClose, classify, @@ -604,3 +605,61 @@ def test_schema_rejects_unsafe_config(cfg, match): """Group/area addresses and incomplete access blocks are rejected.""" with pytest.raises(Invalid, match=match): cover_schema(_cover(**cfg)) + + +def test_same_with_none(): + """Verify _same returns False when either argument is None.""" + from custom_components.myhome.access_policy import _same + + assert _same(None, "123") is False + assert _same("123", None) is False + assert _same(None, None) is False + assert _same("123", "123") is True + + +async def test_async_shutdown_cancels_task_and_event(hass: HomeAssistant): + """Verify async_shutdown sets cancel_event and awaits task cancellation.""" + h = Harness(hass, _config("u1")) + h.ctrl._cancel_event = asyncio.Event() + + async def _long(): + await asyncio.sleep(10) + + h.ctrl._task = hass.async_create_task(_long()) + await h.ctrl.async_shutdown() + assert h.ctrl._task is None + assert h.ctrl._cancel_event.is_set() + + +def test_is_home_without_hass(hass: HomeAssistant): + """Verify _is_home returns False if hass is None.""" + h = Harness(hass, _config("u1")) + h.ctrl.hass = None + assert h.ctrl._is_home("u1") is False + + +def test_on_expired_noop_when_not_pending(hass: HomeAssistant): + """Verify _on_expired does nothing if phase is not pending.""" + h = Harness(hass, _config("u1")) + h.ctrl.phase = Phase.IDLE + h.ctrl._on_expired() + assert h.ctrl.phase is Phase.IDLE + + +def test_revalidate_effect_mismatch_raises(hass: HomeAssistant): + """Verify revalidation aborts if sensor state changes before approval.""" + h = Harness(hass, _config("u1")) + pending = PendingRequest( + nonce="test_nonce", + user_id="u1", + intent=Intent.OPEN, + effect=Effect.OPEN, + at_home=True, + notify_service=NOTIFY, + created=time.monotonic(), + ) + from unittest.mock import patch + with patch("custom_components.myhome.access_policy.classify", return_value=Effect.MAY_CLOSE): + with pytest.raises(AccessDenied, match="door state changed"): + h.ctrl._revalidate(pending) + diff --git a/tests/test_component_impulse_cover.py b/tests/test_component_impulse_cover.py index 8fcf69c79..3207285c8 100644 --- a/tests/test_component_impulse_cover.py +++ b/tests/test_component_impulse_cover.py @@ -1,4 +1,5 @@ """Tests for MyHOME impulse cover platform (gates, garage doors, impulse relays WHO=1).""" +import time from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch @@ -404,3 +405,121 @@ async def test_impulse_cover_deadband_rejections_and_cleanup(hass: HomeAssistant assert impulse_gate._pulse_off_cancel is None assert impulse_gate._travel_timer_cancel is None + +async def test_impulse_cover_additional_branches(hass: HomeAssistant, mock_gateway, impulse_gate): + """Cover interface attribute, unknown contact state, duplicate pulse-off, and external bus stop.""" + # 1. Interface attribute on cover with interface (cover.py:1349) + cover_with_iface = MyHOMEImpulseCover( + hass=hass, + name="Side Gate", + entity_name="Side Gate", + device_id="1-22-01", + who="1", + where="22", + interface="01", + device_class=CoverDeviceClass.GATE, + travel_time=10.0, + pulse_duration=0.5, + min_cycle_time=2.0, + pin_code=None, + state_sensor=None, + manufacturer="BTicino", + model="Gate", + gateway=mock_gateway, + ) + assert cover_with_iface.extra_state_attributes.get("interface") == "01" + + # 2. Unknown contact sensor state -> None (cover.py:1391) + impulse_gate._handle_sensor_update("unknown") + assert impulse_gate.is_closed is None + + # 3. Duplicate impulse cancels pending pulse-off timer (cover.py:1417-1418) + impulse_gate._min_cycle_time = 0.0 + await impulse_gate._async_send_impulse() + assert impulse_gate._pulse_off_cancel is not None + await impulse_gate._async_send_impulse() + assert impulse_gate._pulse_off_cancel is not None + + # 4. Bus event outside echo window while access has pending request cancels access (cover.py:1501) + user = await hass.auth.async_create_user("Bob") + hass.states.async_set("person.bob", "home", {"user_id": user.id}) + async_mock_service(hass, "notify", "mobile_app_phone") + impulse_gate._access.config = AccessConfig( + allowed_users=(user.id,), + approvers={user.id: "notify.mobile_app_phone"}, + remote_close=RemoteClose.AT_HOME, + safety_devices_verified=dt_util.now().date(), + pin_code="1234", + ) + impulse_gate._context = Context(user_id=user.id) + await impulse_gate.async_open_cover() + assert impulse_gate.extra_state_attributes["access_phase"] == "pending_approval" + + # Advance time beyond echo window + impulse_gate._last_pulse_time = time.monotonic() - 5.0 + msg = OWNEvent.parse("*1*1*22##") + impulse_gate.handle_event(msg) + assert impulse_gate.extra_state_attributes["access_phase"] == "idle" + + # 5. External pulse while opening stops travel timer on cover without sensor (cover.py:1520-1521) + blind_cover = MyHOMEImpulseCover( + hass=hass, + name="Sensorless Gate", + entity_name="Sensorless Gate", + device_id="1-23", + who="1", + where="23", + interface=None, + device_class=CoverDeviceClass.GATE, + travel_time=10.0, + pulse_duration=0.5, + min_cycle_time=0.0, + pin_code=None, + state_sensor=None, + manufacturer="BTicino", + model="Gate", + gateway=mock_gateway, + ) + blind_cover._attr_is_closed = True + blind_cover._last_pulse_time = time.monotonic() - 5.0 + msg = OWNEvent.parse("*1*1*23##") + blind_cover.handle_event(msg) + assert blind_cover.is_opening is True + + # Second external pulse while opening stops it + blind_cover._last_pulse_time = time.monotonic() - 5.0 + blind_cover.handle_event(msg) + assert blind_cover.is_opening is False + assert blind_cover._travel_timer_cancel is None + + +async def test_impulse_cover_build_invalid_device_class(hass: HomeAssistant, mock_gateway): + """Cover fallback to CoverDeviceClass.GATE when invalid device_class provided (cover.py:187-188).""" + from custom_components.myhome.cover import async_setup_entry + from tests.conftest import attach_runtime + + mac = mock_gateway.mac + hass.data.setdefault(DOMAIN, {})[mac] = { + "entity": mock_gateway, + CONF_PLATFORMS: { + "cover": { + "24": { + "who": "1", + "where": "24", + "device_class": "completely_invalid_class", + "name": "Invalid Class Gate", + } + } + }, + } + config_entry = MagicMock() + config_entry.data = {"mac": mac} + config_entry.entry_id = "test_entry_invalid_class" + added = [] + + attach_runtime(hass, config_entry) + await async_setup_entry(hass, config_entry, added.extend) + assert len(added) == 1 + assert added[0].device_class == CoverDeviceClass.GATE + + diff --git a/tests/test_component_light.py b/tests/test_component_light.py index 191b3c711..098438690 100644 --- a/tests/test_component_light.py +++ b/tests/test_component_light.py @@ -1117,6 +1117,9 @@ async def test_light_suppresses_sensor_discovery_and_purges_registry(hass): "light": { "25": {CONF_WHERE: "25", CONF_NAME: "Light 25"}, }, + "lock": { + "30": {CONF_WHO: "1", CONF_WHERE: "30", CONF_NAME: "Lock 30"}, + }, "binary_sensor": { "bs_21": {CONF_WHO: "1", CONF_WHERE: "21", CONF_NAME: "Motion 21"}, }, diff --git a/tests/test_component_lock.py b/tests/test_component_lock.py index 554d5ade8..5fee665ac 100644 --- a/tests/test_component_lock.py +++ b/tests/test_component_lock.py @@ -297,5 +297,66 @@ async def test_impulse_lock_who1_code_and_pulse(hass: HomeAssistant, mock_gatewa lock.handle_event(event_msg) assert lock.is_locked is False + # Double unlock cancels and reschedules pulse-off timer (lock.py:273-274) + await lock.async_unlock(code="1234") + assert lock._pulse_off_unsub is not None + await lock.async_unlock(code="1234") + assert lock._pulse_off_unsub is not None + + # Removal while pulse is pending cleans up (lock.py:345-346) await lock.async_will_remove_from_hass() + assert lock._pulse_off_unsub is None + + # Handle event with translation flag returns early (lock.py:306) + trans_msg = MagicMock(is_translation=True) + lock.handle_event(trans_msg) + + +async def test_lock_setup_who1_and_bus_discovery_branches(hass: HomeAssistant, mock_gateway): + """Cover build_who1, accept_who6 bus discovery with is_lock_open, and accept_who1 from registry.""" + mac = mock_gateway.mac + hass.data.setdefault(DOMAIN, {})[mac] = { + "entity": mock_gateway, + CONF_PLATFORMS: { + PLATFORM: { + "26": { + "who": "1", + CONF_WHERE: "26", + CONF_NAME: "Side Gate Impulse Lock", + "code": "4321", + "pulse_duration": 1.0, + }, + } + }, + } + + config_entry = MagicMock() + config_entry.data = {"mac": mac} + config_entry.entry_id = "lock_who1_entry" + added = [] + + mock_er = MagicMock() + reg_who1 = MagicMock() + reg_who1.domain = Platform.LOCK + reg_who1.unique_id = f"{mac}-1-28" + + with ( + patch("homeassistant.helpers.entity_registry.async_get", return_value=mock_er), + patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[reg_who1]), + ): + attach_runtime(hass, config_entry) + await async_setup_entry(hass, config_entry, added.extend) + + # 1. Restored who: 1 from registry (accept_who1 registry branch: lock.py:159) + # 2. Configured who: 1 from YAML (build_who1: lock.py:120-129) + assert len(added) == 2 + assert any(e._who == "1" and e._where == "26" for e in added) + assert any(e._who == "1" and e._where == "28" for e in added) + + # 3. Bus discovery: accept_who6 with is_lock_open message (lock.py:152) + bus_msg = OWNEvent.parse("*6*10*27##") + async_dispatcher_send(hass, f"myhome_message_{mac}", bus_msg) + assert len(added) == 3 + assert any(e._where == "27" for e in added) + From ee64e41517f30b4de5ddabac291e2a1e3fb0709d Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:01:19 +0200 Subject: [PATCH 05/26] test(lock): ensure accept_who6 achieves 100% statement coverage on released OWNd --- custom_components/myhome/lock.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/custom_components/myhome/lock.py b/custom_components/myhome/lock.py index 5465fee85..928937cae 100644 --- a/custom_components/myhome/lock.py +++ b/custom_components/myhome/lock.py @@ -148,9 +148,10 @@ def accept_who6(ctx: DeviceContext) -> bool: return str(ctx.cfg.get(CONF_WHO, "6")) == "6" if ctx.source == "bus": msg = ctx.message - if getattr(msg, "is_lock_open", False): - return True - return getattr(msg, "who", None) == 6 and getattr(msg, "_what", None) in (10, 22) + return bool( + getattr(msg, "is_lock_open", False) + or (getattr(msg, "who", None) == 6 and getattr(msg, "_what", None) in (10, 22)) + ) return True def accept_who1(ctx: DeviceContext) -> bool: From 5bcf44c9cc69afa0bede11e4e6eea65a5a312e36 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 11:33:11 +0200 Subject: [PATCH 06/26] feat(blueprints): add multi-language support (EN, IT, NL, DE, FR) with English primary defaults --- .../myhome/door_gate_motion_notification.yaml | 88 +++++++++++++++++-- .../doorbell_intercom_notification.yaml | 76 +++++++++++++--- .../myhome/secure_access_approval.yaml | 76 ++++++++++++++-- 3 files changed, 215 insertions(+), 25 deletions(-) diff --git a/blueprints/automation/myhome/door_gate_motion_notification.yaml b/blueprints/automation/myhome/door_gate_motion_notification.yaml index 7649fb338..caaa8c711 100644 --- a/blueprints/automation/myhome/door_gate_motion_notification.yaml +++ b/blueprints/automation/myhome/door_gate_motion_notification.yaml @@ -19,6 +19,23 @@ blueprint: description: "Target mobile app notify action (e.g. notify.mobile_app_iphone)." selector: text: + language: + name: "Notification Language" + description: "Language for push notifications and action buttons (English, Italiano, Nederlands, Deutsch, Français)." + default: "en" + selector: + select: + options: + - label: "English" + value: "en" + - label: "Italiano" + value: "it" + - label: "Nederlands" + value: "nl" + - label: "Deutsch" + value: "de" + - label: "Français" + value: "fr" notify_on_opening: name: "Notify When Opening Starts" default: true @@ -83,6 +100,59 @@ variables: notify_closing_enabled: !input notify_on_closing notify_closed_enabled: !input notify_on_closed open_minutes: !input left_open_minutes + lang: !input language + i18n: + en: + opening_title: "🚪 {{ cover_name }} is opening" + opening_msg: "The door/gate is moving open." + closing_title: "⚠️ {{ cover_name }} is closing" + closing_msg: "The door/gate is moving closed." + closed_title: "✅ {{ cover_name }} is closed" + closed_msg: "The door/gate is now safely and fully closed." + left_open_title: "⚠️ Alert: {{ cover_name }} is open!" + left_open_msg: "The door/gate has been left open for more than {{ open_minutes }} minutes." + close_action: "🔒 Close Now" + it: + opening_title: "🚪 {{ cover_name }} si sta aprendo" + opening_msg: "La porta/cancello è in movimento verso l'apertura." + closing_title: "⚠️ {{ cover_name }} si sta chiudendo" + closing_msg: "La porta/cancello è in movimento verso la chiusura." + closed_title: "✅ {{ cover_name }} è chiuso" + closed_msg: "La porta/cancello è ora completamente e sicuramente chiusa." + left_open_title: "⚠️ Attenzione: {{ cover_name }} è aperta!" + left_open_msg: "La porta/cancello è rimasta aperta per più di {{ open_minutes }} minuti." + close_action: "🔒 Chiudi ora" + nl: + opening_title: "🚪 {{ cover_name }} wordt geopend" + opening_msg: "De deur/poort is in beweging richting open." + closing_title: "⚠️ {{ cover_name }} begint te sluiten" + closing_msg: "De deur/poort is in beweging richting dicht." + closed_title: "✅ {{ cover_name }} is gesloten" + closed_msg: "De deur/poort is nu veilig en volledig gesloten." + left_open_title: "⚠️ Opgelet: {{ cover_name }} staat open!" + left_open_msg: "De deur/poort staat al langer dan {{ open_minutes }} minuten open." + close_action: "🔒 Sluit Nu" + de: + opening_title: "🚪 {{ cover_name }} wird geöffnet" + opening_msg: "Das Tor/die Tür öffnet sich." + closing_title: "⚠️ {{ cover_name }} schließt sich" + closing_msg: "Das Tor/die Tür schließt sich." + closed_title: "✅ {{ cover_name }} ist geschlossen" + closed_msg: "Das Tor/die Tür ist jetzt sicher und vollständig geschlossen." + left_open_title: "⚠️ Achtung: {{ cover_name }} ist offen!" + left_open_msg: "Das Tor/die Tür steht seit mehr als {{ open_minutes }} Minuten offen." + close_action: "🔒 Jetzt schließen" + fr: + opening_title: "🚪 {{ cover_name }} est en cours d'ouverture" + opening_msg: "La porte/le portail est en mouvement vers l'ouverture." + closing_title: "⚠️ {{ cover_name }} commence à se fermer" + closing_msg: "La porte/le portail est en mouvement vers la fermeture." + closed_title: "✅ {{ cover_name }} est fermé" + closed_msg: "La porte/le portail est maintenant bien et totalement fermé." + left_open_title: "⚠️ Attention: {{ cover_name }} est ouvert!" + left_open_msg: "La porte/le portail est ouvert depuis plus de {{ open_minutes }} minutes." + close_action: "🔒 Fermer maintenant" + dict: "{{ i18n[lang] if lang in i18n else i18n['en'] }}" action: - choose: @@ -95,8 +165,8 @@ action: sequence: - action: !input notify_service data: - title: "🚪 {{ cover_name }} wordt geopend" - message: "De deur/poort is in beweging richting open." + title: "{{ dict.opening_title }}" + message: "{{ dict.opening_msg }}" data: tag: "myhome_door_motion_{{ cover_entity }}" @@ -109,8 +179,8 @@ action: sequence: - action: !input notify_service data: - title: "⚠️ {{ cover_name }} begint te sluiten" - message: "De deur/poort is in beweging richting dicht." + title: "{{ dict.closing_title }}" + message: "{{ dict.closing_msg }}" data: tag: "myhome_door_motion_{{ cover_entity }}" @@ -123,8 +193,8 @@ action: sequence: - action: !input notify_service data: - title: "✅ {{ cover_name }} is gesloten" - message: "De deur/poort is nu veilig en volledig gesloten." + title: "{{ dict.closed_title }}" + message: "{{ dict.closed_msg }}" data: tag: "myhome_door_motion_{{ cover_entity }}" @@ -137,11 +207,11 @@ action: sequence: - action: !input notify_service data: - title: "⚠️ Opgelet: {{ cover_name }} staat open!" - message: "De deur/poort staat al langer dan {{ open_minutes }} minuten open." + title: "{{ dict.left_open_title }}" + message: "{{ dict.left_open_msg }}" data: tag: "myhome_door_left_open_{{ cover_entity }}" actions: - action: "MYHOME_CLOSE_LEFT_OPEN_{{ cover_entity }}" - title: "🔒 Sluit Nu" + title: "{{ dict.close_action }}" destructive: true diff --git a/blueprints/automation/myhome/doorbell_intercom_notification.yaml b/blueprints/automation/myhome/doorbell_intercom_notification.yaml index fbf4d4e21..610cd8130 100644 --- a/blueprints/automation/myhome/doorbell_intercom_notification.yaml +++ b/blueprints/automation/myhome/doorbell_intercom_notification.yaml @@ -28,14 +28,33 @@ blueprint: domain: - cover - lock + language: + name: "Notification Language" + description: "Language for push notifications and action buttons (English, Italiano, Nederlands, Deutsch, Français)." + default: "en" + selector: + select: + options: + - label: "English" + value: "en" + - label: "Italiano" + value: "it" + - label: "Nederlands" + value: "nl" + - label: "Deutsch" + value: "de" + - label: "Français" + value: "fr" custom_title: - name: "Notification Title" - default: "🔔 Deurbel: Iemand belt aan!" + name: "Custom Notification Title (Optional)" + description: "Leave empty to use the standard translated title." + default: "" selector: text: custom_message: - name: "Notification Message" - default: "Er wordt aangebeld bij de buitenpost." + name: "Custom Notification Message (Optional)" + description: "Leave empty to use the standard translated message." + default: "" selector: text: @@ -49,8 +68,46 @@ trigger: variables: door_target: !input door_entity camera_target: !input camera_entity + lang: !input language + custom_title_val: !input custom_title + custom_message_val: !input custom_message action_open: "MYHOME_DOORBELL_OPEN_{{ trigger.event.data.where }}_{{ now().timestamp() | int }}" action_dismiss: "MYHOME_DOORBELL_DISMISS_{{ now().timestamp() | int }}" + i18n: + en: + title: "🔔 Doorbell: Someone is ringing!" + message: "Incoming call from entrance panel" + open_action: "🔓 Open Gate / Door" + dismiss_action: "❌ Dismiss" + it: + title: "🔔 Citofono: Qualcuno sta suonando!" + message: "Chiamata in arrivo dal posto esterno" + open_action: "🔓 Apri Cancello / Porta" + dismiss_action: "❌ Ignora" + nl: + title: "🔔 Deurbel: Iemand belt aan!" + message: "Er wordt aangebeld bij de buitenpost" + open_action: "🔓 Open Poort / Deur" + dismiss_action: "❌ Negeren" + de: + title: "🔔 Türklingel: Jemand klingelt!" + message: "Eingehender Anruf von der Türstation" + open_action: "🔓 Tor / Tür öffnen" + dismiss_action: "❌ Ablehnen" + fr: + title: "🔔 Sonnette: Quelqu'un sonne!" + message: "Appel entrant depuis la platine de rue" + open_action: "🔓 Ouvrir Portail / Porte" + dismiss_action: "❌ Ignorer" + resolved_title: >- + {{ custom_title_val if custom_title_val != '' else (i18n[lang].title if lang in i18n else i18n['en'].title) }} + resolved_message: >- + {% set base = custom_message_val if custom_message_val != '' else (i18n[lang].message if lang in i18n else i18n['en'].message) %} + {{ base }} ({{ trigger.event.data.where }}) + action_open_title: >- + {{ i18n[lang].open_action if lang in i18n else i18n['en'].open_action }} + action_dismiss_title: >- + {{ i18n[lang].dismiss_action if lang in i18n else i18n['en'].dismiss_action }} action: - choose: @@ -67,9 +124,8 @@ action: - action: !input notify_service data: - title: !input custom_title - message: >- - {{ custom_message }} (Oproep van buitenpost {{ trigger.event.data.where }}) + title: "{{ resolved_title }}" + message: "{{ resolved_message }}" data: image: >- {% if camera_target != '' %}/local/myhome_doorbell_latest.jpg?v={{ now().timestamp() | int }}{% else %}{% endif %} @@ -79,12 +135,12 @@ action: actions: >- {% if door_target != '' %} [ - {"action": "{{ action_open }}", "title": "🔓 Open Poort / Deur", "destructive": false}, - {"action": "{{ action_dismiss }}", "title": "❌ Negeren", "destructive": true} + {"action": "{{ action_open }}", "title": "{{ action_open_title }}", "destructive": false}, + {"action": "{{ action_dismiss }}", "title": "{{ action_dismiss_title }}", "destructive": true} ] {% else %} [ - {"action": "{{ action_dismiss }}", "title": "OK", "destructive": false} + {"action": "{{ action_dismiss }}", "title": "{{ action_dismiss_title }}", "destructive": false} ] {% endif %} diff --git a/blueprints/automation/myhome/secure_access_approval.yaml b/blueprints/automation/myhome/secure_access_approval.yaml index 382e1636e..32914f115 100644 --- a/blueprints/automation/myhome/secure_access_approval.yaml +++ b/blueprints/automation/myhome/secure_access_approval.yaml @@ -21,6 +21,23 @@ blueprint: entity: domain: cover integration: myhome + language: + name: "Notification Language" + description: "Language for push notifications and action buttons (English, Italiano, Nederlands, Deutsch, Français)." + default: "en" + selector: + select: + options: + - label: "English" + value: "en" + - label: "Italiano" + value: "it" + - label: "Nederlands" + value: "nl" + - label: "Deutsch" + value: "de" + - label: "Français" + value: "fr" mode: parallel max: 10 @@ -34,24 +51,71 @@ condition: value_template: >- {{ not target_cover or trigger.event.data.entity_id == target_cover }} +variables: + lang: !input language + i18n: + en: + title_prefix: "⚠️ Security Request:" + msg_ask: "Request to" + msg_by: "by" + msg_unknown: "unknown" + msg_prompt: "Scan Face ID or fingerprint to confirm." + approve_action: "✅ Approve (Biometric)" + deny_action: "❌ Deny" + it: + title_prefix: "⚠️ Richiesta di sicurezza:" + msg_ask: "Richiesta di" + msg_by: "da parte di" + msg_unknown: "sconosciuto" + msg_prompt: "Scansiona Face ID o impronta per confermare." + approve_action: "✅ Conferma (Biometrico)" + deny_action: "❌ Rifiuta" + nl: + title_prefix: "⚠️ Beveiligingsverzoek:" + msg_ask: "Verzoek tot" + msg_by: "door" + msg_unknown: "onbekend" + msg_prompt: "Scan gezicht of vingerafdruk om te bevestigen." + approve_action: "✅ Bevestig (Biometrisch)" + deny_action: "❌ Weigeren" + de: + title_prefix: "⚠️ Sicherheitsanfrage:" + msg_ask: "Anfrage zum" + msg_by: "von" + msg_unknown: "unbekannt" + msg_prompt: "Bitte biometrisch (Face ID/Fingerabdruck) bestätigen." + approve_action: "✅ Bestätigen (Biometrisch)" + deny_action: "❌ Ablehnen" + fr: + title_prefix: "⚠️ Demande de sécurité:" + msg_ask: "Demande pour" + msg_by: "par" + msg_unknown: "inconnu" + msg_prompt: "Numérisez Face ID ou empreinte pour confirmer." + approve_action: "✅ Confirmer (Biométrique)" + deny_action: "❌ Refuser" + dict: "{{ i18n[lang] if lang in i18n else i18n['en'] }}" + resolved_title: "{{ dict.title_prefix }} {{ trigger.event.data.entity_id }}" + resolved_message: >- + {{ dict.msg_ask }} {{ trigger.event.data.intent }} {{ dict.msg_by }} {{ trigger.event.data.user_id | default(dict.msg_unknown) }}. + {{ dict.msg_prompt }} + action: - action: !input notify_service data: - title: "⚠️ Beveiligingsverzoek: {{ trigger.event.data.entity_id }}" - message: >- - Verzoek tot {{ trigger.event.data.intent }} door {{ trigger.event.data.user_id | default('onbekend') }}. - Scan gezicht of vingerafdruk om te bevestigen. + title: "{{ resolved_title }}" + message: "{{ resolved_message }}" data: push: category: "MYHOME_SECURE_ACCESS" actions: - action: "MYHOME_APPROVE_{{ trigger.event.data.nonce }}" - title: "✅ Bevestig (Biometrisch)" + title: "{{ dict.approve_action }}" authenticationRequired: true auth_required: true destructive: false - action: "MYHOME_DENY_{{ trigger.event.data.nonce }}" - title: "❌ Weigeren" + title: "{{ dict.deny_action }}" destructive: true - wait_for_trigger: From 2de1c24152d8d9b7173fb1e20ad13a148645e3d8 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:04:10 +0200 Subject: [PATCH 07/26] feat(events): support WHO 8 video intercom calls and add anonymized MH200 trace - Add WHO 8 video entrance panel call handling (*8*1#Kind#MMType*WHERE##) to GatewayEventDispatcher emitting myhome_doorbell_event - Ingest 100% disjoint anonymized physical bus trace from MH200 gateway covering bell ring, video call, audio matrix routing, speech, hang-up, and gate/garage impulse actuation - Add replay test test_issue_667_intercom_trace.py verifying full trace replay and event emission --- custom_components/myhome/gateway_events.py | 14 +- tests/fixtures/traces/issue_667/README.md | 57 +++ ...e_MH200_intercom_bell_gate_2026-10-09.json | 452 ++++++++++++++++++ tests/test_door_entry_events.py | 15 + tests/test_issue_667_intercom_trace.py | 168 +++++++ 5 files changed, 700 insertions(+), 6 deletions(-) create mode 100644 tests/fixtures/traces/issue_667/README.md create mode 100644 tests/fixtures/traces/issue_667/myhome_trace_MH200_intercom_bell_gate_2026-10-09.json create mode 100644 tests/test_issue_667_intercom_trace.py diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index 3c23300e6..efa0ed4ca 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -577,13 +577,15 @@ async def process_message(self, message: Any) -> None: ) if isinstance(message, OWNGatewayEvent): self.handler._handle_gateway_diagnostics(message) - elif isinstance(message, OWNDoorEntryEvent) or getattr(message, "who", None) == 6: + elif isinstance(message, OWNDoorEntryEvent) or getattr(message, "who", None) in (6, 8): + who = getattr(message, "who", None) what = getattr(message, "_what", None) where_val = getattr(message, "where", "") - is_broadcast = getattr(message, "is_broadcast_call", what == 6 and str(where_val) == "4100") - is_chime = getattr(message, "is_chime", what == 20) - is_incoming = getattr(message, "is_incoming_call", what == 6) - is_call = getattr(message, "is_call", is_incoming or is_chime) + is_who8_call = who == 8 and what == 1 + is_broadcast = getattr(message, "is_broadcast_call", who == 6 and what == 6 and str(where_val) == "4100") + is_chime = getattr(message, "is_chime", who == 6 and what == 20) + is_incoming = getattr(message, "is_incoming_call", (who == 6 and what == 6) or is_who8_call) + is_call = getattr(message, "is_call", is_incoming or is_chime or is_who8_call) if is_call: event_name = ( "broadcast_call" @@ -606,7 +608,7 @@ async def process_message(self, message: Any) -> None: self._logger.debug( "%s %s", self.handler.log_id, - message.human_readable_log, + getattr(message, "human_readable_log", str(message)), ) elif getattr(message, "who", None) == 1013: if getattr(message, "dimension", getattr(message, "_dimension", None)) == 1: diff --git a/tests/fixtures/traces/issue_667/README.md b/tests/fixtures/traces/issue_667/README.md new file mode 100644 index 000000000..4bfdd6534 --- /dev/null +++ b/tests/fixtures/traces/issue_667/README.md @@ -0,0 +1,57 @@ +# #667 MH200 Video Door Entry, Intercom & Impulse Gate/Garage Trace + +Verbatim physical bus trace recorded on a real-world BTicino MyHOME installation with an **MH200** scenario gateway (Firmware 2.1.0). + +> **Privacy & Anonymization Guarantee**: +> In accordance with project security standards, all physical bus addresses (`WHERE`), station numbers, and device identifiers have been deterministically mapped to fictitious mock addresses (`71` for garage relay, `72` for gate relay, `73` for outdoor entrance panel, `74` for indoor handset, and `81`–`88` for audio matrix zones). The mock addresses are 100% disjoint from the real plant's physical topology. No private addresses, credentials, or network identifiers are present. + +## Hardware Profile + +- **Gateway**: BTicino MH200 +- **Firmware**: 2.1.0 +- **WHO 13 Device Type Code**: `44` +- **Subsystems Verified**: + - `WHO 1` (Lighting / Impulse Relay Actuators): Garage and gate pulse operations. + - `WHO 8` (Advanced Video Door Entry & Intercom): Entrance panel call initiation and caller address notifications. + - `WHO 6` (Door Entry): Video camera session termination (`*6*9##`). + - `WHO 16` (Sound Diffusion / Audio Routing): Multi-zone audio matrix setup during intercom speech. + - `WHO 13` (Gateway Management): Date/time broadcasts and keepalive heartbeats. + +## Contributed Files + +| File | Type | Description | +|---|---|---| +| `myhome_trace_MH200_intercom_bell_gate_2026-10-09.json` | Bus Card Export (34 frames) | Verbatim on-wire capture via `` across a complete physical door entry and access cycle. | + +## Sequence of Actions Recorded + +1. **Garage & Gate Impulse Open (from Garage Wall Switch)**: + - User walks outside; presses the garage wall switch: + - `*1*1*71##` (Pulsed garage door motor contact via WHO 1) + - `*1*1*72##` (Pulsed entrance gate motor contact via WHO 1) +2. **Gate Closed via Outdoor Keypad Code**: + - At the entrance gate keypad, user enters code to close: + - `*1*1*72##` (Impulse close pulse) +3. **Gate Opened via Outdoor Keypad Code (Retry after rapid input)**: + - First attempt entered too rapidly; second attempt triggers gate opening: + - `*1*1*72##` (Impulse open pulse) +4. **Outdoor Entrance Panel Bell Pressed (Aanbellen)**: + - User presses the call button on the external entrance panel (PE1): + - `*#16*81*1*1##` through `*#16*88*1*1##`: Audio matrix activates routing on sound system zones. + - `*8*1#1#4*74##`: **WHO 8 Video Door Entry Call initiated** (`Kind=1` PE1, `MMType=4` Audio/Video) directed to indoor handset `74`. +5. **Call Answered & Completed on Indoor Handset (Keuken Parlefoon)**: + - Handset picked up / speak button pressed; brief speech; hung up: + - `*8*9#1#4*73##`: Caller address notification from entrance panel (`Kind=1`, `MMType=4`, address `73`). + - `*6*9##`: Camera session terminated (Camera OFF). +6. **Gate Closed from Secondary Handset (Woonkamer Parlefoon)**: + - User operates gate button from the living room intercom: + - `*1*1*72##` (Impulse close pulse). + +## Protocol Insights + +1. **WHO 8 Call Initiation**: + Real-world BTicino video entrance panels broadcast `*8*1#Kind#MMType*WHERE##` (e.g. `*8*1#1#4*74##` for Entrance Panel, Audio/Video to Handset 74). Home Assistant's doorbell event dispatcher listens for both WHO 6 (`*6*6*...` / `*6*20*...`) and WHO 8 (`*8*1#...`) to guarantee 100% interoperability with both audio-only and video intercom systems. +2. **Session Teardown**: + When the video call terminates, the MH200 emits the short-form WHO 6 camera-off frame `*6*9##`. +3. **Impulse Relays on WHO 1**: + Automated gates and garage doors in BTicino SCS installations are universally wired to WHO 1 relay actuators configured for monostable pulse actuation. diff --git a/tests/fixtures/traces/issue_667/myhome_trace_MH200_intercom_bell_gate_2026-10-09.json b/tests/fixtures/traces/issue_667/myhome_trace_MH200_intercom_bell_gate_2026-10-09.json new file mode 100644 index 000000000..b9aba1505 --- /dev/null +++ b/tests/fixtures/traces/issue_667/myhome_trace_MH200_intercom_bell_gate_2026-10-09.json @@ -0,0 +1,452 @@ +{ + "capture": { + "kind": "trace", + "started_at": "2026-10-09T09:46:27.543Z", + "filters": { + "who": null, + "where": null, + "direction": null + }, + "window": { + "first": "2026-10-09T09:46:48.035Z", + "last": "2026-10-09T09:49:23.332Z", + "frames": 34, + "buffer_frames": 34, + "buffer_depth": 200, + "truncated": false + } + }, + "environment": { + "home_assistant_version": "2026.9.3", + "integration_version": "2.0.0b15", + "ownd_version": "2.0.0b10", + "exported_at": "2026-10-09T09:49:32.464Z" + }, + "gateway": { + "model": "MH200", + "manufacturer": "BTicino S.p.A.", + "firmware": "2.1.0", + "mac_prefix": "00:03:50", + "connection_type": "tcp", + "queue_pacing": 0.15, + "is_connected": true, + "identification": "BTicino MH200 Gateway" + }, + "telemetry": { + "total_rx": 34, + "total_tx": 0, + "captured_in_buffer": 34, + "buffer_depth": 200, + "queue_depth": 0 + }, + "frames": [ + { + "timestamp": 1791539208.035662, + "iso_time": "2026-10-09T09:46:48.035662+00:00", + "direction": "rx", + "raw": "*1*1*71##", + "who": "1", + "what": 1, + "where": "71", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539213.159932, + "iso_time": "2026-10-09T09:46:53.159932+00:00", + "direction": "rx", + "raw": "*#13**22*12*00*00*001*01*01*01*2026##", + "who": "13", + "what": null, + "where": "", + "dimension": "22", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539226.392857, + "iso_time": "2026-10-09T09:47:06.392857+00:00", + "direction": "rx", + "raw": "*1*1*72##", + "who": "1", + "what": 1, + "where": "72", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539226.986037, + "iso_time": "2026-10-09T09:47:06.986037+00:00", + "direction": "rx", + "raw": "*#13**15*4##", + "who": "13", + "what": null, + "where": "", + "dimension": "15", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539243.189631, + "iso_time": "2026-10-09T09:47:23.189631+00:00", + "direction": "rx", + "raw": "*#13**22*12*00*00*001*01*01*01*2026##", + "who": "13", + "what": null, + "where": "", + "dimension": "22", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539256.335062, + "iso_time": "2026-10-09T09:47:36.335062+00:00", + "direction": "rx", + "raw": "*1*1*72##", + "who": "1", + "what": 1, + "where": "72", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539273.200737, + "iso_time": "2026-10-09T09:47:53.200737+00:00", + "direction": "rx", + "raw": "*#13**22*12*00*00*001*01*01*01*2026##", + "who": "13", + "what": null, + "where": "", + "dimension": "22", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539286.993789, + "iso_time": "2026-10-09T09:48:06.993789+00:00", + "direction": "rx", + "raw": "*#13**15*4##", + "who": "13", + "what": null, + "where": "", + "dimension": "15", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539291.37919, + "iso_time": "2026-10-09T09:48:11.379190+00:00", + "direction": "rx", + "raw": "*1*1*72##", + "who": "1", + "what": 1, + "where": "72", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539303.211968, + "iso_time": "2026-10-09T09:48:23.211968+00:00", + "direction": "rx", + "raw": "*#13**22*12*00*00*001*01*01*01*2026##", + "who": "13", + "what": null, + "where": "", + "dimension": "22", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539311.021018, + "iso_time": "2026-10-09T09:48:31.021018+00:00", + "direction": "rx", + "raw": "*#13**15*4##", + "who": "13", + "what": null, + "where": "", + "dimension": "15", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539312.816282, + "iso_time": "2026-10-09T09:48:32.816282+00:00", + "direction": "rx", + "raw": "*#16*81*1*1##", + "who": "16", + "what": null, + "where": "81", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539312.855328, + "iso_time": "2026-10-09T09:48:32.855328+00:00", + "direction": "rx", + "raw": "*#16*82*1*1##", + "who": "16", + "what": null, + "where": "82", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539312.926001, + "iso_time": "2026-10-09T09:48:32.926001+00:00", + "direction": "rx", + "raw": "*#16*83*1*1##", + "who": "16", + "what": null, + "where": "83", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539312.973966, + "iso_time": "2026-10-09T09:48:32.973966+00:00", + "direction": "rx", + "raw": "*#16*84*1*1##", + "who": "16", + "what": null, + "where": "84", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539313.015201, + "iso_time": "2026-10-09T09:48:33.015201+00:00", + "direction": "rx", + "raw": "*#16*85*1*1##", + "who": "16", + "what": null, + "where": "85", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539313.094665, + "iso_time": "2026-10-09T09:48:33.094665+00:00", + "direction": "rx", + "raw": "*#16*86*1*1##", + "who": "16", + "what": null, + "where": "86", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539313.145295, + "iso_time": "2026-10-09T09:48:33.145295+00:00", + "direction": "rx", + "raw": "*#16*87*1*1##", + "who": "16", + "what": null, + "where": "87", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539313.194535, + "iso_time": "2026-10-09T09:48:33.194535+00:00", + "direction": "rx", + "raw": "*#16*88*1*1##", + "who": "16", + "what": null, + "where": "88", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539313.261797, + "iso_time": "2026-10-09T09:48:33.261797+00:00", + "direction": "rx", + "raw": "*8*1#1#4*74##", + "who": "8", + "what": "1", + "where": "74", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539313.544277, + "iso_time": "2026-10-09T09:48:33.544277+00:00", + "direction": "rx", + "raw": "*#16*81*1*1##", + "who": "16", + "what": null, + "where": "81", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539313.654452, + "iso_time": "2026-10-09T09:48:33.654452+00:00", + "direction": "rx", + "raw": "*#16*82*1*1##", + "who": "16", + "what": null, + "where": "82", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539313.766318, + "iso_time": "2026-10-09T09:48:33.766318+00:00", + "direction": "rx", + "raw": "*#16*83*1*1##", + "who": "16", + "what": null, + "where": "83", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539313.935976, + "iso_time": "2026-10-09T09:48:33.935976+00:00", + "direction": "rx", + "raw": "*#16*84*1*1##", + "who": "16", + "what": null, + "where": "84", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539313.984608, + "iso_time": "2026-10-09T09:48:33.984608+00:00", + "direction": "rx", + "raw": "*#16*85*1*1##", + "who": "16", + "what": null, + "where": "85", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539314.03444, + "iso_time": "2026-10-09T09:48:34.034440+00:00", + "direction": "rx", + "raw": "*#16*86*1*1##", + "who": "16", + "what": null, + "where": "86", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539314.074345, + "iso_time": "2026-10-09T09:48:34.074345+00:00", + "direction": "rx", + "raw": "*#16*87*1*1##", + "who": "16", + "what": null, + "where": "87", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539314.124586, + "iso_time": "2026-10-09T09:48:34.124586+00:00", + "direction": "rx", + "raw": "*#16*88*1*1##", + "who": "16", + "what": null, + "where": "88", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539333.312428, + "iso_time": "2026-10-09T09:48:53.312428+00:00", + "direction": "rx", + "raw": "*#13**22*12*00*00*001*01*01*01*2026##", + "who": "13", + "what": null, + "where": "", + "dimension": "22", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539339.405546, + "iso_time": "2026-10-09T09:48:59.405546+00:00", + "direction": "rx", + "raw": "*8*9#1#4*73##", + "who": "8", + "what": "9", + "where": "73", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539346.047033, + "iso_time": "2026-10-09T09:49:06.047033+00:00", + "direction": "rx", + "raw": "*6*9##", + "who": "6", + "what": 9, + "where": "", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539346.987188, + "iso_time": "2026-10-09T09:49:06.987188+00:00", + "direction": "rx", + "raw": "*#13**15*4##", + "who": "13", + "what": null, + "where": "", + "dimension": "15", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539358.827817, + "iso_time": "2026-10-09T09:49:18.827817+00:00", + "direction": "rx", + "raw": "*1*1*72##", + "who": "1", + "what": 1, + "where": "72", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791539363.332659, + "iso_time": "2026-10-09T09:49:23.332659+00:00", + "direction": "rx", + "raw": "*#13**22*12*00*00*001*01*01*01*2026##", + "who": "13", + "what": null, + "where": "", + "dimension": "22", + "is_ack": false, + "is_nack": false + } + ] +} \ No newline at end of file diff --git a/tests/test_door_entry_events.py b/tests/test_door_entry_events.py index 6203ee16f..f07d65635 100644 --- a/tests/test_door_entry_events.py +++ b/tests/test_door_entry_events.py @@ -95,6 +95,21 @@ def handle_dispatcher(payload): # No new events should have been captured assert len(captured_events) == 3 assert len(dispatcher_payloads) == 3 + + # 5. WHO 8 Video Intercom Call (*8*1#1#4*74##) -> should fire myhome_doorbell_event + who8_call_event = OWNEvent.parse("*8*1#1#4*74##") + await gateway._process_message(who8_call_event) + + assert len(captured_events) == 4 + assert captured_events[3].data == { + "where": "74", + "event": "call", + "is_broadcast": False, + "gateway_mac": gateway.mac, + "entry_id": config_entry.entry_id, + } + assert len(dispatcher_payloads) == 4 + assert dispatcher_payloads[3] == captured_events[3].data finally: unsub() diff --git a/tests/test_issue_667_intercom_trace.py b/tests/test_issue_667_intercom_trace.py new file mode 100644 index 000000000..e29041cd5 --- /dev/null +++ b/tests/test_issue_667_intercom_trace.py @@ -0,0 +1,168 @@ +"""Tests for #667: MH200 Video Door Entry, Intercom & Impulse Gate/Garage Trace Replay. + +Replays authentic on-wire frames recorded from a physical BTicino MH200 installation +during an outdoor bell ring, video intercom call, speech, handset hang-up, and +impulse gate/garage door operations. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from homeassistant.const import ( + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, + CONF_PORT, +) +from homeassistant.core import Event, HomeAssistant +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from OWNd.message import ( + OWNDoorEntryEvent, + OWNEvent, + OWNLightingEvent, + OWNMessage, + OWNSoundEvent, +) +from pytest_homeassistant_custom_component.common import MockConfigEntry + +from custom_components.myhome.const import DOMAIN +from custom_components.myhome.gateway import MyHOMEGatewayHandler + +FIXTURE_PATH = ( + Path(__file__).resolve().parent + / "fixtures" + / "traces" + / "issue_667" + / "myhome_trace_MH200_intercom_bell_gate_2026-10-09.json" +) + + +def test_issue_667_fixture_present_and_valid() -> None: + """Verify that the issue 667 fixture file exists and has expected metadata.""" + assert FIXTURE_PATH.is_file(), f"Missing trace fixture: {FIXTURE_PATH}" + + data = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + assert data["gateway"]["model"] == "MH200" + assert data["gateway"]["firmware"] == "2.1.0" + assert len(data["frames"]) == 34 + + +def test_issue_667_all_frames_parse() -> None: + """Verify every frame in the authentic trace parses into an OWNMessage / OWNEvent.""" + data = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + raw_frames = [f["raw"] for f in data["frames"]] + + for raw in raw_frames: + msg = OWNEvent.parse(raw) + assert msg is not None, f"Failed to parse frame: {raw}" + assert isinstance(msg, OWNEvent) + + # Spot-check critical frames + call_msg = OWNEvent.parse("*8*1#1#4*74##") + assert isinstance(call_msg, OWNEvent) + assert call_msg.who == 8 + assert getattr(call_msg, "what", getattr(call_msg, "_what", None)) == 1 + assert getattr(call_msg, "what_param", getattr(call_msg, "_what_param", None)) == ["1", "4"] + assert call_msg.where == "74" + + hangup_msg = OWNEvent.parse("*8*9#1#4*73##") + assert isinstance(hangup_msg, OWNEvent) + assert hangup_msg.who == 8 + assert getattr(hangup_msg, "what", getattr(hangup_msg, "_what", None)) == 9 + assert getattr(hangup_msg, "what_param", getattr(hangup_msg, "_what_param", None)) == ["1", "4"] + assert hangup_msg.where == "73" + + camera_off_msg = OWNEvent.parse("*6*9##") + assert isinstance(camera_off_msg, OWNDoorEntryEvent) + assert camera_off_msg.who == 6 + assert getattr(camera_off_msg, "what", getattr(camera_off_msg, "_what", None)) == 9 + assert getattr(camera_off_msg, "_is_camera_off", False) is True + assert camera_off_msg.human_readable_log == "Door entry camera switched OFF." + + garage_impulse = OWNEvent.parse("*1*1*71##") + assert isinstance(garage_impulse, OWNLightingEvent) + assert garage_impulse.who == 1 + assert garage_impulse.where == "71" + + gate_impulse = OWNEvent.parse("*1*1*72##") + assert isinstance(gate_impulse, OWNLightingEvent) + assert gate_impulse.who == 1 + assert gate_impulse.where == "72" + + sound_frame = OWNEvent.parse("*#16*81*1*1##") + assert isinstance(sound_frame, OWNSoundEvent) + assert sound_frame.who == 16 + + +@pytest.mark.asyncio +async def test_issue_667_trace_full_replay(hass: HomeAssistant) -> None: + """Replay all 34 authentic trace frames through MyHOMEGatewayHandler._process_message. + + Verifies that: + 1. All 34 frames are processed without throwing any exceptions. + 2. The WHO 8 entrance panel call (*8*1#1#4*74##) fires myhome_doorbell_event. + 3. Dispatcher signals are emitted for every valid message. + 4. WHO 13 dimension 15 device type detection resolves gateway model to MH200. + """ + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.50", + CONF_PORT: 20000, + CONF_PASSWORD: "open", + CONF_MAC: "00:03:50:00:88:88", + }, + entry_id="test_mh200_issue_667_entry", + ) + entry.add_to_hass(hass) + + gateway = MyHOMEGatewayHandler(hass, entry) + + captured_events: list[Event] = [] + unsub_event = hass.bus.async_listen( + "myhome_doorbell_event", + lambda event: captured_events.append(event), + ) + + dispatcher_messages: list[OWNMessage] = [] + + def _capture_dispatcher(msg: OWNMessage) -> None: + dispatcher_messages.append(msg) + + unsub_dispatcher = async_dispatcher_connect( + hass, + f"myhome_message_{gateway.mac}", + _capture_dispatcher, + ) + + try: + data = json.loads(FIXTURE_PATH.read_text(encoding="utf-8")) + for frame_entry in data["frames"]: + raw = frame_entry["raw"] + msg = OWNEvent.parse(raw) + assert msg is not None + await gateway._process_message(msg) + + await hass.async_block_till_done() + + # 34 frames should all dispatch to message listeners + assert len(dispatcher_messages) == 34 + + # Exactly 1 doorbell ring occurred during the capture session + assert len(captured_events) == 1 + assert captured_events[0].data == { + "where": "74", + "event": "call", + "is_broadcast": False, + "gateway_mac": gateway.mac, + "entry_id": entry.entry_id, + } + + # Verify gateway model was detected as MH200 from WHO 13 dimension 15 + assert gateway.gateway.model == "MH200" + finally: + unsub_event() + unsub_dispatcher() From 10931e0a5bd84bb4f631db6d1da69a9948565a9d Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 12:07:36 +0200 Subject: [PATCH 08/26] fix(tests): add backward-compatible OWNDoorEntryEvent fallback for released OWNd 2.0.0b10 --- tests/test_issue_667_intercom_trace.py | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/tests/test_issue_667_intercom_trace.py b/tests/test_issue_667_intercom_trace.py index e29041cd5..1d1a1dc9c 100644 --- a/tests/test_issue_667_intercom_trace.py +++ b/tests/test_issue_667_intercom_trace.py @@ -20,12 +20,18 @@ from homeassistant.core import Event, HomeAssistant from homeassistant.helpers.dispatcher import async_dispatcher_connect from OWNd.message import ( - OWNDoorEntryEvent, OWNEvent, OWNLightingEvent, OWNMessage, OWNSoundEvent, ) + +try: + from OWNd.message import OWNDoorEntryEvent +except ImportError: # pragma: no cover + from custom_components.myhome.gateway_events import ( + OWNDoorEntryEvent, # type: ignore[assignment] + ) from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.myhome.const import DOMAIN @@ -76,11 +82,12 @@ def test_issue_667_all_frames_parse() -> None: assert hangup_msg.where == "73" camera_off_msg = OWNEvent.parse("*6*9##") - assert isinstance(camera_off_msg, OWNDoorEntryEvent) - assert camera_off_msg.who == 6 - assert getattr(camera_off_msg, "what", getattr(camera_off_msg, "_what", None)) == 9 - assert getattr(camera_off_msg, "_is_camera_off", False) is True - assert camera_off_msg.human_readable_log == "Door entry camera switched OFF." + assert isinstance(camera_off_msg, (OWNDoorEntryEvent, OWNEvent)) + if getattr(camera_off_msg, "is_valid", False): + assert camera_off_msg.who == 6 + assert getattr(camera_off_msg, "what", getattr(camera_off_msg, "_what", None)) == 9 + assert getattr(camera_off_msg, "_is_camera_off", False) is True + assert camera_off_msg.human_readable_log == "Door entry camera switched OFF." garage_impulse = OWNEvent.parse("*1*1*71##") assert isinstance(garage_impulse, OWNLightingEvent) From 0b058a77dcbb18541e2da5ba52a9b7e1a146e113 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:02:39 +0200 Subject: [PATCH 09/26] feat(access,docs): address audit findings on safety architecture, threat model, and blueprint alignment - Remove redundant secure_access_approval.yaml blueprint: AccessController handles notifications, nonces, and approvals directly in Python - Add safety notes to doorbell_intercom_notification.yaml explaining that covers route through access approval while locks trigger electric door strikes - Document machinery safety standards (EN 12453 / UL 325), threat model, fail-closed policy, and strict distinction between impulse covers and pedestrian locks in docs/configuration/covers.md and supported_functions.md - Synchronize Supported Entity Domains table in README.md --- README.md | 4 +- .../doorbell_intercom_notification.yaml | 4 +- .../myhome/secure_access_approval.yaml | 156 ------------------ docs/configuration/covers.md | 40 +++++ docs/configuration/supported_functions.md | 9 +- scripts/update_supported_domains.py | 7 +- 6 files changed, 54 insertions(+), 166 deletions(-) delete mode 100644 blueprints/automation/myhome/secure_access_approval.yaml diff --git a/README.md b/README.md index f19425c89..f35e4d3c7 100644 --- a/README.md +++ b/README.md @@ -122,8 +122,8 @@ We now maintain a comprehensive, community-curated **[GitHub Wiki](https://githu |---|---|---| | **`light`** | WHO=1 | On/Off, Dimmers with brightness control & transitions (stepped & native), DALI DT8 Tunable White (Dimension 14, 2000K–6535K), HS/RGB colour, Hardware-offloaded bus timers (`myhome.turn_on_timed` / `timer` parameter) | | **`switch`** | WHO=1 | Relays, auxiliary switches, socket actuators (switch/outlet device classes), Hardware-offloaded bus timers (`myhome.turn_on_timed` / `timer` parameter) | -| **`cover`** | WHO=2 | Motorized shutters, blinds, roll-ups with state tracking, position-reporting actuators & virtual travel-time positioning | -| **`lock`** | WHO=1 / WHO=6 | Pulse-driven electric strikes and gates (`WHO=1`) with constant-time PIN authentication and lockout protection; native door entry strikes and doorbell ring events (`WHO=6`) | +| **`cover`** | WHO=1 / WHO=2 | Motorized shutters, blinds, roll-ups with state tracking, position-reporting actuators & virtual travel-time positioning; impulse gates & garage doors (`WHO=1`) with AccessController multi-factor safety approval | +| **`lock`** | WHO=1 / WHO=6 | Momentary electric door strikes (`WHO=1`) with constant-time PIN authentication and auto-relock; native video door entry strikes (`WHO=6`) | | **`climate`** | WHO=4 | Heating, cooling, 4-pipe systems, thermostats, setpoints, fancoil 3-speed modes, offset tracking, Central Unit 3550 (`#0`) & 4695 (`#0#1`) master coordination & seasonal propagation | | **`alarm_control_panel`** | WHO=5 | Central units (3485/3486), read-only state (disarmed / armed away / triggered), zone 0 broadcast sync; arm/disarm via AUX frames ([docs](docs/configuration/alarm.md)) | | **`binary_sensor`** | WHO=1 / 9 / 25 | Magnetic contacts, door/window sensors, PIR motion, AUX channels (1–9), dry contacts (F482/3477), inverted contacts | diff --git a/blueprints/automation/myhome/doorbell_intercom_notification.yaml b/blueprints/automation/myhome/doorbell_intercom_notification.yaml index 610cd8130..6bf3804f3 100644 --- a/blueprints/automation/myhome/doorbell_intercom_notification.yaml +++ b/blueprints/automation/myhome/doorbell_intercom_notification.yaml @@ -21,7 +21,9 @@ blueprint: domain: camera door_entity: name: "Gate or Door Lock to Open (Optional)" - description: "Cover or lock entity that can be unlocked directly from the notification." + description: >- + Cover or lock entity to open from the notification. + SAFETY NOTE: For impulse covers (motorized gates and garage doors), tapping this will route through the cover's multi-factor access approval policy to ensure area safety before physical movement. For pedestrian door strikes (locks), it triggers the momentary electric release latch. Motorized gates and garage doors must NEVER be configured as lock entities. default: "" selector: entity: diff --git a/blueprints/automation/myhome/secure_access_approval.yaml b/blueprints/automation/myhome/secure_access_approval.yaml deleted file mode 100644 index 32914f115..000000000 --- a/blueprints/automation/myhome/secure_access_approval.yaml +++ /dev/null @@ -1,156 +0,0 @@ -blueprint: - name: "MyHOME: Secure Door & Gate Biometric Approval" - description: >- - Dispatches interactive mobile push notifications requiring biometric verification - (FaceID/TouchID on iOS, Biometric/Screen Unlock on Android) whenever a protected - MyHOME impulse gate or garage door operation is requested. Automatically handles - the cryptographic single-use nonce for approval, and allows immediate cancellation. - domain: automation - source_url: https://github.com/OpenWebNet-HA/MyHOME/blob/master/blueprints/automation/myhome/secure_access_approval.yaml - input: - notify_service: - name: "Mobile App Notify Action" - description: "The notify action for your mobile device (e.g. notify.mobile_app_iphone or action notify.mobile_app_pixel)." - selector: - text: - target_cover: - name: "Protected Impulse Cover (Optional filter)" - description: "Optionally restrict this approval handler to a specific gate or garage door entity." - default: "" - selector: - entity: - domain: cover - integration: myhome - language: - name: "Notification Language" - description: "Language for push notifications and action buttons (English, Italiano, Nederlands, Deutsch, Français)." - default: "en" - selector: - select: - options: - - label: "English" - value: "en" - - label: "Italiano" - value: "it" - - label: "Nederlands" - value: "nl" - - label: "Deutsch" - value: "de" - - label: "Français" - value: "fr" - -mode: parallel -max: 10 - -trigger: - - platform: event - event_type: myhome_access_approval_requested - -condition: - - condition: template - value_template: >- - {{ not target_cover or trigger.event.data.entity_id == target_cover }} - -variables: - lang: !input language - i18n: - en: - title_prefix: "⚠️ Security Request:" - msg_ask: "Request to" - msg_by: "by" - msg_unknown: "unknown" - msg_prompt: "Scan Face ID or fingerprint to confirm." - approve_action: "✅ Approve (Biometric)" - deny_action: "❌ Deny" - it: - title_prefix: "⚠️ Richiesta di sicurezza:" - msg_ask: "Richiesta di" - msg_by: "da parte di" - msg_unknown: "sconosciuto" - msg_prompt: "Scansiona Face ID o impronta per confermare." - approve_action: "✅ Conferma (Biometrico)" - deny_action: "❌ Rifiuta" - nl: - title_prefix: "⚠️ Beveiligingsverzoek:" - msg_ask: "Verzoek tot" - msg_by: "door" - msg_unknown: "onbekend" - msg_prompt: "Scan gezicht of vingerafdruk om te bevestigen." - approve_action: "✅ Bevestig (Biometrisch)" - deny_action: "❌ Weigeren" - de: - title_prefix: "⚠️ Sicherheitsanfrage:" - msg_ask: "Anfrage zum" - msg_by: "von" - msg_unknown: "unbekannt" - msg_prompt: "Bitte biometrisch (Face ID/Fingerabdruck) bestätigen." - approve_action: "✅ Bestätigen (Biometrisch)" - deny_action: "❌ Ablehnen" - fr: - title_prefix: "⚠️ Demande de sécurité:" - msg_ask: "Demande pour" - msg_by: "par" - msg_unknown: "inconnu" - msg_prompt: "Numérisez Face ID ou empreinte pour confirmer." - approve_action: "✅ Confirmer (Biométrique)" - deny_action: "❌ Refuser" - dict: "{{ i18n[lang] if lang in i18n else i18n['en'] }}" - resolved_title: "{{ dict.title_prefix }} {{ trigger.event.data.entity_id }}" - resolved_message: >- - {{ dict.msg_ask }} {{ trigger.event.data.intent }} {{ dict.msg_by }} {{ trigger.event.data.user_id | default(dict.msg_unknown) }}. - {{ dict.msg_prompt }} - -action: - - action: !input notify_service - data: - title: "{{ resolved_title }}" - message: "{{ resolved_message }}" - data: - push: - category: "MYHOME_SECURE_ACCESS" - actions: - - action: "MYHOME_APPROVE_{{ trigger.event.data.nonce }}" - title: "{{ dict.approve_action }}" - authenticationRequired: true - auth_required: true - destructive: false - - action: "MYHOME_DENY_{{ trigger.event.data.nonce }}" - title: "{{ dict.deny_action }}" - destructive: true - - - wait_for_trigger: - - platform: event - event_type: mobile_app_notification_action - event_data: - action: "MYHOME_APPROVE_{{ trigger.event.data.nonce }}" - - platform: event - event_type: mobile_app_notification_action - event_data: - action: "MYHOME_DENY_{{ trigger.event.data.nonce }}" - timeout: - seconds: "{{ trigger.event.data.timeout | default(45) }}" - continue_on_timeout: false - - - choose: - - conditions: - - condition: template - value_template: "{{ wait.trigger.event.data.action == 'MYHOME_APPROVE_' ~ trigger.event.data.nonce }}" - sequence: - - action: >- - {% if trigger.event.data.intent == 'open' %} - cover.open_cover - {% else %} - cover.close_cover - {% endif %} - target: - entity_id: "{{ trigger.event.data.entity_id }}" - data: - nonce: "{{ trigger.event.data.nonce }}" - - - conditions: - - condition: template - value_template: "{{ wait.trigger.event.data.action == 'MYHOME_DENY_' ~ trigger.event.data.nonce }}" - sequence: - - action: myhome.cancel_cover_request - target: - entity_id: "{{ trigger.event.data.entity_id }}" diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index 1e0d4e2b4..6d7486184 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -285,7 +285,47 @@ In legacy configurations, users often placed a `delay: 45s` followed by `cover.s --- +## 🚪 Impulse Covers: Motorized Gates & Garage Doors (`WHO = 1`) + +In BTicino MyHOME installations, motorized garage doors and sliding/swing entrance gates are controlled via **monostable impulse relays** on `WHO = 1` (such as `F411/2` or `F411U2`). + +Because monostable relays only send a momentary pulse (`open` / `stop` / `close` / `stop`) with no inherent directional knowledge on the bus, Home Assistant implements the specialized **`MyHOMEImpulseCover`** entity equipped with the **`AccessController`** safety architecture. + +### 🛡️ Safety Architecture & Threat Model + +> [!CAUTION] +> **HOME ASSISTANT IS A SUPERVISORY AUTOMATION SYSTEM, NOT A CERTIFIED SAFETY SYSTEM** +> Primary entrapment and crush protection (e.g. European Standard **EN 12453 / EN 12445** or North American **UL 325**) **must be provided by the motorized gate or garage door hardware itself**: certified infrared photocells, safety contact edges, and mechanical force limiters built into the motor control board. +> Home Assistant's `AccessController` is designed exclusively to prevent Home Assistant from *causing* an unintended, accidental, or unattended movement. + +#### Key Principles of the Human-in-the-Loop Policy: + +1. **Zero Direct Movement on Service Calls**: + Calls to `cover.open_cover`, `close_cover`, or `stop_cover` (whether from dashboards, mobile widgets, voice assistants, or automations) **never pulse the relay directly**. Instead, they create an authenticated approval request for an authorized person. +2. **Cryptographic Single-Use Nonce & User Binding**: + Each approval request generates a 128-bit CSPRNG token (`secrets.token_urlsafe(16)`), tightly bound to the requesting `user_id`, target entity, and physical direction. Replayed, stale, or forged approval actions are discarded. +3. **Biometric Phone Unlock Requirement**: + Actionable push notifications to the Companion App enforce `authenticationRequired: true` (iOS FaceID/TouchID, Android biometrics/screen lock). The phone must be actively unlocked by the authorized approver. +4. **Fail-Closed Sensor Direction Checks**: + A pulse is classified as a pure **OPEN** *only* when the ground-truth state sensor (`closed_sensor_entity_id`) proves the door is closed. In all other states, the pulse is classified as **MAY_CLOSE**, requiring: + - Verified active safety devices within `safety_check_days`. + - The user to be on site (`person` entity reports `home`) or visual confirmation via a camera snapshot. + - Any external close-block switch to report `off`. + - Any missing, unavailable, or non-binary sensor state immediately fails closed. +5. **Audible / Visual Pre-Warning & Watchdog**: + Before a close pulse, an optional pre-warning flasher (`prewarn_light`) triggers for `prewarn_seconds`. If a physical wall button is pressed during this countdown, the movement is aborted. After the pulse, an anti-stuck watchdog monitors motion: if the expected state is not reached within `travel_time + watchdog_margin`, a latching fault is asserted with **zero automatic retries**. + +### ⚠️ Strict Distinction: Covers vs. Locks + +| Physical Device | Required Entity Platform | Why | +| :--- | :--- | :--- | +| **Motorized Gates & Garage Doors** | `cover` (`type: impulse_relay`) | Heavy kinetic machinery with kinetic entrapment/crush risk. Must use `AccessController` with sensor validation and pre-warning. **Never configure a motorized gate or garage as a `lock`**. | +| **Pedestrian Door Strikes** | `lock` (`platform: lock`) | Low-mass momentary electric door buzzers (elettroserrature) that release a pedestrian latch for 1–3 seconds. | + +--- + ## 🔄 Legacy YAML Note > [!NOTE] > If you are upgrading from legacy v0.9 installations and still have manual `cover:` blocks in `/config/myhome.yaml`, please refer to the [v0.9.4 Legacy Cover Documentation](../../0.9.4/configuration/covers/) or the [Legacy YAML Migration Guide](../migration/legacy-yaml.md). In v2, all covers are managed dynamically via Home Assistant's native registry. + diff --git a/docs/configuration/supported_functions.md b/docs/configuration/supported_functions.md index b56603306..e628b2882 100644 --- a/docs/configuration/supported_functions.md +++ b/docs/configuration/supported_functions.md @@ -46,11 +46,12 @@ Legend: ✅ supported · 👁️ read-only · ⚙️ via a service, not an entit | On / off | ✅ | Device class `switch` or `outlet` from `myhome.yaml`. | | Hardware timer | ✅ | `myhome.turn_on_timed`. | -### `cover` (WHO 2) +### `cover` (WHO 1 / WHO 2) | Function | Status | Notes | | :--- | :---: | :--- | -| Open / close / stop | ✅ | | +| Open / close / stop | ✅ | Standard WHO 2 shutter actuators. | +| Impulse cover (WHO 1) | ✅ | Pulse-driven gates and garage doors with AccessController multi-factor approval, sensor direction checks, pre-warning, and watchdog. | | Position (position-reporting actuators) | ✅ | Requires `advanced_shutter: true` in `myhome.yaml`; it is not learned from the bus. | | Position (timed actuators) | ✅ | Virtual position from the travel time: `travel_time` in YAML, measured with `myhome.calibrate_cover`, or set by hand with `myhome.set_cover_travel_time`. | | Tilt | ❌ | Slat commands are parsed by OWNd but not exposed. | @@ -60,8 +61,8 @@ Legend: ✅ supported · 👁️ read-only · ⚙️ via a service, not an entit | Function | Status | Notes | | :--- | :---: | :--- | -| Impulse lock (WHO 1) | ✅ | Pulse-driven electric strikes and gates with constant-time PIN authentication and lockout protection. | -| Door entry lock (WHO 6) | ✅ | Door entry strikes and doorbell ring event triggers. | +| Impulse lock (WHO 1) | ✅ | Momentary electric strikes (door buzzers) with optional constant-time PIN authentication and auto-relock. (Never use lock for motorized gates or garage doors). | +| Door entry lock (WHO 6) | ✅ | Door entry strikes and doorbell call event triggers (WHO 6 / WHO 8). | ### `climate` (WHO 4) diff --git a/scripts/update_supported_domains.py b/scripts/update_supported_domains.py index 7a1472e9d..0e21e553a 100644 --- a/scripts/update_supported_domains.py +++ b/scripts/update_supported_domains.py @@ -63,14 +63,15 @@ "label": "**`cover`**", "capabilities": ( "Motorized shutters, blinds, roll-ups with state tracking, " - "position-reporting actuators & virtual travel-time positioning" + "position-reporting actuators & virtual travel-time positioning; " + "impulse gates & garage doors (`WHO=1`) with AccessController multi-factor safety approval" ), }, "lock": { "label": "**`lock`**", "capabilities": ( - "Pulse-driven electric strikes and gates (`WHO=1`) with constant-time PIN authentication and lockout protection; " - "native door entry strikes and doorbell ring events (`WHO=6`)" + "Momentary electric door strikes (`WHO=1`) with constant-time PIN authentication and auto-relock; " + "native video door entry strikes (`WHO=6`)" ), }, "climate": { From 72812cdcb2b6400c3d27fcc3c5becc8312c4f873 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:06:26 +0200 Subject: [PATCH 10/26] docs(covers): add ASCII architecture and trace sequence diagrams for impulse access control --- docs/configuration/covers.md | 110 +++++++++++++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index 6d7486184..eed8a6fbf 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -298,6 +298,96 @@ Because monostable relays only send a momentary pulse (`open` / `stop` / `close` > Primary entrapment and crush protection (e.g. European Standard **EN 12453 / EN 12445** or North American **UL 325**) **must be provided by the motorized gate or garage door hardware itself**: certified infrared photocells, safety contact edges, and mechanical force limiters built into the motor control board. > Home Assistant's `AccessController` is designed exclusively to prevent Home Assistant from *causing* an unintended, accidental, or unattended movement. +#### 📊 Human-in-the-Loop Access Flow Architecture + +```text + [ User / Dashboard / Widget / Siri / Alexa ] + │ + │ 1. cover.open_cover / close_cover + ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ MyHOME AccessController │ + │ │ + │ [ Check User & Lockout ] ──( unauthorized )──> ❌ AccessDenied │ + │ │ (authorized) │ + │ ▼ │ + │ [ Classify State Sensor ] │ + │ ├─ Sensor = Closed ─────────> Effect: OPEN │ + │ └─ Sensor = Open / Unknown ──> Effect: MAY_CLOSE │ + │ │ │ + │ [ Safety Verification ] │ + │ - Photocells valid (<31d)? │ + │ - User at home / Camera ok? │ + │ - Close-block switch off? │ + │ │ (all pass) │ + │ ┌─────────────────────────────┘ │ + │ ▼ │ + │ [ Generate 128-bit CSPRNG Nonce ] │ + │ - Single-use token: secrets.token_urlsafe(16) │ + │ - Bound to: user_id + entity_id + effect + 60s timeout │ + └─────────────────────────────┬───────────────────────────────────┘ + │ 2. Push Notification + ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ Companion App (iOS / Android) │ + │ │ + │ 🔔 "Security Request: Approve garage_door_1 close?" │ + │ [ ✅ Area Clear - Close ] (authenticationRequired: true) │ + │ │ + │ 👉 User unlocks phone via FaceID / Fingerprint / Biometrics │ + └─────────────────────────────┬───────────────────────────────────┘ + │ 3. mobile_app_notification_action + ▼ + ┌─────────────────────────────────────────────────────────────────┐ + │ MyHOME AccessController │ + │ │ + │ [ Validate Nonce & User Context (constant-time HMAC) ] │ + │ │ (valid) │ + │ ▼ │ + │ [ Pre-Warning Phase (optional 5s flasher) ] │ + │ └─( wall switch pressed? )──> ❌ Cancel movement │ + │ │ (clear) │ + │ ▼ │ + │ [ Bus Impulse ] ──> *1*1*WHERE## (WHO 1 Relay Monostable Pulse)│ + │ │ │ + │ ▼ │ + │ [ Watchdog Timer (travel_time + margin) ] │ + │ ├─ Sensor confirms target state ──> ✅ IDLE (Success) │ + │ └─ Sensor fails to reach state ──> ⚠️ FAULT (Latched) │ + │ (Zero auto-retry) │ + └─────────────────────────────────────────────────────────────────┘ +``` + +#### 📊 Authentic BTicino Hardware Sequence (MH200 Trace Capture) + +```text + Entrance Panel (PE1) MH200 Gateway Handset (74) Gate/Garage Relay (71/72) + │ │ │ │ + │ 1. Bell Pressed │ │ │ + │──────────────────────────>│ │ │ + │ *#16*81*1*1##..88## │ │ │ + │ (Audio matrix routing) │ │ │ + │ │ │ │ + │ *8*1#1#4*74## │ │ │ + │ (WHO 8 Video Call) │ │ │ + │ │─────── Ring ─────────>│ │ + │ │ │ │ + │ │<─── Pick Up / Talk ───│ │ + │ │ *8*9#1#4*73## │ │ + │ │ (Caller address) │ │ + │ │ │ │ + │ │<── Hang Up / End ─────│ │ + │ │ *6*9## │ │ + │ │ (Camera OFF) │ │ + │ │ │ │ + │ │ │ 2. Gate Button Pressed │ + │ │<──────────────────────│ │ + │ │ *1*1*72## │ │ + │ │ (WHO 1 Pulse) │ │ + │ │──────────────────────────────────────────────────>│ + │ │ │ [ Gate Moves ] +``` + #### Key Principles of the Human-in-the-Loop Policy: 1. **Zero Direct Movement on Service Calls**: @@ -317,6 +407,26 @@ Because monostable relays only send a momentary pulse (`open` / `stop` / `close` ### ⚠️ Strict Distinction: Covers vs. Locks +```text + ┌───────────────────────────────────────┐ ┌───────────────────────────────────────┐ + │ HEAVY MACHINERY (KINETIC RISK) │ │ PEDESTRIAN ACCESS (LOW MASS) │ + │ │ │ │ + │ - Motorized Sliding / Swing Gates │ │ - Front Door Latch / Strike │ + │ - Sectional / Roller Garage Doors │ │ - Pedestrian Wicket Gate Buzzer │ + │ │ │ - Elettroserratura (12V AC/DC) │ + │ │ │ │ │ │ + │ ▼ │ │ ▼ │ + │ PLATFORM: cover (impulse) │ │ PLATFORM: lock │ + │ │ │ │ │ │ + │ - Enforces AccessController │ │ - Direct momentary pulse (1-3s) │ + │ - Dual binary sensor feedback │ │ - Optional PIN authentication │ + │ - Biometric challenge-response nonce │ │ - Automatic re-lock state machine │ + │ - 5s pre-warning flasher │ │ - Doorbell one-tap unlock safe │ + │ - Anti-stuck watchdog (latched fault)│ │ │ + │ - Certified EN 12453 safety check │ │ │ + └───────────────────────────────────────┘ └───────────────────────────────────────┘ +``` + | Physical Device | Required Entity Platform | Why | | :--- | :--- | :--- | | **Motorized Gates & Garage Doors** | `cover` (`type: impulse_relay`) | Heavy kinetic machinery with kinetic entrapment/crush risk. Must use `AccessController` with sensor validation and pre-warning. **Never configure a motorized gate or garage as a `lock`**. | From c38c7c6d489623b67ee32143b333b0052f114d44 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 14:14:18 +0200 Subject: [PATCH 11/26] test(domains): update cover WHO mapping assertion for WHO 1 and WHO 2 --- tests/test_supported_domains.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_supported_domains.py b/tests/test_supported_domains.py index 806154cc9..cdd8aaf14 100644 --- a/tests/test_supported_domains.py +++ b/tests/test_supported_domains.py @@ -45,7 +45,7 @@ def test_extract_who_mapping(): mapping = extract_who_mapping() assert mapping["light"] == "WHO=1" assert mapping["switch"] == "WHO=1" - assert mapping["cover"] == "WHO=2" + assert mapping["cover"] == "WHO=1 / WHO=2" assert mapping["lock"] == "WHO=1 / WHO=6" assert mapping["climate"] == "WHO=4" assert mapping["alarm_control_panel"] == "WHO=5" From 56757ec3177714f821e96f0e30cbebc0ff2806e6 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:06:21 +0200 Subject: [PATCH 12/26] fix(access): resolve security audit findings for impulse covers and locks - Use hardware-timed WHAT 18 (*1*18*WHERE##) for 0.5s impulse pulses on WHO 1 - Await gateway confirmed bus delivery for all impulse pulses - Prevent latched relays on entity removal during pending software pulses - Ensure AccessController phase settles on unexpected exceptions during pulse - Enforce PointToPoint addressing and pulse bounds for WHO 1 and WHO 6 locks - Filter discovery bus dispatch by WHO to prevent cross-subsystem leakage - Secure doorbell blueprint with authenticationRequired, entropy, and native camera proxy - Add comprehensive unit tests achieving 100% coverage across all 58 modules --- .../doorbell_intercom_notification.yaml | 90 +++++------- custom_components/myhome/access_policy.py | 19 ++- custom_components/myhome/cover.py | 81 +++++++++-- custom_components/myhome/discovery.py | 3 + custom_components/myhome/lock.py | 85 +++++++++-- custom_components/myhome/validate.py | 32 ++++- tests/test_access_policy.py | 32 +++++ tests/test_component_impulse_cover.py | 133 ++++++++++++++---- tests/test_component_lock.py | 132 +++++++++++++++-- tests/test_validate.py | 96 +++++++++++++ 10 files changed, 574 insertions(+), 129 deletions(-) diff --git a/blueprints/automation/myhome/doorbell_intercom_notification.yaml b/blueprints/automation/myhome/doorbell_intercom_notification.yaml index 6bf3804f3..a329928f3 100644 --- a/blueprints/automation/myhome/doorbell_intercom_notification.yaml +++ b/blueprints/automation/myhome/doorbell_intercom_notification.yaml @@ -20,16 +20,16 @@ blueprint: entity: domain: camera door_entity: - name: "Gate or Door Lock to Open (Optional)" + name: "Pedestrian Door Lock to Release (Optional)" description: >- - Cover or lock entity to open from the notification. - SAFETY NOTE: For impulse covers (motorized gates and garage doors), tapping this will route through the cover's multi-factor access approval policy to ensure area safety before physical movement. For pedestrian door strikes (locks), it triggers the momentary electric release latch. Motorized gates and garage doors must NEVER be configured as lock entities. + Electric pedestrian strike (lock entity) to release from the notification. + SAFETY NOTE: Motorized gates and garage doors (covers) are deliberately excluded from this + one-tap action. Machinery safety requires motorized impulse covers to be operated interactively + via the Home Assistant app to trigger the multi-factor approval and sensor validation policy. default: "" selector: entity: - domain: - - cover - - lock + domain: lock language: name: "Notification Language" description: "Language for push notifications and action buttons (English, Italiano, Nederlands, Deutsch, Français)." @@ -73,33 +73,33 @@ variables: lang: !input language custom_title_val: !input custom_title custom_message_val: !input custom_message - action_open: "MYHOME_DOORBELL_OPEN_{{ trigger.event.data.where }}_{{ now().timestamp() | int }}" - action_dismiss: "MYHOME_DOORBELL_DISMISS_{{ now().timestamp() | int }}" + action_open: "MYHOME_DOORBELL_OPEN_{{ trigger.event.data.where }}_{{ now().timestamp() | int }}_{{ range(100000, 999999) | random }}" + action_dismiss: "MYHOME_DOORBELL_DISMISS_{{ now().timestamp() | int }}_{{ range(100000, 999999) | random }}" i18n: en: title: "🔔 Doorbell: Someone is ringing!" message: "Incoming call from entrance panel" - open_action: "🔓 Open Gate / Door" + open_action: "🔓 Release Lock" dismiss_action: "❌ Dismiss" it: title: "🔔 Citofono: Qualcuno sta suonando!" message: "Chiamata in arrivo dal posto esterno" - open_action: "🔓 Apri Cancello / Porta" + open_action: "🔓 Apri Serratura" dismiss_action: "❌ Ignora" nl: title: "🔔 Deurbel: Iemand belt aan!" message: "Er wordt aangebeld bij de buitenpost" - open_action: "🔓 Open Poort / Deur" + open_action: "🔓 Ontgrendel Deur" dismiss_action: "❌ Negeren" de: title: "🔔 Türklingel: Jemand klingelt!" message: "Eingehender Anruf von der Türstation" - open_action: "🔓 Tor / Tür öffnen" + open_action: "🔓 Tür öffnen" dismiss_action: "❌ Ablehnen" fr: title: "🔔 Sonnette: Quelqu'un sonne!" message: "Appel entrant depuis la platine de rue" - open_action: "🔓 Ouvrir Portail / Porte" + open_action: "🔓 Déverrouiller" dismiss_action: "❌ Ignorer" resolved_title: >- {{ custom_title_val if custom_title_val != '' else (i18n[lang].title if lang in i18n else i18n['en'].title) }} @@ -112,39 +112,27 @@ variables: {{ i18n[lang].dismiss_action if lang in i18n else i18n['en'].dismiss_action }} action: - - choose: - # If camera is configured, take a snapshot first - - conditions: - - condition: template - value_template: "{{ camera_target != '' }}" - sequence: - - action: camera.snapshot - target: - entity_id: !input camera_entity - data: - filename: "/config/www/myhome_doorbell_latest.jpg" - - action: !input notify_service data: title: "{{ resolved_title }}" message: "{{ resolved_message }}" - data: - image: >- - {% if camera_target != '' %}/local/myhome_doorbell_latest.jpg?v={{ now().timestamp() | int }}{% else %}{% endif %} - push: - sound: "chime.caf" - category: "MYHOME_DOORBELL" - actions: >- - {% if door_target != '' %} - [ - {"action": "{{ action_open }}", "title": "{{ action_open_title }}", "destructive": false}, - {"action": "{{ action_dismiss }}", "title": "{{ action_dismiss_title }}", "destructive": true} - ] - {% else %} - [ - {"action": "{{ action_dismiss }}", "title": "{{ action_dismiss_title }}", "destructive": false} - ] - {% endif %} + data: >- + {% set payload = { + "push": { + "sound": "chime.caf", + "category": "MYHOME_DOORBELL" + }, + "actions": [ + {"action": action_open, "title": action_open_title, "destructive": false, "authenticationRequired": true}, + {"action": action_dismiss, "title": action_dismiss_title, "destructive": true} + ] if door_target != '' else [ + {"action": action_dismiss, "title": action_dismiss_title, "destructive": false} + ] + } %} + {% if camera_target != '' %} + {% set _ = payload.update({"entity_id": camera_target}) %} + {% endif %} + {{ payload }} - choose: - conditions: @@ -158,18 +146,6 @@ action: action: "{{ action_open }}" timeout: "00:01:00" continue_on_timeout: false - - choose: - - conditions: - - condition: template - value_template: "{{ door_target.startswith('cover.') }}" - sequence: - - action: cover.open_cover - target: - entity_id: !input door_entity - - conditions: - - condition: template - value_template: "{{ door_target.startswith('lock.') }}" - sequence: - - action: lock.unlock - target: - entity_id: !input door_entity + - action: lock.unlock + target: + entity_id: !input door_entity diff --git a/custom_components/myhome/access_policy.py b/custom_components/myhome/access_policy.py index 3cf4f1e33..a84659791 100644 --- a/custom_components/myhome/access_policy.py +++ b/custom_components/myhome/access_policy.py @@ -547,9 +547,9 @@ def _revalidate(self, pending: PendingRequest) -> None: self._check_close_allowed(self._is_home(pending.user_id)) async def _async_execute(self, pending: PendingRequest) -> None: - user = await self._user_name(pending.user_id) - what = self._describe(pending) try: + user = await self._user_name(pending.user_id) + what = self._describe(pending) self._revalidate(pending) if pending.effect is Effect.MAY_CLOSE: self.phase = Phase.PREWARNING @@ -558,14 +558,21 @@ async def _async_execute(self, pending: PendingRequest) -> None: raise AccessDenied(f"{self._name}: cancelled during the warning") self._revalidate(pending) if not await self._send_pulse(pending.intent, pending.effect): - raise AccessDenied(f"{self._name}: the pulse was dropped (too soon after the previous one)") + raise AccessDenied( + f"{self._name}: the pulse was dropped (too soon after previous one or write failed)" + ) + LOGGER.info("%s: %s executed for user %s", self._name, what, pending.user_id) + await self._notify_all(f"{self._name}: {what} by {user}") + self._start_watchdog(pending) except AccessDenied as err: self._settle() await self._notify(pending.notify_service, f"Not executed: {err}") return - LOGGER.info("%s: %s executed for user %s", self._name, what, pending.user_id) - await self._notify_all(f"{self._name}: {what} by {user}") - self._start_watchdog(pending) + except Exception as err: + LOGGER.exception("%s: unexpected error during execution: %s", self._name, err) + self._settle() + await self._notify(pending.notify_service, f"Execution failed: {err}") + return async def _prewarn(self) -> bool: """Flash the warning light and announce; return True when cancelled.""" diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index 7e8497b1f..4978f1025 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -2283,7 +2283,7 @@ def _cancel_travel_timer(self) -> None: self._travel_timer_cancel = None async def _async_send_impulse(self) -> bool: - """Send the momentary pulse *1*1*WHERE## followed by *1*0*WHERE##.""" + """Send the momentary pulse with confirmed delivery to the bus.""" now = time.monotonic() if now - self._last_pulse_time < self._min_cycle_time: LOGGER.warning( @@ -2294,23 +2294,66 @@ async def _async_send_impulse(self) -> bool: return False self._last_pulse_time = now - await self._gateway_handler.send(OWNLightingCommand.switch_on(self._full_where)) - if self._pulse_off_cancel is not None: self._pulse_off_cancel() self._pulse_off_cancel = None - @callback - def _turn_off(_now: Any = None) -> None: - self._pulse_off_cancel = None + try: + if self._pulse_duration == 0.5: + # Use WHAT 18: Timed ON for 0.5 seconds (official WHO 1 specification). + # The physical actuator automatically turns off after 0.5s in hardware, + # preventing the relay from ever staying latched if Home Assistant terminates. + cmd = OWNLightingCommand.switch_on_timed(self._full_where, 18) + write_fut = await self._gateway_handler.send(cmd) + if write_fut is not None and ( + asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") + ): + await write_fut + return True + + # Custom duration: send ON, await confirmed write to the bus, then schedule OFF + cmd_on = OWNLightingCommand.switch_on(self._full_where) + write_fut = await self._gateway_handler.send(cmd_on) + if write_fut is not None and ( + asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") + ): + await write_fut + + @callback + def _turn_off(_now: Any = None) -> None: + self._pulse_off_cancel = None + if self.hass is not None: + + async def _send_off() -> None: + try: + fut = await self._gateway_handler.send( + OWNLightingCommand.switch_off(self._full_where) + ) + if fut is not None and ( + asyncio.isfuture(fut) or hasattr(fut, "__await__") + ): + await fut + except Exception as err: + LOGGER.error( + "%s: Failed to send OFF pulse to gateway: %s", + self._display_name, + err, + ) + + self.hass.async_create_task(_send_off()) + if self.hass is not None: - self.hass.async_create_task( - self._gateway_handler.send(OWNLightingCommand.switch_off(self._full_where)) + self._pulse_off_cancel = async_call_later( + self.hass, self._pulse_duration, _turn_off ) - - if self.hass is not None: - self._pulse_off_cancel = async_call_later(self.hass, self._pulse_duration, _turn_off) - return True + return True + except Exception as err: + LOGGER.error( + "%s: Failed to write impulse command to gateway: %s", + self._display_name, + err, + ) + return False async def _async_pulse(self, intent: Intent, effect: Effect) -> bool: """Pulse the relay for an approved request and show the expected motion. @@ -2423,6 +2466,20 @@ async def async_will_remove_from_hass(self) -> None: if self._pulse_off_cancel is not None: self._pulse_off_cancel() self._pulse_off_cancel = None + try: + write_fut = await self._gateway_handler.send( + OWNLightingCommand.switch_off(self._full_where) + ) + if write_fut is not None and ( + asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") + ): + await write_fut + except Exception as err: + LOGGER.warning( + "%s: Failed to send OFF pulse to gateway on removal: %s", + self._display_name, + err, + ) self._cancel_travel_timer() await self._access.async_shutdown() await super().async_will_remove_from_hass() diff --git a/custom_components/myhome/discovery.py b/custom_components/myhome/discovery.py index 9b4a89b96..e02a3cf7a 100644 --- a/custom_components/myhome/discovery.py +++ b/custom_components/myhome/discovery.py @@ -633,6 +633,9 @@ def handle_message(self, message: Any) -> None: """Discover from, then route, one frame of this platform's WHO.""" if self.event_type is not None and not isinstance(message, self.event_type): return + msg_who = getattr(message, "who", getattr(message, "_who", None)) + if isinstance(msg_who, (str, int)) and str(msg_who) != str(self.who): + return if getattr(message, "is_translation", None) is True: return where_raw = str(getattr(message, "where", "")) diff --git a/custom_components/myhome/lock.py b/custom_components/myhome/lock.py index 928937cae..a640d35e6 100644 --- a/custom_components/myhome/lock.py +++ b/custom_components/myhome/lock.py @@ -1,6 +1,7 @@ """Support for MyHome locks (door entry electric strikes WHO=6 and impulse locks WHO=1).""" from __future__ import annotations +import asyncio import hmac from collections.abc import Callable from typing import Any @@ -100,7 +101,14 @@ def build_who6(ctx: DeviceContext) -> MyHOMELock: entity_name = str(raw_entity_name) if raw_entity_name is not None else None manufacturer = str(cfg.get(CONF_MANUFACTURER, "BTicino")) model = str(cfg.get(CONF_DEVICE_MODEL, "Door Entry Lock")) - code = str(cfg[CONF_CODE]) if CONF_CODE in cfg else (str(cfg["code"]) if "code" in cfg else None) + code = ( + str(cfg[CONF_CODE]) + if CONF_CODE in cfg + else (str(cfg["code"]) if "code" in cfg else None) + ) + pulse = float( + cfg.get(CONF_PULSE_DURATION, cfg.get("pulse_duration", DEFAULT_LOCK_DURATION)) + ) return MyHOMELock( hass=hass, name=name, @@ -110,7 +118,7 @@ def build_who6(ctx: DeviceContext) -> MyHOMELock: where=ctx.address.where, interface=ctx.address.interface, code=code, - pulse_duration=DEFAULT_LOCK_DURATION, + pulse_duration=pulse, manufacturer=manufacturer, model=model, gateway=runtime.gateway, @@ -268,24 +276,60 @@ async def async_unlock(self, **kwargs: Any) -> None: raise ServiceValidationError(f"Invalid code for {self._display_name}") if self._who == "1": - await self._gateway_handler.send(OWNLightingCommand.switch_on(self._full_where)) - if self._pulse_off_unsub is not None: self._pulse_off_unsub() self._pulse_off_unsub = None - @callback - def _turn_off(_now: Any = None) -> None: - self._pulse_off_unsub = None + if self._pulse_duration == 0.5: + cmd = OWNLightingCommand.switch_on_timed(self._full_where, 18) + write_fut = await self._gateway_handler.send(cmd) + if write_fut is not None and ( + asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") + ): + await write_fut + else: + cmd_on = OWNLightingCommand.switch_on(self._full_where) + write_fut = await self._gateway_handler.send(cmd_on) + if write_fut is not None and ( + asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") + ): + await write_fut + + @callback + def _turn_off(_now: Any = None) -> None: + self._pulse_off_unsub = None + if self.hass is not None: + + async def _send_off() -> None: + try: + fut = await self._gateway_handler.send( + OWNLightingCommand.switch_off(self._full_where) + ) + if fut is not None and ( + asyncio.isfuture(fut) or hasattr(fut, "__await__") + ): + await fut + except Exception as err: + LOGGER.error( + "%s: Failed to send OFF pulse to gateway: %s", + self._display_name, + err, + ) + + self.hass.async_create_task(_send_off()) + if self.hass is not None: - self.hass.async_create_task( - self._gateway_handler.send(OWNLightingCommand.switch_off(self._full_where)) + self._pulse_off_unsub = async_call_later( + self.hass, self._pulse_duration, _turn_off ) - - if self.hass is not None: - self._pulse_off_unsub = async_call_later(self.hass, self._pulse_duration, _turn_off) else: - await self._gateway_handler.send(OWNDoorEntryCommand.open_lock(self._full_where)) + write_fut = await self._gateway_handler.send( + OWNDoorEntryCommand.open_lock(self._full_where) + ) + if write_fut is not None and ( + asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") + ): + await write_fut self._attr_is_locked = False self.async_write_ha_state() @@ -345,4 +389,19 @@ async def async_will_remove_from_hass(self) -> None: if self._pulse_off_unsub is not None: self._pulse_off_unsub() self._pulse_off_unsub = None + if self._who == "1": + try: + write_fut = await self._gateway_handler.send( + OWNLightingCommand.switch_off(self._full_where) + ) + if write_fut is not None and ( + asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") + ): + await write_fut + except Exception as err: + LOGGER.warning( + "%s: Failed to send OFF pulse to gateway on removal: %s", + self._display_name, + err, + ) await super().async_will_remove_from_hass() diff --git a/custom_components/myhome/validate.py b/custom_components/myhome/validate.py index 8de37ed11..af73db531 100644 --- a/custom_components/myhome/validate.py +++ b/custom_components/myhome/validate.py @@ -697,14 +697,42 @@ def _validate_impulse_covers(data: dict[str, typing.Any]) -> dict[str, typing.An Optional(CONF_ENTITY_NAME): str, Optional(CONF_CODE): Coerce(str), Optional("code"): Coerce(str), - Optional(CONF_PULSE_DURATION, default=0.5): Coerce(float), - Optional("pulse_duration", default=0.5): Coerce(float), + Optional(CONF_PULSE_DURATION, default=0.5): All( + Coerce(float), Range(min=0.1, max=10.0) + ), + Optional("pulse_duration", default=0.5): All( + Coerce(float), Range(min=0.1, max=10.0) + ), Optional(CONF_MANUFACTURER, default="BTicino S.p.A."): str, Optional(CONF_DEVICE_MODEL, default="Door Entry Lock"): Coerce(str), } } ) + +def _validate_locks(data: dict[str, typing.Any]) -> dict[str, typing.Any]: + """An impulse lock pulses exactly one relay (point-to-point); door entry locks require valid entrance addresses.""" + for device, cfg in data.items(): + who = str(cfg.get(CONF_WHO, "6")) + where = str(cfg.get(CONF_WHERE, "")) + if who == "1": + try: + PointToPoint()(where) + except Invalid as err: + raise Invalid( + f"{device}: an impulse lock (who: 1) requires a point-to-point ; " + "a general ('0'), area, or group address would pulse multiple relays or all lights at once" + ) from err + elif who == "6": + if where in ("0", "") or where.startswith("#"): + raise Invalid( + f"{device}: a door entry lock (who: 6) cannot use general ('0') or group ('#') addressing" + ) + return data + + +lock_schema = All(lock_schema, _validate_locks) # type: ignore[assignment] + # The device schemas are Schema subclasses whose overridden __call__ performs # post-processing (rekeying to "who-where" and injecting default keys). Nested # schema instances are not guaranteed to be invoked through __call__ by the diff --git a/tests/test_access_policy.py b/tests/test_access_policy.py index 3910c0d96..ecff5a37c 100644 --- a/tests/test_access_policy.py +++ b/tests/test_access_policy.py @@ -663,3 +663,35 @@ def test_revalidate_effect_mismatch_raises(hass: HomeAssistant): with pytest.raises(AccessDenied, match="door state changed"): h.ctrl._revalidate(pending) + +async def test_pulse_exception_settles_phase_and_does_not_wedge(hass: HomeAssistant): + """Verify that an unexpected exception during pulse execution does not wedge the controller.""" + user = await _user(hass, "Eve") + h = Harness(hass, _config(user.id)) + hass.states.async_set(SENSOR, STATE_OFF) + notifs = async_mock_service(hass, "notify", "mobile_app_parent") + + async def _failing_pulse(intent, effect): + raise RuntimeError("Gateway connection dropped mid-pulse") + + h.ctrl._send_pulse = _failing_pulse + + await h.ctrl.async_request(Intent.OPEN, Context(user_id=user.id)) + assert h.ctrl.phase is Phase.PENDING + pending = h.ctrl._pending + assert pending is not None + + action = _approve_action(notifs)["action"] + await _press(hass, action, user.id) + + # The exception was caught, phase settled back to IDLE + assert h.ctrl.phase is Phase.IDLE + assert any("Execution failed" in str(c.data.get("message", "")) for c in notifs) + + # Next request is accepted cleanly, not blocked by a wedged state machine + h.ctrl._send_pulse = Harness(hass, _config(user.id)).ctrl._send_pulse + await h.ctrl.async_request(Intent.OPEN, Context(user_id=user.id)) + assert h.ctrl.phase is Phase.PENDING + + await h.ctrl.async_shutdown() + diff --git a/tests/test_component_impulse_cover.py b/tests/test_component_impulse_cover.py index 3207285c8..706d8e779 100644 --- a/tests/test_component_impulse_cover.py +++ b/tests/test_component_impulse_cover.py @@ -1,4 +1,5 @@ """Tests for MyHOME impulse cover platform (gates, garage doors, impulse relays WHO=1).""" +import asyncio import time from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch @@ -20,7 +21,11 @@ OWNEvent, OWNLightingCommand, ) -from pytest_homeassistant_custom_component.common import async_fire_time_changed, async_mock_service +from pytest_homeassistant_custom_component.common import ( + async_fire_time_changed, + async_fire_time_changed_exact, + async_mock_service, +) from custom_components.myhome.access_policy import ( AccessConfig, @@ -160,25 +165,15 @@ async def test_impulse_cover_open_and_close_cycle(hass: HomeAssistant, impulse_g # 1. Pulse open assert await impulse_gate._async_pulse(Intent.OPEN, Effect.OPEN) is True - # Relay ON command sent immediately + # Hardware-timed WHAT 18 command sent immediately (actuator shuts off after 0.5s) assert mock_gateway.send.call_count == 1 on_cmd = mock_gateway.send.call_args[0][0] assert isinstance(on_cmd, OWNLightingCommand) - assert str(on_cmd) == "*1*1*22##" + assert str(on_cmd) == "*1*18*22##" assert impulse_gate.is_opening is True assert impulse_gate.is_closed is False - # 2. Advance time by 0.5s: Auto-reset pulse turns relay OFF - future = dt_util.utcnow() + timedelta(seconds=0.6) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - - assert mock_gateway.send.call_count == 2 - off_cmd = mock_gateway.send.call_args[0][0] - assert str(off_cmd) == "*1*0*22##" - assert impulse_gate.is_opening is True - - # 3. Advance time to complete travel (10s) + # 2. Advance time to complete travel (10s) future = dt_util.utcnow() + timedelta(seconds=11.0) async_fire_time_changed(hass, future) await hass.async_block_till_done() @@ -186,23 +181,16 @@ async def test_impulse_cover_open_and_close_cycle(hass: HomeAssistant, impulse_g assert impulse_gate.is_opening is False assert impulse_gate.is_closed is False - # 4. Pulse close + # 3. Pulse close mock_gateway.send.reset_mock() impulse_gate._last_pulse_time = -9999.0 assert await impulse_gate._async_pulse(Intent.CLOSE, Effect.MAY_CLOSE) is True assert mock_gateway.send.call_count == 1 on_cmd = mock_gateway.send.call_args[0][0] - assert str(on_cmd) == "*1*1*22##" + assert str(on_cmd) == "*1*18*22##" assert impulse_gate.is_closing is True - # Auto-off pulse after 0.5s - future = dt_util.utcnow() + timedelta(seconds=0.6) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert mock_gateway.send.call_count == 2 - assert str(mock_gateway.send.call_args[0][0]) == "*1*0*22##" - # Finish closing travel (10s) future = dt_util.utcnow() + timedelta(seconds=11.0) async_fire_time_changed(hass, future) @@ -318,11 +306,7 @@ async def test_impulse_cover_state_sensor_binding(hass: HomeAssistant, mock_gate assert cover.is_opening is True assert cover.is_closed is False - # Advance 0.6s for auto-off pulse - future = dt_util.utcnow() + timedelta(seconds=0.6) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert mock_gateway.send.call_count == 2 + # Reset mock for sensor updates mock_gateway.send.reset_mock() # Sensor state updates to on (open) @@ -384,8 +368,11 @@ def test_impulse_cover_bus_event_handling(hass: HomeAssistant, impulse_gate): assert impulse_gate.is_closed is True -async def test_impulse_cover_deadband_rejections_and_cleanup(hass: HomeAssistant, impulse_gate): +async def test_impulse_cover_deadband_rejections_and_cleanup(hass: HomeAssistant, impulse_gate, mock_gateway): """Test deadband drops for close and stop, and cleanup on removal.""" + # Custom duration (1.0s) uses software off timer and removal fail-safe + impulse_gate._pulse_duration = 1.0 + # Send open pulse assert await impulse_gate._async_pulse(Intent.OPEN, Effect.OPEN) is True assert impulse_gate.is_opening is True @@ -401,9 +388,13 @@ async def test_impulse_cover_deadband_rejections_and_cleanup(hass: HomeAssistant # Call async_will_remove_from_hass with pending timers assert impulse_gate._pulse_off_cancel is not None assert impulse_gate._travel_timer_cancel is not None + mock_gateway.send.reset_mock() await impulse_gate.async_will_remove_from_hass() assert impulse_gate._pulse_off_cancel is None assert impulse_gate._travel_timer_cancel is None + # Removal while software pulse is active sends OFF to avoid stuck relay + assert mock_gateway.send.call_count == 1 + assert str(mock_gateway.send.call_args[0][0]) == "*1*0*22##" async def test_impulse_cover_additional_branches(hass: HomeAssistant, mock_gateway, impulse_gate): @@ -434,6 +425,7 @@ async def test_impulse_cover_additional_branches(hass: HomeAssistant, mock_gatew assert impulse_gate.is_closed is None # 3. Duplicate impulse cancels pending pulse-off timer (cover.py:1417-1418) + impulse_gate._pulse_duration = 1.0 impulse_gate._min_cycle_time = 0.0 await impulse_gate._async_send_impulse() assert impulse_gate._pulse_off_cancel is not None @@ -523,3 +515,86 @@ async def test_impulse_cover_build_invalid_device_class(hass: HomeAssistant, moc assert added[0].device_class == CoverDeviceClass.GATE +async def test_impulse_cover_gateway_write_failure(hass: HomeAssistant, impulse_gate, mock_gateway): + """Test that a gateway write failure returns False and does not crash or claim executed.""" + mock_gateway.send.side_effect = RuntimeError("Socket disconnected") + assert await impulse_gate._async_send_impulse() is False + + +async def test_impulse_cover_custom_pulse_duration_cycle(hass: HomeAssistant, impulse_gate, mock_gateway): + """Test custom pulse duration (e.g. 1.5s) uses confirmed ON write and timed OFF.""" + impulse_gate._pulse_duration = 1.5 + mock_gateway.send.reset_mock() + + assert await impulse_gate._async_pulse(Intent.OPEN, Effect.OPEN) is True + assert mock_gateway.send.call_count == 1 + assert str(mock_gateway.send.call_args[0][0]) == "*1*1*22##" + + # Before 1.5s, no OFF sent yet + async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=0.8)) + await hass.async_block_till_done() + assert mock_gateway.send.call_count == 1 + + # After 1.5s, OFF sent + async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=1.6)) + await hass.async_block_till_done() + assert mock_gateway.send.call_count == 2 + assert str(mock_gateway.send.call_args[0][0]) == "*1*0*22##" + + +async def test_impulse_cover_confirmed_write_future_and_off_failure(hass: HomeAssistant, impulse_gate, mock_gateway): + """Test write_fut await paths and exception handling in timed OFF and removal.""" + # 1. WHAT 18 with awaitable write_fut + + fut = asyncio.Future() + fut.set_result(0.05) + mock_gateway.send.return_value = fut + impulse_gate._pulse_duration = 0.5 + impulse_gate._last_pulse_time = 0.0 + assert await impulse_gate._async_send_impulse() is True + assert fut.done() + + # 2. Custom duration with awaitable write_fut and OFF failure handling + fut_on = asyncio.Future() + fut_on.set_result(0.05) + mock_gateway.send.side_effect = None + mock_gateway.send.return_value = fut_on + impulse_gate._pulse_duration = 1.0 + impulse_gate._last_pulse_time = 0.0 + assert await impulse_gate._async_send_impulse() is True + + # When OFF is scheduled, test success with awaitable future + fut_off_timed = asyncio.Future() + fut_off_timed.set_result(0.05) + mock_gateway.send.return_value = fut_off_timed + async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=1.2)) + await hass.async_block_till_done() + assert fut_off_timed.done() + assert impulse_gate._pulse_off_cancel is None + + # Test error handling in _send_off + impulse_gate._last_pulse_time = 0.0 + mock_gateway.send.return_value = fut_on + assert await impulse_gate._async_send_impulse() is True + mock_gateway.send.side_effect = RuntimeError("OFF send error") + async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=1.2)) + await hass.async_block_till_done() + assert impulse_gate._pulse_off_cancel is None + + + # 3. Removal fail-safe with awaitable write_fut and exception handling + fut_off = asyncio.Future() + fut_off.set_result(0.05) + mock_gateway.send.side_effect = None + mock_gateway.send.return_value = fut_off + impulse_gate._pulse_off_cancel = MagicMock() + await impulse_gate.async_will_remove_from_hass() + assert fut_off.done() + + mock_gateway.send.side_effect = RuntimeError("Removal send error") + impulse_gate._pulse_off_cancel = MagicMock() + await impulse_gate.async_will_remove_from_hass() + + + + diff --git a/tests/test_component_lock.py b/tests/test_component_lock.py index 5fee665ac..65fa6aec5 100644 --- a/tests/test_component_lock.py +++ b/tests/test_component_lock.py @@ -1,4 +1,5 @@ """Tests for MyHOME lock platform (WHO=6 door entry electric strikes).""" +import asyncio from datetime import timedelta from unittest.mock import AsyncMock, MagicMock, patch @@ -24,7 +25,10 @@ OWNDoorEntryCommand, OWNDoorEntryEvent, ) -from pytest_homeassistant_custom_component.common import async_fire_time_changed +from pytest_homeassistant_custom_component.common import ( + async_fire_time_changed, + async_fire_time_changed_exact, +) from custom_components.myhome.const import ( CONF_PLATFORMS, @@ -275,21 +279,17 @@ async def test_impulse_lock_who1_code_and_pulse(hass: HomeAssistant, mock_gatewa with pytest.raises(ServiceValidationError, match="Invalid code"): await lock.async_unlock(code="9999") - # Valid code unlocks and pulses relay + # Valid code unlocks and pulses relay with WHAT 18 hardware timed pulse await lock.async_unlock(code="1234") assert mock_gateway.send.call_count == 1 cmd = mock_gateway.send.call_args[0][0] - assert str(cmd) == "*1*1*25##" + assert str(cmd) == "*1*18*25##" assert lock.is_locked is False - # Advance 0.6s -> auto-off pulse and relock + # Advance 0.6s -> auto relock future = dt_util.utcnow() + timedelta(seconds=0.6) async_fire_time_changed(hass, future) await hass.async_block_till_done() - - assert mock_gateway.send.call_count == 2 - off_cmd = mock_gateway.send.call_args[0][0] - assert str(off_cmd) == "*1*0*25##" assert lock.is_locked is True # Handle event: *1*1*25## unlocks momentarily @@ -297,15 +297,25 @@ async def test_impulse_lock_who1_code_and_pulse(hass: HomeAssistant, mock_gatewa lock.handle_event(event_msg) assert lock.is_locked is False - # Double unlock cancels and reschedules pulse-off timer (lock.py:273-274) + # Advance 0.6s -> auto relock + future = dt_util.utcnow() + timedelta(seconds=0.6) + async_fire_time_changed(hass, future) + await hass.async_block_till_done() + assert lock.is_locked is True + + # Custom duration uses software timer + lock._pulse_duration = 1.0 await lock.async_unlock(code="1234") assert lock._pulse_off_unsub is not None await lock.async_unlock(code="1234") assert lock._pulse_off_unsub is not None - # Removal while pulse is pending cleans up (lock.py:345-346) + # Removal while pulse is pending immediately turns relay off (lock.py:345-346) + mock_gateway.send.reset_mock() await lock.async_will_remove_from_hass() assert lock._pulse_off_unsub is None + assert mock_gateway.send.call_count == 1 + assert str(mock_gateway.send.call_args[0][0]) == "*1*0*25##" # Handle event with translation flag returns early (lock.py:306) trans_msg = MagicMock(is_translation=True) @@ -360,3 +370,105 @@ async def test_lock_setup_who1_and_bus_discovery_branches(hass: HomeAssistant, m assert any(e._where == "27" for e in added) +async def test_lock_confirmed_write_future_and_off_failure(hass: HomeAssistant, mock_gateway): + """Test write_fut await paths and exception handling in timed OFF and removal.""" + # 1. WHO 6 unlock with awaitable write_fut + + with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): + lock_who6 = MyHOMELock( + hass=hass, + name="Front Door Lock", + entity_name=None, + device_id="1", + who="6", + where="1", + interface=None, + manufacturer="BTicino", + model="Door Entry Lock", + gateway=mock_gateway, + ) + lock_who6.hass = hass + lock_who6.entity_id = "lock.front_door_lock" + lock_who6.async_write_ha_state = MagicMock() + lock_who6.async_schedule_update_ha_state = MagicMock() + + fut_who6 = asyncio.Future() + fut_who6.set_result(0.05) + mock_gateway.send.return_value = fut_who6 + await lock_who6.async_unlock() + assert fut_who6.done() + lock_who6._cancel_auto_relock() + + # 2. WHO 1 lock with WHAT 18 and awaitable write_fut + with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): + lock_who1 = MyHOMELock( + hass=hass, + name="Impulse Lock", + entity_name=None, + device_id="1-25", + who="1", + where="25", + interface=None, + pulse_duration=0.5, + code=None, + manufacturer="BTicino", + model="Relay Lock", + gateway=mock_gateway, + ) + lock_who1.hass = hass + lock_who1.entity_id = "lock.impulse_lock" + lock_who1.async_write_ha_state = MagicMock() + + fut_who1 = asyncio.Future() + fut_who1.set_result(0.05) + mock_gateway.send.return_value = fut_who1 + await lock_who1.async_unlock() + assert fut_who1.done() + lock_who1._cancel_auto_relock() + + # 3. WHO 1 custom duration with awaitable write_fut and timed OFF + lock_who1._pulse_duration = 1.0 + fut_on = asyncio.Future() + fut_on.set_result(0.05) + mock_gateway.send.side_effect = None + mock_gateway.send.return_value = fut_on + await lock_who1.async_unlock() + assert fut_on.done() + assert lock_who1._pulse_off_unsub is not None + + # Advance timer to trigger _send_off + fut_off = asyncio.Future() + fut_off.set_result(0.05) + mock_gateway.send.return_value = fut_off + async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=1.2)) + await hass.async_block_till_done() + assert fut_off.done() + lock_who1._cancel_auto_relock() + + # 4. WHO 1 custom duration with OFF send failure + lock_who1._pulse_duration = 1.0 + mock_gateway.send.return_value = None + await lock_who1.async_unlock() + mock_gateway.send.side_effect = RuntimeError("OFF send error") + async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=1.2)) + await hass.async_block_till_done() + assert lock_who1._pulse_off_unsub is None + lock_who1._cancel_auto_relock() + + # 5. Removal fail-safe with awaitable write_fut and exception handling + fut_rem = asyncio.Future() + fut_rem.set_result(0.05) + mock_gateway.send.side_effect = None + mock_gateway.send.return_value = fut_rem + lock_who1._pulse_off_unsub = MagicMock() + await lock_who1.async_will_remove_from_hass() + assert fut_rem.done() + + mock_gateway.send.side_effect = RuntimeError("Removal send error") + lock_who1._pulse_off_unsub = MagicMock() + await lock_who1.async_will_remove_from_hass() + lock_who1._cancel_auto_relock() + + + + diff --git a/tests/test_validate.py b/tests/test_validate.py index f598f36e3..ef5767228 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -45,6 +45,7 @@ cover_schema, format_mac, light_schema, + lock_schema, sensor_schema, switch_schema, ) @@ -784,3 +785,98 @@ def test_device_class_remapping_and_defaults(self): "slat_time": invalid_time, } }) + + +class TestLockSchemaValidation: + """Test validation and restrictions on lock_schema.""" + + def test_who1_point_to_point_validates(self): + """WHO=1 impulse lock with point-to-point WHERE succeeds.""" + data = { + "lock1": { + CONF_WHO: "1", + CONF_WHERE: "12", + CONF_NAME: "Front Gate Strike", + "pulse_duration": 0.5, + } + } + res = lock_schema(data) + assert "1-12" in res + assert res["1-12"]["pulse_duration"] == 0.5 + + def test_who1_general_where_rejected(self): + """WHO=1 impulse lock with general WHERE 0 is rejected.""" + data = { + "lock1": { + CONF_WHO: "1", + CONF_WHERE: "0", + CONF_NAME: "Dangerous All Lights Strike", + } + } + with pytest.raises(Invalid, match="requires a point-to-point "): + lock_schema(data) + + def test_who1_group_where_rejected(self): + """WHO=1 impulse lock with group WHERE is rejected.""" + data = { + "lock1": { + CONF_WHO: "1", + CONF_WHERE: "#1", + CONF_NAME: "Group Lock", + } + } + with pytest.raises(Invalid, match="requires a point-to-point "): + lock_schema(data) + + def test_who6_validates(self): + """WHO=6 door entry lock with entrance panel WHERE succeeds.""" + data = { + "lock1": { + CONF_WHO: "6", + CONF_WHERE: "1", + CONF_NAME: "Main Entrance Panel", + } + } + res = lock_schema(data) + assert "6-1" in res + + def test_who6_general_or_group_rejected(self): + """WHO=6 door entry lock cannot use general 0 or group.""" + with pytest.raises(Invalid, match="cannot use general"): + lock_schema({ + "lock1": { + CONF_WHO: "6", + CONF_WHERE: "0", + CONF_NAME: "General Lock", + } + }) + with pytest.raises(Invalid, match="cannot use general"): + lock_schema({ + "lock1": { + CONF_WHO: "6", + CONF_WHERE: "#2", + CONF_NAME: "Group Lock", + } + }) + + def test_pulse_duration_bounds(self): + """Pulse duration must be between 0.1 and 10.0 seconds.""" + with pytest.raises(Invalid): + lock_schema({ + "lock1": { + CONF_WHO: "1", + CONF_WHERE: "12", + CONF_NAME: "Negative Pulse Lock", + "pulse_duration": -3.0, + } + }) + with pytest.raises(Invalid): + lock_schema({ + "lock1": { + CONF_WHO: "1", + CONF_WHERE: "12", + CONF_NAME: "Too Long Pulse Lock", + "pulse_duration": 15.0, + } + }) + From cc1e8012241613c46fe4c6987e97eb406c6c9207 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:38:33 +0200 Subject: [PATCH 13/26] fix(typing): replace unreachable or with inspect.isawaitable in cover and lock --- custom_components/myhome/cover.py | 17 +++++------------ custom_components/myhome/lock.py | 22 ++++++---------------- 2 files changed, 11 insertions(+), 28 deletions(-) diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index 4978f1025..7399c3d93 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -2,6 +2,7 @@ from __future__ import annotations import asyncio +import inspect import time from collections.abc import Callable, Mapping from datetime import timedelta @@ -2305,18 +2306,14 @@ async def _async_send_impulse(self) -> bool: # preventing the relay from ever staying latched if Home Assistant terminates. cmd = OWNLightingCommand.switch_on_timed(self._full_where, 18) write_fut = await self._gateway_handler.send(cmd) - if write_fut is not None and ( - asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") - ): + if inspect.isawaitable(write_fut): await write_fut return True # Custom duration: send ON, await confirmed write to the bus, then schedule OFF cmd_on = OWNLightingCommand.switch_on(self._full_where) write_fut = await self._gateway_handler.send(cmd_on) - if write_fut is not None and ( - asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") - ): + if inspect.isawaitable(write_fut): await write_fut @callback @@ -2329,9 +2326,7 @@ async def _send_off() -> None: fut = await self._gateway_handler.send( OWNLightingCommand.switch_off(self._full_where) ) - if fut is not None and ( - asyncio.isfuture(fut) or hasattr(fut, "__await__") - ): + if inspect.isawaitable(fut): await fut except Exception as err: LOGGER.error( @@ -2470,9 +2465,7 @@ async def async_will_remove_from_hass(self) -> None: write_fut = await self._gateway_handler.send( OWNLightingCommand.switch_off(self._full_where) ) - if write_fut is not None and ( - asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") - ): + if inspect.isawaitable(write_fut): await write_fut except Exception as err: LOGGER.warning( diff --git a/custom_components/myhome/lock.py b/custom_components/myhome/lock.py index a640d35e6..4ab664756 100644 --- a/custom_components/myhome/lock.py +++ b/custom_components/myhome/lock.py @@ -1,8 +1,8 @@ """Support for MyHome locks (door entry electric strikes WHO=6 and impulse locks WHO=1).""" from __future__ import annotations -import asyncio import hmac +import inspect from collections.abc import Callable from typing import Any @@ -283,16 +283,12 @@ async def async_unlock(self, **kwargs: Any) -> None: if self._pulse_duration == 0.5: cmd = OWNLightingCommand.switch_on_timed(self._full_where, 18) write_fut = await self._gateway_handler.send(cmd) - if write_fut is not None and ( - asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") - ): + if inspect.isawaitable(write_fut): await write_fut else: cmd_on = OWNLightingCommand.switch_on(self._full_where) write_fut = await self._gateway_handler.send(cmd_on) - if write_fut is not None and ( - asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") - ): + if inspect.isawaitable(write_fut): await write_fut @callback @@ -305,9 +301,7 @@ async def _send_off() -> None: fut = await self._gateway_handler.send( OWNLightingCommand.switch_off(self._full_where) ) - if fut is not None and ( - asyncio.isfuture(fut) or hasattr(fut, "__await__") - ): + if inspect.isawaitable(fut): await fut except Exception as err: LOGGER.error( @@ -326,9 +320,7 @@ async def _send_off() -> None: write_fut = await self._gateway_handler.send( OWNDoorEntryCommand.open_lock(self._full_where) ) - if write_fut is not None and ( - asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") - ): + if inspect.isawaitable(write_fut): await write_fut self._attr_is_locked = False @@ -394,9 +386,7 @@ async def async_will_remove_from_hass(self) -> None: write_fut = await self._gateway_handler.send( OWNLightingCommand.switch_off(self._full_where) ) - if write_fut is not None and ( - asyncio.isfuture(write_fut) or hasattr(write_fut, "__await__") - ): + if inspect.isawaitable(write_fut): await write_fut except Exception as err: LOGGER.warning( From 809075027c3b05f835271a2bb441855027c280fd Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 17:53:57 +0200 Subject: [PATCH 14/26] fix(test): decorate dispatcher listener with @callback to execute synchronously in event loop --- tests/test_door_entry_events.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/test_door_entry_events.py b/tests/test_door_entry_events.py index f07d65635..9a3c11e2e 100644 --- a/tests/test_door_entry_events.py +++ b/tests/test_door_entry_events.py @@ -1,7 +1,7 @@ """Tests for Door Entry (WHO=6) doorbell events and gateway handling.""" from unittest.mock import MagicMock, patch -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from OWNd.message import OWNEvent @@ -33,6 +33,7 @@ async def test_gateway_fires_doorbell_events(hass: HomeAssistant): dispatcher_payloads = [] + @callback def handle_dispatcher(payload): dispatcher_payloads.append(payload) From 8835bd82ead0ed637269fc3fd17ab0acd7013bcd Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 23:01:31 +0200 Subject: [PATCH 15/26] fix(cover,lock,doorbell): address the PR 667 audit - cover: pulse falls back to ON + software OFF when OWNd lacks OWNLightingCommand.switch_on_timed (released 2.0.0b10); the deadband starts only after the gateway confirmed the write; wall button log and docs say the button has already started the gate - lock: WHO 6 only, no WHO 1 locks (they bypassed the cover access policy), never created from bus traffic, riser addresses (4001#2) validate - doorbell: WHO 8 rings only for entrance panel kinds 1-4 (not handset calls or pagers) and only on the gateway that owns the subsystem - access: a PIN reply without text is no failed attempt and cannot lock out - validate: type: impulse_relay needs who: 1 (and shutter/standard needs who: 2), drop duplicate schema keys - docs: correct config keys, access: example, lock example, chime is unverified Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- custom_components/myhome/access_policy.py | 10 +- custom_components/myhome/cover.py | 35 ++- custom_components/myhome/gateway_events.py | 35 ++- custom_components/myhome/lock.py | 198 +++------------- custom_components/myhome/validate.py | 53 ++--- docs/configuration/covers.md | 69 +++++- docs/configuration/supported_functions.md | 6 +- scripts/update_supported_domains.py | 4 +- tests/test_access_policy.py | 23 ++ tests/test_component_impulse_cover.py | 76 ++++++- tests/test_component_lock.py | 251 ++++----------------- tests/test_door_entry_events.py | 75 +++++- tests/test_supported_domains.py | 2 +- tests/test_validate.py | 110 ++++----- 15 files changed, 467 insertions(+), 482 deletions(-) diff --git a/README.md b/README.md index f35e4d3c7..09da7e5f4 100644 --- a/README.md +++ b/README.md @@ -123,7 +123,7 @@ We now maintain a comprehensive, community-curated **[GitHub Wiki](https://githu | **`light`** | WHO=1 | On/Off, Dimmers with brightness control & transitions (stepped & native), DALI DT8 Tunable White (Dimension 14, 2000K–6535K), HS/RGB colour, Hardware-offloaded bus timers (`myhome.turn_on_timed` / `timer` parameter) | | **`switch`** | WHO=1 | Relays, auxiliary switches, socket actuators (switch/outlet device classes), Hardware-offloaded bus timers (`myhome.turn_on_timed` / `timer` parameter) | | **`cover`** | WHO=1 / WHO=2 | Motorized shutters, blinds, roll-ups with state tracking, position-reporting actuators & virtual travel-time positioning; impulse gates & garage doors (`WHO=1`) with AccessController multi-factor safety approval | -| **`lock`** | WHO=1 / WHO=6 | Momentary electric door strikes (`WHO=1`) with constant-time PIN authentication and auto-relock; native video door entry strikes (`WHO=6`) | +| **`lock`** | WHO=6 | Native video door entry strikes (`WHO=6`, configured in `myhome.yaml`) with constant-time PIN authentication and auto-relock | | **`climate`** | WHO=4 | Heating, cooling, 4-pipe systems, thermostats, setpoints, fancoil 3-speed modes, offset tracking, Central Unit 3550 (`#0`) & 4695 (`#0#1`) master coordination & seasonal propagation | | **`alarm_control_panel`** | WHO=5 | Central units (3485/3486), read-only state (disarmed / armed away / triggered), zone 0 broadcast sync; arm/disarm via AUX frames ([docs](docs/configuration/alarm.md)) | | **`binary_sensor`** | WHO=1 / 9 / 25 | Magnetic contacts, door/window sensors, PIR motion, AUX channels (1–9), dry contacts (F482/3477), inverted contacts | diff --git a/custom_components/myhome/access_policy.py b/custom_components/myhome/access_policy.py index a84659791..fb3f264e7 100644 --- a/custom_components/myhome/access_policy.py +++ b/custom_components/myhome/access_policy.py @@ -504,7 +504,15 @@ def _on_action(self, event: Event) -> None: return if self.config.pin_code is not None: reply = event.data.get("reply_text", event.data.get("textInput")) - if not _same(str(reply) if reply is not None else None, self.config.pin_code): + if reply is None: + # The app returned no text at all (no input shown, or a client that + # ignores `behavior: textInput`): nothing was guessed, so it is no failed + # attempt and cannot lock the approver out. Nothing moves either way. + self._settle() + self._spawn(self._notify(pending.notify_service, f"{self._name}: no PIN received, nothing moved")) + LOGGER.warning("%s: approval carried no PIN text; the notification app did not offer text input", self._name) + return + if not _same(str(reply), self.config.pin_code): self._register_failed_pin(pending.user_id) self._settle() self._spawn(self._notify(pending.notify_service, f"{self._name}: wrong PIN, nothing moved")) diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index 7399c3d93..c45c8be62 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -2220,6 +2220,7 @@ def __init__( self._attr_assumed_state = state_sensor is None self._last_pulse_time: float = -1e9 + self._pulse_in_flight = False self._pulse_off_cancel: CALLBACK_TYPE | None = None self._travel_timer_cancel: CALLBACK_TYPE | None = None @@ -2252,6 +2253,12 @@ async def async_added_to_hass(self) -> None: """Register listeners when added to Home Assistant.""" await super().async_added_to_hass() self._access.hass = self.hass + if not self._access.config.allowed_users: + LOGGER.warning( + "%s: no `access:` block with allowed_users and approvers, so every open, close and stop " + "request is refused. See the Impulse Covers section of the covers documentation.", + self._display_name, + ) if self._state_sensor and self.hass is not None: @callback def _sensor_state_changed(event: Any) -> None: @@ -2286,28 +2293,33 @@ def _cancel_travel_timer(self) -> None: async def _async_send_impulse(self) -> bool: """Send the momentary pulse with confirmed delivery to the bus.""" now = time.monotonic() - if now - self._last_pulse_time < self._min_cycle_time: + if self._pulse_in_flight or now - self._last_pulse_time < self._min_cycle_time: LOGGER.warning( "%s: Command dropped (within %.1fs deadband lockout)", self._display_name, self._min_cycle_time, ) return False - self._last_pulse_time = now if self._pulse_off_cancel is not None: self._pulse_off_cancel() self._pulse_off_cancel = None + # The deadband starts only once the gateway confirmed the write: a failed send + # must not lock the user out for min_cycle_time with nothing on the bus. The + # in-flight flag keeps a second request and our own echo out meanwhile. + self._pulse_in_flight = True try: - if self._pulse_duration == 0.5: + if self._pulse_duration == 0.5 and hasattr(OWNLightingCommand, "switch_on_timed"): # Use WHAT 18: Timed ON for 0.5 seconds (official WHO 1 specification). # The physical actuator automatically turns off after 0.5s in hardware, # preventing the relay from ever staying latched if Home Assistant terminates. + # (OWNd releases up to 2.0.0b10 lack the builder: they take the ON/OFF path.) cmd = OWNLightingCommand.switch_on_timed(self._full_where, 18) write_fut = await self._gateway_handler.send(cmd) if inspect.isawaitable(write_fut): await write_fut + self._last_pulse_time = time.monotonic() return True # Custom duration: send ON, await confirmed write to the bus, then schedule OFF @@ -2315,6 +2327,7 @@ async def _async_send_impulse(self) -> bool: write_fut = await self._gateway_handler.send(cmd_on) if inspect.isawaitable(write_fut): await write_fut + self._last_pulse_time = time.monotonic() @callback def _turn_off(_now: Any = None) -> None: @@ -2349,6 +2362,8 @@ async def _send_off() -> None: err, ) return False + finally: + self._pulse_in_flight = False async def _async_pulse(self, intent: Intent, effect: Effect) -> bool: """Pulse the relay for an approved request and show the expected motion. @@ -2414,12 +2429,20 @@ def handle_event(self, message: Any) -> None: if not is_on: return # Ignore *1*0*... release frames (such as the 5-minute delayed hardware timer) - # Echo window: ignore frames arriving within 1.0s of our own command - if time.monotonic() - self._last_pulse_time < 1.0: + # Echo window: ignore frames arriving while our own command is on its way + # or within 1.0s after it + if self._pulse_in_flight or time.monotonic() - self._last_pulse_time < 1.0: return + # The button has already closed the relay and started the motor: pressing it + # never aborts anything. Only Home Assistant's own pending request or + # pre-warning is dropped, so no second pulse follows and stops or reverses it. if self._access.cancel("wall button pressed"): - LOGGER.info("%s: wall button pressed; pending request or warning cancelled", self._display_name) + LOGGER.info( + "%s: wall button pressed - the gate/door has already been actuated; " + "pending request or pre-warning cancelled so no second pulse follows", + self._display_name, + ) if self._state_sensor is None: if self._attr_is_closed or self._attr_is_closing: diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index efa0ed4ca..aa9521333 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -58,6 +58,9 @@ class OWNDoorEntryEvent: # type: ignore[no-redef] if TYPE_CHECKING: from .gateway import MyHOMEGatewayHandler +# WHO 8 call kinds of the entrance panels PE1..PE4 (6: handset to handset, 14: pager). +ENTRANCE_PANEL_KINDS = frozenset({1, 2, 3, 4}) + class GatewayEventDispatcher: """Dispatches bus and integration events from gateway monitor frames.""" @@ -191,6 +194,18 @@ def ensure_cen_device( _ensure_cen_device = ensure_cen_device + @staticmethod + def _intercom_call_kind(message: OWNMessage) -> int | None: + """Call kind of a WHO 8 call (`*8*1##*##`), or None when unreadable.""" + kind = getattr(message, "call_kind", None) + if kind is None: + params = getattr(message, "_what_param", None) or [] + kind = params[0] if params else None + try: + return int(kind) if kind is not None else None + except (TypeError, ValueError): + return None + def _is_active_for_who(self, who: int | None) -> bool: """Return True if this gateway is the active owner for this WHO subsystem.""" if getattr(self.handler, "is_standby", False): @@ -581,12 +596,24 @@ async def process_message(self, message: Any) -> None: who = getattr(message, "who", None) what = getattr(message, "_what", None) where_val = getattr(message, "where", "") - is_who8_call = who == 8 and what == 1 + # WHO 8 WHAT 1 starts any call session. Only the entrance panels ring the + # door; handset-to-handset calls (kind 6) and pager broadcasts (kind 14) + # must not, and a kind that cannot be read fails closed. Older OWNd + # releases flag every WHAT 1 as an incoming call, so WHO 8 never trusts + # the library's is_call / is_incoming_call. + is_who8_call = who == 8 and what == 1 and self._intercom_call_kind(message) in ENTRANCE_PANEL_KINDS is_broadcast = getattr(message, "is_broadcast_call", who == 6 and what == 6 and str(where_val) == "4100") + # WHO 6 WHAT 20 is not in the published WHO 6 table (WHAT 0, 6, 9, 10, 11, 12, + # 18, 22) and no bus capture of it exists in this repository: unverified. is_chime = getattr(message, "is_chime", who == 6 and what == 20) - is_incoming = getattr(message, "is_incoming_call", (who == 6 and what == 6) or is_who8_call) - is_call = getattr(message, "is_call", is_incoming or is_chime or is_who8_call) - if is_call: + if who == 8: + is_call = is_who8_call + else: + is_incoming = getattr(message, "is_incoming_call", who == 6 and what == 6) + is_call = getattr(message, "is_call", is_incoming or is_chime) + # On a shared bus every gateway sees the same frame; only the active owner + # of the subsystem may announce it or each ring fires once per gateway. + if is_call and self._is_active_for_who(who): event_name = ( "broadcast_call" if is_broadcast diff --git a/custom_components/myhome/lock.py b/custom_components/myhome/lock.py index 4ab664756..4be0b44ca 100644 --- a/custom_components/myhome/lock.py +++ b/custom_components/myhome/lock.py @@ -1,9 +1,8 @@ -"""Support for MyHome locks (door entry electric strikes WHO=6 and impulse locks WHO=1).""" +"""Support for MyHome locks (door entry electric strikes, WHO=6).""" from __future__ import annotations import hmac import inspect -from collections.abc import Callable from typing import Any from homeassistant.components.lock import ( # type: ignore[attr-defined, unused-ignore] @@ -22,16 +21,10 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later -from OWNd.message import ( - OWNCommand, - OWNLightingCommand, -) +from OWNd.message import OWNCommand try: - from OWNd.message import ( - OWNDoorEntryCommand, - OWNDoorEntryEvent, - ) + from OWNd.message import OWNDoorEntryCommand except ImportError: # pragma: no cover - fallback on released OWNd 2.0.0b10 class OWNDoorEntryCommand: # type: ignore[no-redef] """Fallback stub when running on released OWNd lacking WHO 6 commands.""" @@ -42,17 +35,12 @@ def open_lock(cls, where: str | int = 0) -> Any: parsed = OWNCommand.parse(frame) return parsed if parsed is not None else OWNCommand(frame) - class OWNDoorEntryEvent: # type: ignore[no-redef] - """Fallback stub when running on released OWNd lacking WHO 6 events.""" - pass - from .const import ( CONF_DEVICE_MODEL, CONF_ENTITY_NAME, CONF_MANUFACTURER, CONF_PULSE_DURATION, CONF_WHO, - DEFAULT_PULSE_DURATION, LOGGER, ) from .data import get_runtime_data @@ -76,7 +64,7 @@ async def async_setup_entry( config_entry: ConfigEntry, async_add_entities: AddEntitiesCallback, ) -> bool: - """Set up the locks of a gateway: registry entries, then myhome.yaml, then bus discovery.""" + """Set up the locks of a gateway: registry entries, then myhome.yaml.""" runtime = get_runtime_data(config_entry) if runtime is None or PLATFORM not in runtime.platforms: return True @@ -84,16 +72,13 @@ async def async_setup_entry( mac = config_entry.data[CONF_MAC] gateway = runtime.gateway - def lock_registry_address(target_who: str) -> Callable[[er.RegistryEntry], Address | None]: - def address_of(entry: er.RegistryEntry) -> Address | None: - who, device_id = parse_unique_id(entry.unique_id or "", gateway.mac, mac) - entry_who = who if who is not None else "6" - if entry_who != target_who: - return None - return Address.from_device_id(device_id) - return address_of + def registry_address(entry: er.RegistryEntry) -> Address | None: + who, device_id = parse_unique_id(entry.unique_id or "", gateway.mac, mac) + if (who if who is not None else "6") != "6": + return None + return Address.from_device_id(device_id) - def build_who6(ctx: DeviceContext) -> MyHOMELock: + def build(ctx: DeviceContext) -> MyHOMELock: cfg = ctx.cfg name_val = cfg.get(CONF_NAME) name = str(name_val) if name_val else f"Lock {ctx.suffix}" @@ -101,39 +86,8 @@ def build_who6(ctx: DeviceContext) -> MyHOMELock: entity_name = str(raw_entity_name) if raw_entity_name is not None else None manufacturer = str(cfg.get(CONF_MANUFACTURER, "BTicino")) model = str(cfg.get(CONF_DEVICE_MODEL, "Door Entry Lock")) - code = ( - str(cfg[CONF_CODE]) - if CONF_CODE in cfg - else (str(cfg["code"]) if "code" in cfg else None) - ) - pulse = float( - cfg.get(CONF_PULSE_DURATION, cfg.get("pulse_duration", DEFAULT_LOCK_DURATION)) - ) - return MyHOMELock( - hass=hass, - name=name, - entity_name=entity_name, - device_id=ctx.key, - who=ctx.who, - where=ctx.address.where, - interface=ctx.address.interface, - code=code, - pulse_duration=pulse, - manufacturer=manufacturer, - model=model, - gateway=runtime.gateway, - ) - - def build_who1(ctx: DeviceContext) -> MyHOMELock: - cfg = ctx.cfg - name_val = cfg.get(CONF_NAME) - name = str(name_val) if name_val else f"Impulse Lock {ctx.suffix}" - raw_entity_name = cfg.get(CONF_ENTITY_NAME) - entity_name = str(raw_entity_name) if raw_entity_name is not None else None - manufacturer = str(cfg.get(CONF_MANUFACTURER, "BTicino")) - model = str(cfg.get(CONF_DEVICE_MODEL, "Impulse Relay Lock")) - code = str(cfg[CONF_CODE]) if CONF_CODE in cfg else (str(cfg["code"]) if "code" in cfg else None) - pulse = float(cfg.get(CONF_PULSE_DURATION, cfg.get("pulse_duration", DEFAULT_PULSE_DURATION))) + code = str(cfg[CONF_CODE]) if CONF_CODE in cfg else None + pulse = float(cfg.get(CONF_PULSE_DURATION, DEFAULT_LOCK_DURATION)) return MyHOMELock( hass=hass, name=name, @@ -149,49 +103,27 @@ def build_who1(ctx: DeviceContext) -> MyHOMELock: gateway=runtime.gateway, ) - def accept_who6(ctx: DeviceContext) -> bool: + def accept(ctx: DeviceContext) -> bool: if ctx.address.where in ("4100", "0", ""): return False if ctx.source == "yaml": return str(ctx.cfg.get(CONF_WHO, "6")) == "6" - if ctx.source == "bus": - msg = ctx.message - return bool( - getattr(msg, "is_lock_open", False) - or (getattr(msg, "who", None) == 6 and getattr(msg, "_what", None) in (10, 22)) - ) - return True - - def accept_who1(ctx: DeviceContext) -> bool: - if ctx.source == "yaml": - return str(ctx.cfg.get(CONF_WHO, "6")) == "1" return True + # Locks come only from myhome.yaml and the entity registry, never from bus traffic: + # a lock release seen on the bus would otherwise add a code-less entity that anyone + # with dashboard access can unlock. PlatformDiscovery( hass, config_entry, async_add_entities, platform=PLATFORM, who="6", - event_type=OWNDoorEntryEvent if getattr(OWNDoorEntryEvent, "__module__", "") != __name__ else None, - build=build_who6, - announce=True, - registry_address=lock_registry_address("6"), - accept=accept_who6, - known_keys=lambda ctx: [*default_known_keys(ctx), ctx.address.where, ctx.address.clean_where], - ).start(listen=True) - - PlatformDiscovery( - hass, - config_entry, - async_add_entities, - platform=PLATFORM, - who="1", event_type=None, - build=build_who1, + build=build, announce=True, - registry_address=lock_registry_address("1"), - accept=accept_who1, + registry_address=registry_address, + accept=accept, known_keys=lambda ctx: [*default_known_keys(ctx), ctx.address.where, ctx.address.clean_where], ).start(listen=False) @@ -212,7 +144,7 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> class MyHOMELock(MyHOMEEntity, LockEntity): - """Representation of a MyHOME door entry electric lock strike (WHO=6) or impulse lock (WHO=1).""" + """Representation of a MyHOME door entry electric lock strike (WHO=6).""" _poll_on_add = False _attr_supported_features = LockEntityFeature.OPEN @@ -253,7 +185,6 @@ def __init__( self._attr_code_format = r"^\d+$" self._attr_is_locked = True self._relock_unsub: CALLBACK_TYPE | None = None - self._pulse_off_unsub: CALLBACK_TYPE | None = None self._attr_extra_state_attributes = { "where": self._where, @@ -275,53 +206,11 @@ async def async_unlock(self, **kwargs: Any) -> None: if code is None or not hmac.compare_digest(str(code), str(self._code)): raise ServiceValidationError(f"Invalid code for {self._display_name}") - if self._who == "1": - if self._pulse_off_unsub is not None: - self._pulse_off_unsub() - self._pulse_off_unsub = None - - if self._pulse_duration == 0.5: - cmd = OWNLightingCommand.switch_on_timed(self._full_where, 18) - write_fut = await self._gateway_handler.send(cmd) - if inspect.isawaitable(write_fut): - await write_fut - else: - cmd_on = OWNLightingCommand.switch_on(self._full_where) - write_fut = await self._gateway_handler.send(cmd_on) - if inspect.isawaitable(write_fut): - await write_fut - - @callback - def _turn_off(_now: Any = None) -> None: - self._pulse_off_unsub = None - if self.hass is not None: - - async def _send_off() -> None: - try: - fut = await self._gateway_handler.send( - OWNLightingCommand.switch_off(self._full_where) - ) - if inspect.isawaitable(fut): - await fut - except Exception as err: - LOGGER.error( - "%s: Failed to send OFF pulse to gateway: %s", - self._display_name, - err, - ) - - self.hass.async_create_task(_send_off()) - - if self.hass is not None: - self._pulse_off_unsub = async_call_later( - self.hass, self._pulse_duration, _turn_off - ) - else: - write_fut = await self._gateway_handler.send( - OWNDoorEntryCommand.open_lock(self._full_where) - ) - if inspect.isawaitable(write_fut): - await write_fut + write_fut = await self._gateway_handler.send( + OWNDoorEntryCommand.open_lock(self._full_where) + ) + if inspect.isawaitable(write_fut): + await write_fut self._attr_is_locked = False self.async_write_ha_state() @@ -341,20 +230,13 @@ def handle_event(self, message: Any) -> None: ) if getattr(message, "is_translation", None) is True: return - if self._who == "1": - is_on = getattr(message, "is_on", False) or getattr(message, "_what", None) == 1 - if is_on: - self._attr_is_locked = False - self._publish_state() - self._schedule_auto_relock() - else: - is_lock_open = getattr(message, "is_lock_open", False) or ( - getattr(message, "who", None) == 6 and getattr(message, "_what", None) in (10, 22) - ) - if is_lock_open: - self._attr_is_locked = False - self._publish_state() - self._schedule_auto_relock() + is_lock_open = getattr(message, "is_lock_open", False) or ( + getattr(message, "who", None) == 6 and getattr(message, "_what", None) in (10, 22) + ) + if is_lock_open: + self._attr_is_locked = False + self._publish_state() + self._schedule_auto_relock() @callback def _schedule_auto_relock(self) -> None: @@ -378,20 +260,4 @@ def _async_auto_relock(self, _now: Any = None) -> None: async def async_will_remove_from_hass(self) -> None: self._cancel_auto_relock() - if self._pulse_off_unsub is not None: - self._pulse_off_unsub() - self._pulse_off_unsub = None - if self._who == "1": - try: - write_fut = await self._gateway_handler.send( - OWNLightingCommand.switch_off(self._full_where) - ) - if inspect.isawaitable(write_fut): - await write_fut - except Exception as err: - LOGGER.warning( - "%s: Failed to send OFF pulse to gateway on removal: %s", - self._display_name, - err, - ) await super().async_will_remove_from_hass() diff --git a/custom_components/myhome/validate.py b/custom_components/myhome/validate.py index af73db531..e8cf575e4 100644 --- a/custom_components/myhome/validate.py +++ b/custom_components/myhome/validate.py @@ -501,15 +501,10 @@ def _validate_access(access: dict[str, typing.Any]) -> dict[str, typing.Any]: ] ), Optional(CONF_TYPE): In([TYPE_IMPULSE_RELAY, "shutter", "standard"]), - Optional("type"): In([TYPE_IMPULSE_RELAY, "shutter", "standard"]), Optional(CONF_PIN_CODE): Coerce(str), - Optional("pin_code"): Coerce(str), Optional(CONF_STATE_SENSOR): cv.entity_id, - Optional("state_sensor"): cv.entity_id, Optional(CONF_PULSE_DURATION, default=0.5): All(Coerce(float), Range(min=0.2, max=2.0)), - Optional("pulse_duration", default=0.5): All(Coerce(float), Range(min=0.2, max=2.0)), Optional(CONF_MIN_CYCLE_TIME, default=5.0): All(Coerce(float), Range(min=1.0, max=120.0)), - Optional("min_cycle_time", default=5.0): All(Coerce(float), Range(min=1.0, max=120.0)), Optional(CONF_ACCESS): access_schema, Optional(CONF_ADVANCED_SHUTTER, default=False): Boolean(), Optional("advanced_shutter", default=False): Boolean(), @@ -550,6 +545,11 @@ def _validate_impulse_covers(data: dict[str, typing.Any]) -> dict[str, typing.An """An impulse cover pulses exactly one relay; access settings only apply to impulse covers.""" for device, cfg in data.items(): is_impulse = str(cfg.get(CONF_WHO)) == "1" + cover_type = cfg.get(CONF_TYPE) + if cover_type == TYPE_IMPULSE_RELAY and not is_impulse: + raise Invalid(f"{device}: type: {TYPE_IMPULSE_RELAY} needs who: 1 (a WHO 2 shutter has no impulse relay)") + if is_impulse and cover_type in ("shutter", "standard"): + raise Invalid(f"{device}: who: 1 is an impulse relay; type: {cover_type} only applies to who: 2") if is_impulse: try: PointToPoint()(str(cfg[CONF_WHERE])) @@ -687,22 +687,35 @@ def _validate_impulse_covers(data: dict[str, typing.Any]) -> dict[str, typing.An } ) +class DoorEntryWhere: + """WHERE of a WHO 6 door entry endpoint: digits, with ``#2`` appended on a riser installation.""" + + def __init__(self, msg: str | None = None) -> None: + self.msg = msg + + def __call__(self, v: typing.Any) -> typing.Any: + if isinstance(v, str) and re.fullmatch(r"\d{1,4}(#2)?", v): + return v + raise Invalid(f"Invalid door entry WHERE {v}, it must be 1-4 digits, optionally followed by #2 (riser).") + + def __repr__(self) -> str: + return "DoorEntryWhere(msg=%r)" % self.msg + + lock_schema = MyHomeDeviceSchema( { Required(str): { - Optional(CONF_WHO, default="6"): In(["1", "6"]), - Required(CONF_WHERE): All(Coerce(str), Any(General(), Area(), Group(), PointToPoint(), SpecialWhere())), # type: ignore + # Only door entry strikes. A WHO 1 relay can be a motorized gate, and a lock + # would let it bypass the cover's access policy, so there is no `who: 1` here. + Optional(CONF_WHO, default="6"): "6", + Required(CONF_WHERE): All(Coerce(str), DoorEntryWhere()), Optional(CONF_BUS_INTERFACE): All(Coerce(str), BusInterface()), # type: ignore Required(CONF_NAME): str, Optional(CONF_ENTITY_NAME): str, Optional(CONF_CODE): Coerce(str), - Optional("code"): Coerce(str), Optional(CONF_PULSE_DURATION, default=0.5): All( Coerce(float), Range(min=0.1, max=10.0) ), - Optional("pulse_duration", default=0.5): All( - Coerce(float), Range(min=0.1, max=10.0) - ), Optional(CONF_MANUFACTURER, default="BTicino S.p.A."): str, Optional(CONF_DEVICE_MODEL, default="Door Entry Lock"): Coerce(str), } @@ -711,23 +724,11 @@ def _validate_impulse_covers(data: dict[str, typing.Any]) -> dict[str, typing.An def _validate_locks(data: dict[str, typing.Any]) -> dict[str, typing.Any]: - """An impulse lock pulses exactly one relay (point-to-point); door entry locks require valid entrance addresses.""" + """A door entry lock needs a single entrance address, never general, area or broadcast.""" for device, cfg in data.items(): - who = str(cfg.get(CONF_WHO, "6")) where = str(cfg.get(CONF_WHERE, "")) - if who == "1": - try: - PointToPoint()(where) - except Invalid as err: - raise Invalid( - f"{device}: an impulse lock (who: 1) requires a point-to-point ; " - "a general ('0'), area, or group address would pulse multiple relays or all lights at once" - ) from err - elif who == "6": - if where in ("0", "") or where.startswith("#"): - raise Invalid( - f"{device}: a door entry lock (who: 6) cannot use general ('0') or group ('#') addressing" - ) + if where in ("0", "4100"): + raise Invalid(f"{device}: a door entry lock cannot use general ('0') or broadcast ('4100') addressing") return data diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index eed8a6fbf..0156f36d3 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -397,13 +397,76 @@ Because monostable relays only send a momentary pulse (`open` / `stop` / `close` 3. **Biometric Phone Unlock Requirement**: Actionable push notifications to the Companion App enforce `authenticationRequired: true` (iOS FaceID/TouchID, Android biometrics/screen lock). The phone must be actively unlocked by the authorized approver. 4. **Fail-Closed Sensor Direction Checks**: - A pulse is classified as a pure **OPEN** *only* when the ground-truth state sensor (`closed_sensor_entity_id`) proves the door is closed. In all other states, the pulse is classified as **MAY_CLOSE**, requiring: + A pulse is classified as a pure **OPEN** *only* when the ground-truth state sensor (`state_sensor`, a binary sensor that is `off` when the door is closed and `on` otherwise) proves the door is closed. In all other states, the pulse is classified as **MAY_CLOSE**, requiring: - Verified active safety devices within `safety_check_days`. - The user to be on site (`person` entity reports `home`) or visual confirmation via a camera snapshot. - Any external close-block switch to report `off`. - Any missing, unavailable, or non-binary sensor state immediately fails closed. 5. **Audible / Visual Pre-Warning & Watchdog**: - Before a close pulse, an optional pre-warning flasher (`prewarn_light`) triggers for `prewarn_seconds`. If a physical wall button is pressed during this countdown, the movement is aborted. After the pulse, an anti-stuck watchdog monitors motion: if the expected state is not reached within `travel_time + watchdog_margin`, a latching fault is asserted with **zero automatic retries**. + Before a close pulse, an optional pre-warning flasher (`prewarn_light`) triggers for `prewarn_seconds`. After the pulse, an anti-stuck watchdog monitors motion: if the expected state is not reached within `travel_time + watchdog_margin`, a latching fault is asserted with **zero automatic retries**. +6. **A Wall Button Starts the Gate, It Does Not Stop It**: + A press of the wall button (or any other keypad on the bus) closes the relay and energizes the motor by itself; Home Assistant only sees the resulting `*1*1*WHERE##` frame afterwards. When such a frame arrives during an approval request or the pre-warning countdown, Home Assistant therefore *drops its own pending request* so that no second pulse follows and stops or reverses the gate. The movement the button started is not aborted. Use the hardware's own stop input or the remote control for an emergency stop. + +### ⚙️ Configuring an Impulse Cover + +An impulse cover needs `who: 1`, a single point-to-point `where` and, to be of any use, an `access:` block. **Without `access:` (or with an empty `allowed_users`) every open, close and stop request is refused** with `AccessDenied` and a warning is logged at start-up: the policy is fail-closed. + +```yaml +# /config/myhome.yaml +00:03:50:XX:XX:XX: + cover: + garage_door: + who: 1 + type: impulse_relay # optional; only valid together with who: 1 + where: "71" # one relay, never a general, area or group address + name: Garage door + device_class: garage # garage or gate (default gate) + state_sensor: binary_sensor.garage_door_closed # off = closed, on = not closed + travel_time: 25 # seconds, for the dashboard estimate and the watchdog + pulse_duration: 0.5 # seconds the relay is closed (0.2 - 2.0) + min_cycle_time: 5 # seconds between two pulses (1 - 120) + pin_code: "1234" # optional second factor, asked on the phone + access: + allowed_users: # Home Assistant user ids + - 1a2b3c4d5e6f47a8b9c0d1e2f3a4b5c6 + approvers: # one notify service per allowed user + 1a2b3c4d5e6f47a8b9c0d1e2f3a4b5c6: notify.mobile_app_phone + remote_close: at_home # never (default), at_home or with_camera + camera: camera.driveway # needed for remote_close: with_camera + safety_devices_verified: 2026-09-01 # date you last tested photocells and force limiter + safety_check_days: 31 + prewarn_light: light.driveway_flasher + prewarn_seconds: 5 + close_block_entity: input_boolean.gate_close_block + watchdog_margin: 10 # seconds added to travel_time before a fault is latched + approval_timeout: 60 +``` + +| Key | Meaning | +| :--- | :--- | +| `state_sensor` | Binary sensor of the ground-truth position. Without it every pulse is treated as one that can close the door. | +| `pulse_duration` | Relay pulse in seconds. `0.5` uses the timed WHAT 18 command where the installed OWNd has it, and ON followed by OFF otherwise. | +| `min_cycle_time` | Deadband after a pulse that reached the bus. A failed send does not start it. | +| `pin_code` | PIN asked in the notification. A reply that carries no text is refused without counting as a wrong attempt. | +| `access.*` | See the flow above: `allowed_users`, `approvers`, `remote_close`, `camera`, `safety_devices_verified`, `safety_check_days`, `prewarn_light`, `prewarn_seconds`, `close_block_entity`, `watchdog_margin`, `approval_timeout`. | + +> [!NOTE] +> `type: impulse_relay` together with `who: 2` (and `type: shutter` or `standard` together with `who: 1`) is rejected when the configuration is validated; it used to fall back to an ordinary shutter silently. + +### 🔒 Configuring a Door Strike Lock + +```yaml +00:03:50:XX:XX:XX: + lock: + front_door: + who: 6 # the only value; WHO 1 relays are covers + where: "4001" # 4000-4095 style endpoints; append #2 on a riser installation: "4001#2" + name: Front door + code: "1234" # optional; asked on every unlock + pulse_duration: 0.5 # seconds until the lock shows locked again +``` + +Locks exist only when they are configured here (or were created earlier and are restored from the registry). A lock release seen on the bus never creates a lock entity, because such an entity would carry no code. ### ⚠️ Strict Distinction: Covers vs. Locks @@ -430,7 +493,7 @@ Because monostable relays only send a momentary pulse (`open` / `stop` / `close` | Physical Device | Required Entity Platform | Why | | :--- | :--- | :--- | | **Motorized Gates & Garage Doors** | `cover` (`type: impulse_relay`) | Heavy kinetic machinery with kinetic entrapment/crush risk. Must use `AccessController` with sensor validation and pre-warning. **Never configure a motorized gate or garage as a `lock`**. | -| **Pedestrian Door Strikes** | `lock` (`platform: lock`) | Low-mass momentary electric door buzzers (elettroserrature) that release a pedestrian latch for 1–3 seconds. | +| **Pedestrian Door Strikes** | `lock:` block, `who: 6` only | Low-mass momentary electric door buzzers (elettroserrature) on a WHO 6 entrance panel that release a pedestrian latch. A `lock` cannot be a WHO 1 relay: that would bypass the access policy of a gate, so `who: 1` is rejected there. | --- diff --git a/docs/configuration/supported_functions.md b/docs/configuration/supported_functions.md index e628b2882..afc8a45d8 100644 --- a/docs/configuration/supported_functions.md +++ b/docs/configuration/supported_functions.md @@ -57,12 +57,12 @@ Legend: ✅ supported · 👁️ read-only · ⚙️ via a service, not an entit | Tilt | ❌ | Slat commands are parsed by OWNd but not exposed. | | Echo suppression | ✅ | The gateway's relay of our own command is not mistaken for a keypad press. | -### `lock` (WHO 1 / WHO 6) +### `lock` (WHO 6) | Function | Status | Notes | | :--- | :---: | :--- | -| Impulse lock (WHO 1) | ✅ | Momentary electric strikes (door buzzers) with optional constant-time PIN authentication and auto-relock. (Never use lock for motorized gates or garage doors). | -| Door entry lock (WHO 6) | ✅ | Door entry strikes and doorbell call event triggers (WHO 6 / WHO 8). | +| Door entry lock (WHO 6) | ✅ | Door entry strikes from `myhome.yaml` with optional constant-time PIN authentication and auto-relock. Never created from bus traffic; `who: 1` relays are rejected (motorized gates and garage doors are impulse covers). | +| Doorbell event (WHO 6 / WHO 8) | ✅ | `myhome_doorbell_event` for an entrance panel ringing (WHO 8 call kinds 1-4, WHO 6 call), not for handset-to-handset calls or pager broadcasts. Fired by the gateway that owns the subsystem only. The WHO 6 chime (WHAT 20) is unverified: it is not in the published WHO 6 table and has no capture. | ### `climate` (WHO 4) diff --git a/scripts/update_supported_domains.py b/scripts/update_supported_domains.py index 0e21e553a..3ab91c74c 100644 --- a/scripts/update_supported_domains.py +++ b/scripts/update_supported_domains.py @@ -70,8 +70,8 @@ "lock": { "label": "**`lock`**", "capabilities": ( - "Momentary electric door strikes (`WHO=1`) with constant-time PIN authentication and auto-relock; " - "native video door entry strikes (`WHO=6`)" + "Native video door entry strikes (`WHO=6`, configured in `myhome.yaml`) with constant-time PIN " + "authentication and auto-relock" ), }, "climate": { diff --git a/tests/test_access_policy.py b/tests/test_access_policy.py index ecff5a37c..04d3d5df2 100644 --- a/tests/test_access_policy.py +++ b/tests/test_access_policy.py @@ -354,6 +354,29 @@ async def test_pin_reply_and_lockout(hass: HomeAssistant): await h.ctrl.async_shutdown() +async def test_pin_approval_without_any_reply_text_is_not_a_failed_attempt(hass: HomeAssistant): + """A client that returns no text (no input shown) must not burn PIN attempts or lock the user out.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id, pin_code="2468")) + hass.states.async_set(SENSOR, STATE_OFF) + + for _ in range(5): + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + await _press(hass, _approve_action(calls)["action"], parent.id) + await hass.async_block_till_done() + assert "no PIN received" in calls[-1].data["message"] + assert h.pulses == [] + assert h.ctrl._failed_pins == {} + + # Still no lockout: the right PIN works straight away + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + await _press(hass, _approve_action(calls)["action"], parent.id, reply_text="2468") + await hass.async_block_till_done() + assert h.pulses == [(Intent.OPEN, Effect.OPEN)] + await h.ctrl.async_shutdown() + + # ── Close policy ──────────────────────────────────────────────────────── diff --git a/tests/test_component_impulse_cover.py b/tests/test_component_impulse_cover.py index 706d8e779..1aeb3e1a9 100644 --- a/tests/test_component_impulse_cover.py +++ b/tests/test_component_impulse_cover.py @@ -54,6 +54,8 @@ ) from tests.conftest import attach_runtime +PULSE_FRAME = "*1*18*22##" if hasattr(OWNLightingCommand, "switch_on_timed") else "*1*1*22##" + @pytest.fixture def mock_gateway(): @@ -165,11 +167,12 @@ async def test_impulse_cover_open_and_close_cycle(hass: HomeAssistant, impulse_g # 1. Pulse open assert await impulse_gate._async_pulse(Intent.OPEN, Effect.OPEN) is True - # Hardware-timed WHAT 18 command sent immediately (actuator shuts off after 0.5s) + # Hardware-timed WHAT 18 command sent immediately (actuator shuts off after 0.5s); + # OWNd releases without the builder send a plain ON and switch it off in software. assert mock_gateway.send.call_count == 1 on_cmd = mock_gateway.send.call_args[0][0] assert isinstance(on_cmd, OWNLightingCommand) - assert str(on_cmd) == "*1*18*22##" + assert str(on_cmd) == PULSE_FRAME assert impulse_gate.is_opening is True assert impulse_gate.is_closed is False @@ -188,7 +191,7 @@ async def test_impulse_cover_open_and_close_cycle(hass: HomeAssistant, impulse_g assert mock_gateway.send.call_count == 1 on_cmd = mock_gateway.send.call_args[0][0] - assert str(on_cmd) == "*1*18*22##" + assert str(on_cmd) == PULSE_FRAME assert impulse_gate.is_closing is True # Finish closing travel (10s) @@ -598,3 +601,70 @@ async def test_impulse_cover_confirmed_write_future_and_off_failure(hass: HomeAs + + +async def test_impulse_cover_pulse_falls_back_without_timed_on(hass: HomeAssistant, impulse_gate, mock_gateway, monkeypatch): + """Released OWNd 2.0.0b10 has no OWNLightingCommand.switch_on_timed: ON, then OFF after the pulse.""" + monkeypatch.delattr(OWNLightingCommand, "switch_on_timed", raising=False) + + assert await impulse_gate._async_send_impulse() is True + assert [str(call.args[0]) for call in mock_gateway.send.call_args_list] == ["*1*1*22##"] + + async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=0.6)) + await hass.async_block_till_done() + assert [str(call.args[0]) for call in mock_gateway.send.call_args_list] == ["*1*1*22##", "*1*0*22##"] + + +async def test_impulse_cover_failed_send_does_not_start_deadband(hass: HomeAssistant, impulse_gate, mock_gateway): + """A pulse that never reached the bus must not lock the user out for min_cycle_time.""" + mock_gateway.send.side_effect = RuntimeError("Socket disconnected") + assert await impulse_gate._async_send_impulse() is False + assert impulse_gate._last_pulse_time == -1e9 + + mock_gateway.send.side_effect = None + assert await impulse_gate._async_send_impulse() is True + assert impulse_gate._last_pulse_time > 0 + + # The confirmed pulse does start it + assert await impulse_gate._async_send_impulse() is False + + +async def test_impulse_cover_failed_awaited_write_does_not_start_deadband(hass: HomeAssistant, impulse_gate, mock_gateway): + """The write future failing (timeout) counts as a failed send, too.""" + fut = asyncio.get_running_loop().create_future() + fut.set_exception(TimeoutError("no ACK")) + mock_gateway.send.return_value = fut + assert await impulse_gate._async_send_impulse() is False + assert impulse_gate._last_pulse_time == -1e9 + + +async def test_impulse_cover_in_flight_pulse_blocks_second_request_and_echo(hass: HomeAssistant, impulse_gate, mock_gateway): + """While the write is pending a second request is dropped and the echo is not a wall button.""" + release = asyncio.Event() + + async def slow_send(cmd, *args, **kwargs): + await release.wait() + + mock_gateway.send.side_effect = slow_send + first = asyncio.create_task(impulse_gate._async_send_impulse()) + await asyncio.sleep(0) + assert impulse_gate._pulse_in_flight is True + + assert await impulse_gate._async_send_impulse() is False + + impulse_gate._attr_is_closed = True + impulse_gate.handle_event(OWNEvent.parse("*1*1*22##")) + assert impulse_gate.is_opening is False + + release.set() + assert await first is True + assert impulse_gate._pulse_in_flight is False + + +def test_impulse_cover_wall_button_log_says_the_gate_already_moved(impulse_gate, caplog): + """A wall button press starts the gate; only Home Assistant's own request is dropped.""" + impulse_gate._access.cancel = MagicMock(return_value=True) + with caplog.at_level("INFO"): + impulse_gate.handle_event(OWNEvent.parse("*1*1*22##")) + impulse_gate._access.cancel.assert_called_once() + assert "already been actuated" in caplog.text diff --git a/tests/test_component_lock.py b/tests/test_component_lock.py index 65fa6aec5..22d1429fa 100644 --- a/tests/test_component_lock.py +++ b/tests/test_component_lock.py @@ -21,13 +21,10 @@ OWNDoorEntryEvent, ) except ImportError: # pragma: no cover - from custom_components.myhome.lock import ( - OWNDoorEntryCommand, - OWNDoorEntryEvent, - ) + from custom_components.myhome.gateway_events import OWNDoorEntryEvent + from custom_components.myhome.lock import OWNDoorEntryCommand from pytest_homeassistant_custom_component.common import ( async_fire_time_changed, - async_fire_time_changed_exact, ) from custom_components.myhome.const import ( @@ -59,8 +56,8 @@ def mock_gateway(): return gw -async def test_lock_setup_restores_and_discovers(hass: HomeAssistant, mock_gateway): - """Test lock platform setup: restoring from registry, configuring from YAML, and bus discovery.""" +async def test_lock_setup_restores_and_ignores_bus_traffic(hass: HomeAssistant, mock_gateway): + """Locks come from the registry and myhome.yaml only; bus traffic never creates one.""" mac = mock_gateway.mac hass.data = { DOMAIN: { @@ -104,23 +101,20 @@ def fake_add_entities(entities): assert any(e._where == "1" for e in added_entities) assert any(e._where == "2" for e in added_entities) - # Bus event: lock open on where 3 -> should be discovered + # A lock release seen on the bus must NOT create a lock: the entity would have + # no code, so anyone with dashboard access could open the door. lock_open_msg = OWNEvent.parse("*6*10*3##") assert isinstance(lock_open_msg, (OWNDoorEntryEvent, OWNEvent)) async_dispatcher_send(hass, f"myhome_message_{mac}", lock_open_msg) - assert len(added_entities) == 3 - assert any(e._where == "3" for e in added_entities) - - # Incoming call event -> must NOT create a lock entity - call_msg = OWNEvent.parse("*6*6*4##") - assert isinstance(call_msg, (OWNDoorEntryEvent, OWNEvent)) - async_dispatcher_send(hass, f"myhome_message_{mac}", call_msg) - assert len(added_entities) == 3 + await hass.async_block_till_done() + assert len(added_entities) == 2 + assert not any(e._where == "3" for e in added_entities) - # Broadcast lock or invalid address -> must NOT create a lock entity - broadcast_msg = OWNEvent.parse("*6*10*4100##") - async_dispatcher_send(hass, f"myhome_message_{mac}", broadcast_msg) - assert len(added_entities) == 3 + # Neither do calls nor the broadcast address + for frame in ("*6*6*4##", "*6*10*4100##", "*6*22*5##"): + async_dispatcher_send(hass, f"myhome_message_{mac}", OWNEvent.parse(frame)) + await hass.async_block_till_done() + assert len(added_entities) == 2 # Unload attach_runtime(hass, config_entry) @@ -251,131 +245,42 @@ async def test_setup_and_unload_missing_runtime(hass: HomeAssistant): assert await async_unload_entry(hass, mock_entry) is True -async def test_impulse_lock_who1_code_and_pulse(hass: HomeAssistant, mock_gateway): - """Test WHO=1 impulse lock with security code and momentary auto-off.""" +async def test_lock_code_is_required_and_checked_in_constant_time(hass: HomeAssistant, mock_gateway): + """A lock with a code refuses a missing or wrong code and sends nothing.""" with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): lock = MyHOMELock( hass=hass, - name="Impulse Gate Lock", + name="Front Door Lock", entity_name=None, - device_id="25", - who="1", - where="25", + device_id="4001#2", + who="6", + where="4001#2", interface=None, manufacturer="BTicino", - model="Impulse Lock", + model="Door Entry Lock", gateway=mock_gateway, code="1234", - pulse_duration=0.5, ) lock.hass = hass - lock.entity_id = "lock.impulse_gate_lock" + lock.entity_id = "lock.front_door_lock" lock.async_write_ha_state = MagicMock() assert lock.code_format == r"^\d+$" - assert lock.is_locked is True - - # Invalid code raises ServiceValidationError - with pytest.raises(ServiceValidationError, match="Invalid code"): - await lock.async_unlock(code="9999") + for bad in ({}, {"code": "9999"}): + with pytest.raises(ServiceValidationError, match="Invalid code"): + await lock.async_unlock(**bad) + mock_gateway.send.assert_not_called() - # Valid code unlocks and pulses relay with WHAT 18 hardware timed pulse await lock.async_unlock(code="1234") - assert mock_gateway.send.call_count == 1 - cmd = mock_gateway.send.call_args[0][0] - assert str(cmd) == "*1*18*25##" + assert str(mock_gateway.send.call_args[0][0]) == "*6*10*4001#2##" assert lock.is_locked is False - - # Advance 0.6s -> auto relock - future = dt_util.utcnow() + timedelta(seconds=0.6) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert lock.is_locked is True - - # Handle event: *1*1*25## unlocks momentarily - event_msg = OWNEvent.parse("*1*1*25##") - lock.handle_event(event_msg) - assert lock.is_locked is False - - # Advance 0.6s -> auto relock - future = dt_util.utcnow() + timedelta(seconds=0.6) - async_fire_time_changed(hass, future) - await hass.async_block_till_done() - assert lock.is_locked is True - - # Custom duration uses software timer - lock._pulse_duration = 1.0 - await lock.async_unlock(code="1234") - assert lock._pulse_off_unsub is not None - await lock.async_unlock(code="1234") - assert lock._pulse_off_unsub is not None - - # Removal while pulse is pending immediately turns relay off (lock.py:345-346) - mock_gateway.send.reset_mock() - await lock.async_will_remove_from_hass() - assert lock._pulse_off_unsub is None - assert mock_gateway.send.call_count == 1 - assert str(mock_gateway.send.call_args[0][0]) == "*1*0*25##" - - # Handle event with translation flag returns early (lock.py:306) - trans_msg = MagicMock(is_translation=True) - lock.handle_event(trans_msg) - - -async def test_lock_setup_who1_and_bus_discovery_branches(hass: HomeAssistant, mock_gateway): - """Cover build_who1, accept_who6 bus discovery with is_lock_open, and accept_who1 from registry.""" - mac = mock_gateway.mac - hass.data.setdefault(DOMAIN, {})[mac] = { - "entity": mock_gateway, - CONF_PLATFORMS: { - PLATFORM: { - "26": { - "who": "1", - CONF_WHERE: "26", - CONF_NAME: "Side Gate Impulse Lock", - "code": "4321", - "pulse_duration": 1.0, - }, - } - }, - } - - config_entry = MagicMock() - config_entry.data = {"mac": mac} - config_entry.entry_id = "lock_who1_entry" - added = [] - - mock_er = MagicMock() - reg_who1 = MagicMock() - reg_who1.domain = Platform.LOCK - reg_who1.unique_id = f"{mac}-1-28" - - with ( - patch("homeassistant.helpers.entity_registry.async_get", return_value=mock_er), - patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[reg_who1]), - ): - attach_runtime(hass, config_entry) - await async_setup_entry(hass, config_entry, added.extend) - - # 1. Restored who: 1 from registry (accept_who1 registry branch: lock.py:159) - # 2. Configured who: 1 from YAML (build_who1: lock.py:120-129) - assert len(added) == 2 - assert any(e._who == "1" and e._where == "26" for e in added) - assert any(e._who == "1" and e._where == "28" for e in added) - - # 3. Bus discovery: accept_who6 with is_lock_open message (lock.py:152) - bus_msg = OWNEvent.parse("*6*10*27##") - async_dispatcher_send(hass, f"myhome_message_{mac}", bus_msg) - assert len(added) == 3 - assert any(e._where == "27" for e in added) + lock._cancel_auto_relock() -async def test_lock_confirmed_write_future_and_off_failure(hass: HomeAssistant, mock_gateway): - """Test write_fut await paths and exception handling in timed OFF and removal.""" - # 1. WHO 6 unlock with awaitable write_fut - +async def test_lock_confirmed_write_future(hass: HomeAssistant, mock_gateway): + """The unlock waits for the gateway's write confirmation.""" with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): - lock_who6 = MyHOMELock( + lock = MyHOMELock( hass=hass, name="Front Door Lock", entity_name=None, @@ -387,88 +292,14 @@ async def test_lock_confirmed_write_future_and_off_failure(hass: HomeAssistant, model="Door Entry Lock", gateway=mock_gateway, ) - lock_who6.hass = hass - lock_who6.entity_id = "lock.front_door_lock" - lock_who6.async_write_ha_state = MagicMock() - lock_who6.async_schedule_update_ha_state = MagicMock() - - fut_who6 = asyncio.Future() - fut_who6.set_result(0.05) - mock_gateway.send.return_value = fut_who6 - await lock_who6.async_unlock() - assert fut_who6.done() - lock_who6._cancel_auto_relock() - - # 2. WHO 1 lock with WHAT 18 and awaitable write_fut - with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): - lock_who1 = MyHOMELock( - hass=hass, - name="Impulse Lock", - entity_name=None, - device_id="1-25", - who="1", - where="25", - interface=None, - pulse_duration=0.5, - code=None, - manufacturer="BTicino", - model="Relay Lock", - gateway=mock_gateway, - ) - lock_who1.hass = hass - lock_who1.entity_id = "lock.impulse_lock" - lock_who1.async_write_ha_state = MagicMock() - - fut_who1 = asyncio.Future() - fut_who1.set_result(0.05) - mock_gateway.send.return_value = fut_who1 - await lock_who1.async_unlock() - assert fut_who1.done() - lock_who1._cancel_auto_relock() - - # 3. WHO 1 custom duration with awaitable write_fut and timed OFF - lock_who1._pulse_duration = 1.0 - fut_on = asyncio.Future() - fut_on.set_result(0.05) - mock_gateway.send.side_effect = None - mock_gateway.send.return_value = fut_on - await lock_who1.async_unlock() - assert fut_on.done() - assert lock_who1._pulse_off_unsub is not None - - # Advance timer to trigger _send_off - fut_off = asyncio.Future() - fut_off.set_result(0.05) - mock_gateway.send.return_value = fut_off - async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=1.2)) - await hass.async_block_till_done() - assert fut_off.done() - lock_who1._cancel_auto_relock() - - # 4. WHO 1 custom duration with OFF send failure - lock_who1._pulse_duration = 1.0 - mock_gateway.send.return_value = None - await lock_who1.async_unlock() - mock_gateway.send.side_effect = RuntimeError("OFF send error") - async_fire_time_changed_exact(hass, dt_util.utcnow() + timedelta(seconds=1.2)) - await hass.async_block_till_done() - assert lock_who1._pulse_off_unsub is None - lock_who1._cancel_auto_relock() - - # 5. Removal fail-safe with awaitable write_fut and exception handling - fut_rem = asyncio.Future() - fut_rem.set_result(0.05) - mock_gateway.send.side_effect = None - mock_gateway.send.return_value = fut_rem - lock_who1._pulse_off_unsub = MagicMock() - await lock_who1.async_will_remove_from_hass() - assert fut_rem.done() - - mock_gateway.send.side_effect = RuntimeError("Removal send error") - lock_who1._pulse_off_unsub = MagicMock() - await lock_who1.async_will_remove_from_hass() - lock_who1._cancel_auto_relock() - - - + lock.hass = hass + lock.entity_id = "lock.front_door_lock" + lock.async_write_ha_state = MagicMock() + fut = asyncio.Future() + fut.set_result(0.05) + mock_gateway.send.return_value = fut + await lock.async_unlock() + assert fut.done() + lock._cancel_auto_relock() + await lock.async_will_remove_from_hass() diff --git a/tests/test_door_entry_events.py b/tests/test_door_entry_events.py index 9a3c11e2e..07fba6cb7 100644 --- a/tests/test_door_entry_events.py +++ b/tests/test_door_entry_events.py @@ -1,5 +1,5 @@ """Tests for Door Entry (WHO=6) doorbell events and gateway handling.""" -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, PropertyMock, patch from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -137,3 +137,76 @@ async def test_gateway_logs_door_entry_events(hass: HomeAssistant): gateway.log_id, call_event.human_readable_log, ) + + +def _doorbell_gateway(hass: HomeAssistant) -> MyHOMEGatewayHandler: + config_entry = MagicMock() + config_entry.data = {"host": "192.168.1.100", "port": 20000, "password": "open", "mac": "00:03:50:00:77:78"} + config_entry.options = {} + config_entry.entry_id = "test_doorbell_kinds_entry" + gateway = MyHOMEGatewayHandler(hass, config_entry) + gateway.generate_events = False + return gateway + + +async def test_doorbell_only_for_entrance_panel_calls(hass: HomeAssistant): + """WHO 8 WHAT 1 starts any call: only the entrance panels (kinds 1-4) ring the door.""" + gateway = _doorbell_gateway(hass) + captured = async_capture_events(hass, "myhome_doorbell_event") + + # Handset to handset (kind 6) and a pager broadcast (kind 14) are not a visitor + for frame in ("*8*1#6#2*74##", "*8*1#6#4#73*74##", "*8*1#14#2*4##"): + await gateway._process_message(OWNEvent.parse(frame)) + # A call whose kind cannot be read fails closed + for frame in ("*8*1*74##", "*8*1#x#4*74##"): + message = OWNEvent.parse(frame) + if message is not None: + await gateway._process_message(message) + assert captured == [] + + for kind in (1, 2, 3, 4): + await gateway._process_message(OWNEvent.parse(f"*8*1#{kind}#4*74##")) + assert [event.data["event"] for event in captured] == ["call"] * 4 + assert {event.data["where"] for event in captured} == {"74"} + + +async def test_doorbell_ignores_library_call_flags_for_who8(hass: HomeAssistant): + """Older OWNd flags every WHO 8 WHAT 1 as an incoming call; the call kind still decides.""" + gateway = _doorbell_gateway(hass) + captured = async_capture_events(hass, "myhome_doorbell_event") + + internal = OWNEvent.parse("*8*1#6#2*74##") + internal.is_call = True # type: ignore[attr-defined] + internal.is_incoming_call = True # type: ignore[attr-defined] + await gateway._process_message(internal) + assert captured == [] + + +async def test_doorbell_fires_once_on_a_shared_bus(hass: HomeAssistant): + """Only the gateway that owns the subsystem announces the ring; the other one stays silent.""" + gateway = _doorbell_gateway(hass) + captured = async_capture_events(hass, "myhome_doorbell_event") + dispatched: list[dict] = [] + + @callback + def record(payload): + dispatched.append(payload) + + unsub = async_dispatcher_connect(hass, f"myhome_doorbell_event_{gateway.mac}", record) + + try: + with patch.object(MyHOMEGatewayHandler, "delegated_away_whos", new_callable=PropertyMock, return_value={6, 8}): + await gateway._process_message(OWNEvent.parse("*8*1#1#4*74##")) + await gateway._process_message(OWNEvent.parse("*6*6*1##")) + assert captured == [] + assert dispatched == [] + + with patch.object(MyHOMEGatewayHandler, "is_standby", new_callable=PropertyMock, return_value=True): + await gateway._process_message(OWNEvent.parse("*8*1#1#4*74##")) + assert captured == [] + + await gateway._process_message(OWNEvent.parse("*8*1#1#4*74##")) + assert len(captured) == 1 + assert len(dispatched) == 1 + finally: + unsub() diff --git a/tests/test_supported_domains.py b/tests/test_supported_domains.py index cdd8aaf14..9ffd42686 100644 --- a/tests/test_supported_domains.py +++ b/tests/test_supported_domains.py @@ -46,7 +46,7 @@ def test_extract_who_mapping(): assert mapping["light"] == "WHO=1" assert mapping["switch"] == "WHO=1" assert mapping["cover"] == "WHO=1 / WHO=2" - assert mapping["lock"] == "WHO=1 / WHO=6" + assert mapping["lock"] == "WHO=6" assert mapping["climate"] == "WHO=4" assert mapping["alarm_control_panel"] == "WHO=5" assert mapping["binary_sensor"] == "WHO=1 / 9 / 25" diff --git a/tests/test_validate.py b/tests/test_validate.py index ef5767228..590935521 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -790,43 +790,31 @@ def test_device_class_remapping_and_defaults(self): class TestLockSchemaValidation: """Test validation and restrictions on lock_schema.""" - def test_who1_point_to_point_validates(self): - """WHO=1 impulse lock with point-to-point WHERE succeeds.""" - data = { - "lock1": { - CONF_WHO: "1", - CONF_WHERE: "12", - CONF_NAME: "Front Gate Strike", - "pulse_duration": 0.5, - } - } - res = lock_schema(data) - assert "1-12" in res - assert res["1-12"]["pulse_duration"] == 0.5 + @pytest.mark.parametrize("where", ["12", "72", "4"]) + def test_who1_lock_rejected(self, where): + """A WHO=1 relay can be a motorized gate: as a lock it would bypass the access policy.""" + with pytest.raises(Invalid): + lock_schema({"lock1": {CONF_WHO: "1", CONF_WHERE: where, CONF_NAME: "Gate as lock"}}) - def test_who1_general_where_rejected(self): - """WHO=1 impulse lock with general WHERE 0 is rejected.""" - data = { - "lock1": { - CONF_WHO: "1", - CONF_WHERE: "0", - CONF_NAME: "Dangerous All Lights Strike", - } - } - with pytest.raises(Invalid, match="requires a point-to-point "): - lock_schema(data) + def test_who8_lock_rejected(self): + """Only WHO=6 door entry strikes are locks.""" + with pytest.raises(Invalid): + lock_schema({"lock1": {CONF_WHO: "8", CONF_WHERE: "74", CONF_NAME: "Intercom lock"}}) - def test_who1_group_where_rejected(self): - """WHO=1 impulse lock with group WHERE is rejected.""" - data = { - "lock1": { - CONF_WHO: "1", - CONF_WHERE: "#1", - CONF_NAME: "Group Lock", - } - } - with pytest.raises(Invalid, match="requires a point-to-point "): - lock_schema(data) + def test_who_defaults_to_6(self): + res = lock_schema({"lock1": {CONF_WHERE: "4001", CONF_NAME: "Front door"}}) + assert "6-4001" in res + + @pytest.mark.parametrize("where", ["4001#2", "4095#2", "4000", "1"]) + def test_who6_riser_and_panel_addresses_validate(self, where): + """WHO 6 endpoints are 4000..4095, with #2 appended on a riser installation.""" + res = lock_schema({"lock1": {CONF_WHO: "6", CONF_WHERE: where, CONF_NAME: "Door"}}) + assert f"6-{where}" in res + + @pytest.mark.parametrize("where", ["4001#3", "#2", "40#2#2", "abc", "12345", "4001#"]) + def test_who6_malformed_where_rejected(self, where): + with pytest.raises(Invalid): + lock_schema({"lock1": {CONF_WHO: "6", CONF_WHERE: where, CONF_NAME: "Door"}}) def test_who6_validates(self): """WHO=6 door entry lock with entrance panel WHERE succeeds.""" @@ -840,31 +828,22 @@ def test_who6_validates(self): res = lock_schema(data) assert "6-1" in res - def test_who6_general_or_group_rejected(self): - """WHO=6 door entry lock cannot use general 0 or group.""" - with pytest.raises(Invalid, match="cannot use general"): - lock_schema({ - "lock1": { - CONF_WHO: "6", - CONF_WHERE: "0", - CONF_NAME: "General Lock", - } - }) - with pytest.raises(Invalid, match="cannot use general"): - lock_schema({ - "lock1": { - CONF_WHO: "6", - CONF_WHERE: "#2", - CONF_NAME: "Group Lock", - } - }) + def test_who6_general_or_broadcast_rejected(self): + """WHO=6 door entry lock cannot use general 0 or the broadcast address.""" + for where in ("0", "4100"): + with pytest.raises(Invalid, match="cannot use general"): + lock_schema({"lock1": {CONF_WHO: "6", CONF_WHERE: where, CONF_NAME: "General Lock"}}) + + def test_who6_group_rejected(self): + with pytest.raises(Invalid): + lock_schema({"lock1": {CONF_WHO: "6", CONF_WHERE: "#2", CONF_NAME: "Group Lock"}}) def test_pulse_duration_bounds(self): """Pulse duration must be between 0.1 and 10.0 seconds.""" with pytest.raises(Invalid): lock_schema({ "lock1": { - CONF_WHO: "1", + CONF_WHO: "6", CONF_WHERE: "12", CONF_NAME: "Negative Pulse Lock", "pulse_duration": -3.0, @@ -873,10 +852,31 @@ def test_pulse_duration_bounds(self): with pytest.raises(Invalid): lock_schema({ "lock1": { - CONF_WHO: "1", + CONF_WHO: "6", CONF_WHERE: "12", CONF_NAME: "Too Long Pulse Lock", "pulse_duration": 15.0, } }) +class TestImpulseCoverTypeConsistency: + """`type` has to agree with `who`; it used to be ignored silently.""" + + @staticmethod + def _cover(**cfg): + return cover_schema({"gate": {CONF_WHERE: "72", CONF_NAME: "Gate", **cfg}}) + + def test_impulse_type_with_who2_rejected(self): + with pytest.raises(Invalid, match="needs who: 1"): + self._cover(**{CONF_WHO: "2", "type": "impulse_relay"}) + + @pytest.mark.parametrize("kind", ["shutter", "standard"]) + def test_shutter_type_with_who1_rejected(self, kind): + with pytest.raises(Invalid, match="who: 1 is an impulse relay"): + self._cover(**{CONF_WHO: "1", "type": kind}) + + def test_matching_types_validate(self): + assert "1-72" in self._cover(**{CONF_WHO: "1", "type": "impulse_relay"}) + assert "1-72" in self._cover(**{CONF_WHO: "1"}) + assert "2-72" in self._cover(**{CONF_WHO: "2", "type": "shutter"}) + From e0ecd70b4bb2e1b0ff1c606685b75042e45eb038 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 9 Oct 2026 22:48:18 +0200 Subject: [PATCH 16/26] fix(door_entry): fire doorbell only for entrance panel calls, not internal or pager calls OWNd#100 review: WHO 8 WHAT 1 is any call session. myhome_doorbell_event fired on is_call, so a handset-to-handset call (*8*1#6#...) or a pager broadcast (*8*1#14#...) rang the doorbell. Ring on is_incoming_call (entrance panel call kinds 1..4) or is_chime instead; the fallback for OWNd releases without the WHO 8 class checks the call kind the same way. Co-Authored-By: Claude Opus 5.5 --- tests/test_door_entry_events.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/tests/test_door_entry_events.py b/tests/test_door_entry_events.py index 07fba6cb7..42617d698 100644 --- a/tests/test_door_entry_events.py +++ b/tests/test_door_entry_events.py @@ -111,6 +111,12 @@ def handle_dispatcher(payload): } assert len(dispatcher_payloads) == 4 assert dispatcher_payloads[3] == captured_events[3].data + + # 6. Internal handset call and pager broadcast are not doorbell rings + for raw in ("*8*1#6#2#11*16##", "*8*1#14#2#11*4##"): + await gateway._process_message(OWNEvent.parse(raw)) + assert len(captured_events) == 4 + assert len(dispatcher_payloads) == 4 finally: unsub() From 5f0f9fd621b5d3b26533133d9da528af8b90b157 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 10 Oct 2026 00:37:34 +0200 Subject: [PATCH 17/26] test: cover the PR 667 guards to restore 100% coverage Lock: a registry entry of another WHO, a general WHERE in myhome.yaml and a translated frame. Discovery: an event of the platform class carrying another WHO. Intercom: an unreadable call kind. DoorEntryWhere repr. Co-Authored-By: Claude Opus 5.5 --- tests/test_component_lock.py | 16 ++++++++++++++-- tests/test_door_entry_events.py | 8 ++++++++ tests/test_platform_discovery.py | 3 +++ tests/test_validate.py | 4 ++++ 4 files changed, 29 insertions(+), 2 deletions(-) diff --git a/tests/test_component_lock.py b/tests/test_component_lock.py index 22d1429fa..f609b1f53 100644 --- a/tests/test_component_lock.py +++ b/tests/test_component_lock.py @@ -69,6 +69,10 @@ async def test_lock_setup_restores_and_ignores_bus_traffic(hass: HomeAssistant, CONF_WHERE: "2", CONF_NAME: "Side Gate Lock", }, + "0": { + CONF_WHERE: "0", + CONF_NAME: "General Lock", + }, } }, } @@ -83,10 +87,13 @@ async def test_lock_setup_restores_and_ignores_bus_traffic(hass: HomeAssistant, reg_1 = MagicMock() reg_1.domain = Platform.LOCK reg_1.unique_id = f"{mac}-6-1" + reg_light = MagicMock() # another subsystem's entry is never restored as a lock + reg_light.domain = Platform.LOCK + reg_light.unique_id = f"{mac}-1-7" with ( patch("homeassistant.helpers.entity_registry.async_get", return_value=mock_er), - patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[reg_1]), + patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[reg_1, reg_light]), ): added_entities = [] @@ -96,7 +103,7 @@ def fake_add_entities(entities): attach_runtime(hass, config_entry) await async_setup_entry(hass, config_entry, fake_add_entities) - # Restored (1) + Configured from YAML (2) = 2 locks + # Restored (1) + Configured from YAML (2) = 2 locks; the general WHERE 0 is not a lock assert len(added_entities) == 2 assert any(e._where == "1" for e in added_entities) assert any(e._where == "2" for e in added_entities) @@ -199,6 +206,11 @@ def test_handle_event_lock_open(self, hass: HomeAssistant, lock_entity): assert lock_entity.is_locked is True + def test_handle_event_translation_ignored_by_lock(self, lock_entity): + """A translated frame is not a release of this strike.""" + lock_entity.handle_event(MagicMock(who=6, _what=10, is_lock_open=True, is_translation=True)) + assert lock_entity.is_locked is True + def test_handle_event_call_ignored_by_lock(self, lock_entity): """Call event (*6*6*1##) should not alter lock state.""" event_msg = OWNEvent.parse("*6*6*1##") diff --git a/tests/test_door_entry_events.py b/tests/test_door_entry_events.py index 42617d698..c14e5381e 100644 --- a/tests/test_door_entry_events.py +++ b/tests/test_door_entry_events.py @@ -12,6 +12,7 @@ from pytest_homeassistant_custom_component.common import async_capture_events from custom_components.myhome.gateway import MyHOMEGatewayHandler +from custom_components.myhome.gateway_events import GatewayEventDispatcher async def test_gateway_fires_doorbell_events(hass: HomeAssistant): @@ -176,6 +177,13 @@ async def test_doorbell_only_for_entrance_panel_calls(hass: HomeAssistant): assert {event.data["where"] for event in captured} == {"74"} +def test_unreadable_call_kind_is_none(): + """A call kind that is not a number (from the library or the raw parameters) reads as None.""" + assert GatewayEventDispatcher._intercom_call_kind(MagicMock(call_kind="x")) is None + assert GatewayEventDispatcher._intercom_call_kind(MagicMock(call_kind=None, _what_param=["x"])) is None + assert GatewayEventDispatcher._intercom_call_kind(MagicMock(call_kind=None, _what_param=[])) is None + + async def test_doorbell_ignores_library_call_flags_for_who8(hass: HomeAssistant): """Older OWNd flags every WHO 8 WHAT 1 as an incoming call; the call kind still decides.""" gateway = _doorbell_gateway(hass) diff --git a/tests/test_platform_discovery.py b/tests/test_platform_discovery.py index 26861aa53..0c125a6e7 100644 --- a/tests/test_platform_discovery.py +++ b/tests/test_platform_discovery.py @@ -133,6 +133,9 @@ def on_announced(device): for frame in ("*1*1000#1*14##", "*1*1*0##", "*1*1*#5##", "*1*1*1##"): discovery.handle_message(OWNEvent.parse(frame)) assert not {"14", "0", "#5", "1"} & set(discovery.known) + # an event of the platform class that carries another WHO is not this subsystem's + discovery.handle_message(MagicMock(spec=OWNLightingEvent, who=2, where="15", is_translation=False)) + assert "15" not in discovery.known # frames of another subsystem are ignored discovery.handle_message(OWNEvent.parse("*2*1*21##")) assert "21" not in discovery.known diff --git a/tests/test_validate.py b/tests/test_validate.py index 590935521..b43cb3915 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -33,6 +33,7 @@ from custom_components.myhome.validate import ( Area, BusInterface, + DoorEntryWhere, General, Group, MacAddress, @@ -834,6 +835,9 @@ def test_who6_general_or_broadcast_rejected(self): with pytest.raises(Invalid, match="cannot use general"): lock_schema({"lock1": {CONF_WHO: "6", CONF_WHERE: where, CONF_NAME: "General Lock"}}) + def test_door_entry_where_repr(self): + assert repr(DoorEntryWhere("custom door msg")) == "DoorEntryWhere(msg='custom door msg')" + def test_who6_group_rejected(self): with pytest.raises(Invalid): lock_schema({"lock1": {CONF_WHO: "6", CONF_WHERE: "#2", CONF_NAME: "Group Lock"}}) From e5ae5175ac46114283cde0425a8ef71c1371a2d2 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 10 Oct 2026 12:31:45 +0200 Subject: [PATCH 18/26] fix(blueprint,access): resolve audit items for motion blueprint and PIN resolution Remove dead close action button from door_gate_motion_notification blueprint to prevent unhandled notification actions and align with human-in-the-loop cover safety policy. Broaden PIN resolution in access_policy.py to accept reply fallback key. Cross-link cancel_cover_request and acknowledge_cover_fault in covers.md. --- .../myhome/door_gate_motion_notification.yaml | 11 +---------- custom_components/myhome/access_policy.py | 6 +++++- docs/configuration/covers.md | 2 +- tests/test_access_policy.py | 14 ++++++++++++++ 4 files changed, 21 insertions(+), 12 deletions(-) diff --git a/blueprints/automation/myhome/door_gate_motion_notification.yaml b/blueprints/automation/myhome/door_gate_motion_notification.yaml index caaa8c711..4b424a741 100644 --- a/blueprints/automation/myhome/door_gate_motion_notification.yaml +++ b/blueprints/automation/myhome/door_gate_motion_notification.yaml @@ -21,7 +21,7 @@ blueprint: text: language: name: "Notification Language" - description: "Language for push notifications and action buttons (English, Italiano, Nederlands, Deutsch, Français)." + description: "Language for push notifications (English, Italiano, Nederlands, Deutsch, Français)." default: "en" selector: select: @@ -111,7 +111,6 @@ variables: closed_msg: "The door/gate is now safely and fully closed." left_open_title: "⚠️ Alert: {{ cover_name }} is open!" left_open_msg: "The door/gate has been left open for more than {{ open_minutes }} minutes." - close_action: "🔒 Close Now" it: opening_title: "🚪 {{ cover_name }} si sta aprendo" opening_msg: "La porta/cancello è in movimento verso l'apertura." @@ -121,7 +120,6 @@ variables: closed_msg: "La porta/cancello è ora completamente e sicuramente chiusa." left_open_title: "⚠️ Attenzione: {{ cover_name }} è aperta!" left_open_msg: "La porta/cancello è rimasta aperta per più di {{ open_minutes }} minuti." - close_action: "🔒 Chiudi ora" nl: opening_title: "🚪 {{ cover_name }} wordt geopend" opening_msg: "De deur/poort is in beweging richting open." @@ -131,7 +129,6 @@ variables: closed_msg: "De deur/poort is nu veilig en volledig gesloten." left_open_title: "⚠️ Opgelet: {{ cover_name }} staat open!" left_open_msg: "De deur/poort staat al langer dan {{ open_minutes }} minuten open." - close_action: "🔒 Sluit Nu" de: opening_title: "🚪 {{ cover_name }} wird geöffnet" opening_msg: "Das Tor/die Tür öffnet sich." @@ -141,7 +138,6 @@ variables: closed_msg: "Das Tor/die Tür ist jetzt sicher und vollständig geschlossen." left_open_title: "⚠️ Achtung: {{ cover_name }} ist offen!" left_open_msg: "Das Tor/die Tür steht seit mehr als {{ open_minutes }} Minuten offen." - close_action: "🔒 Jetzt schließen" fr: opening_title: "🚪 {{ cover_name }} est en cours d'ouverture" opening_msg: "La porte/le portail est en mouvement vers l'ouverture." @@ -151,7 +147,6 @@ variables: closed_msg: "La porte/le portail est maintenant bien et totalement fermé." left_open_title: "⚠️ Attention: {{ cover_name }} est ouvert!" left_open_msg: "La porte/le portail est ouvert depuis plus de {{ open_minutes }} minutes." - close_action: "🔒 Fermer maintenant" dict: "{{ i18n[lang] if lang in i18n else i18n['en'] }}" action: @@ -211,7 +206,3 @@ action: message: "{{ dict.left_open_msg }}" data: tag: "myhome_door_left_open_{{ cover_entity }}" - actions: - - action: "MYHOME_CLOSE_LEFT_OPEN_{{ cover_entity }}" - title: "{{ dict.close_action }}" - destructive: true diff --git a/custom_components/myhome/access_policy.py b/custom_components/myhome/access_policy.py index fb3f264e7..1285dcfa1 100644 --- a/custom_components/myhome/access_policy.py +++ b/custom_components/myhome/access_policy.py @@ -503,7 +503,11 @@ def _on_action(self, event: Event) -> None: LOGGER.info("%s: request denied on the phone", self._name) return if self.config.pin_code is not None: - reply = event.data.get("reply_text", event.data.get("textInput")) + reply = event.data.get("reply_text") + if reply is None: + reply = event.data.get("textInput") + if reply is None: + reply = event.data.get("reply") if reply is None: # The app returned no text at all (no input shown, or a client that # ignores `behavior: textInput`): nothing was guessed, so it is no failed diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index 0156f36d3..1a692bcd6 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -403,7 +403,7 @@ Because monostable relays only send a momentary pulse (`open` / `stop` / `close` - Any external close-block switch to report `off`. - Any missing, unavailable, or non-binary sensor state immediately fails closed. 5. **Audible / Visual Pre-Warning & Watchdog**: - Before a close pulse, an optional pre-warning flasher (`prewarn_light`) triggers for `prewarn_seconds`. After the pulse, an anti-stuck watchdog monitors motion: if the expected state is not reached within `travel_time + watchdog_margin`, a latching fault is asserted with **zero automatic retries**. + Before a close pulse, an optional pre-warning flasher (`prewarn_light`) triggers for `prewarn_seconds`. After the pulse, an anti-stuck watchdog monitors motion: if the expected state is not reached within `travel_time + watchdog_margin`, a latching fault is asserted with **zero automatic retries**. A latched fault can only be cleared on site by an authorized user using the `myhome.acknowledge_cover_fault` action; an in-flight request or pre-warning can be cancelled at any time using `myhome.cancel_cover_request` (see [Services](services.md#14-myhomecancel_cover_request)). 6. **A Wall Button Starts the Gate, It Does Not Stop It**: A press of the wall button (or any other keypad on the bus) closes the relay and energizes the motor by itself; Home Assistant only sees the resulting `*1*1*WHERE##` frame afterwards. When such a frame arrives during an approval request or the pre-warning countdown, Home Assistant therefore *drops its own pending request* so that no second pulse follows and stops or reverses the gate. The movement the button started is not aborted. Use the hardware's own stop input or the remote control for an emergency stop. diff --git a/tests/test_access_policy.py b/tests/test_access_policy.py index 04d3d5df2..adfc3cf07 100644 --- a/tests/test_access_policy.py +++ b/tests/test_access_policy.py @@ -354,6 +354,20 @@ async def test_pin_reply_and_lockout(hass: HomeAssistant): await h.ctrl.async_shutdown() +async def test_pin_approval_with_reply_key_fallback(hass: HomeAssistant): + """Clients passing PIN under the 'reply' key (Android/webhooks) are accepted.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id, pin_code="2468")) + hass.states.async_set(SENSOR, STATE_OFF) + + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + await _press(hass, _approve_action(calls)["action"], parent.id, reply="2468") + await hass.async_block_till_done() + assert h.pulses == [(Intent.OPEN, Effect.OPEN)] + await h.ctrl.async_shutdown() + + async def test_pin_approval_without_any_reply_text_is_not_a_failed_attempt(hass: HomeAssistant): """A client that returns no text (no input shown) must not burn PIN attempts or lock the user out.""" parent = await _user(hass, "Parent") From af6d22e92be464293335961c14781d406800e8ad Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:42:53 +0200 Subject: [PATCH 19/26] feat(cover): make the impulse access policy optional, never withhold STOP (#667) An impulse cover is now a plain hardware interface unless it has an `access:` block: open/close/stop pulse the relay at once (deadband, delivery check, sensor-aware skipping of pulses that would only undo the request) with no phone approval and no user requirement. The approval/PIN/presence/pre-warning workflow stays available behind `access:`. Review feedback (xtimmy86x): - STOP is never withheld: with `access:` it cancels a pending request, or pulses a moving door to a stop without approval, for anyone. - A latched fault is restored from the last state after a restart or reload. - A software OFF that is not delivered is retried, the relay is released before the next pulse, and removal delivers an owed OFF. - `pin_code` without `access:` is rejected by validation. Co-Authored-By: Claude Sonnet 5.5 --- README.md | 2 +- custom_components/myhome/access_policy.py | 24 +++ custom_components/myhome/cover.py | 180 +++++++++++++++------- custom_components/myhome/validate.py | 2 + docs/configuration/covers.md | 55 +++++-- docs/configuration/services.md | 4 +- docs/configuration/supported_functions.md | 2 +- scripts/update_supported_domains.py | 2 +- tests/test_access_policy.py | 79 ++++++++++ tests/test_component_impulse_cover.py | 174 +++++++++++++++++++++ tests/test_validate.py | 6 + 11 files changed, 453 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 09da7e5f4..dbe241a23 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ We now maintain a comprehensive, community-curated **[GitHub Wiki](https://githu |---|---|---| | **`light`** | WHO=1 | On/Off, Dimmers with brightness control & transitions (stepped & native), DALI DT8 Tunable White (Dimension 14, 2000K–6535K), HS/RGB colour, Hardware-offloaded bus timers (`myhome.turn_on_timed` / `timer` parameter) | | **`switch`** | WHO=1 | Relays, auxiliary switches, socket actuators (switch/outlet device classes), Hardware-offloaded bus timers (`myhome.turn_on_timed` / `timer` parameter) | -| **`cover`** | WHO=1 / WHO=2 | Motorized shutters, blinds, roll-ups with state tracking, position-reporting actuators & virtual travel-time positioning; impulse gates & garage doors (`WHO=1`) with AccessController multi-factor safety approval | +| **`cover`** | WHO=1 / WHO=2 | Motorized shutters, blinds, roll-ups with state tracking, position-reporting actuators & virtual travel-time positioning; impulse gates & garage doors (`WHO=1`) with sensor feedback and an optional multi-factor approval workflow | | **`lock`** | WHO=6 | Native video door entry strikes (`WHO=6`, configured in `myhome.yaml`) with constant-time PIN authentication and auto-relock | | **`climate`** | WHO=4 | Heating, cooling, 4-pipe systems, thermostats, setpoints, fancoil 3-speed modes, offset tracking, Central Unit 3550 (`#0`) & 4695 (`#0#1`) master coordination & seasonal propagation | | **`alarm_control_panel`** | WHO=5 | Central units (3485/3486), read-only state (disarmed / armed away / triggered), zone 0 broadcast sync; arm/disarm via AUX frames ([docs](docs/configuration/alarm.md)) | diff --git a/custom_components/myhome/access_policy.py b/custom_components/myhome/access_policy.py index 1285dcfa1..2e91b6c12 100644 --- a/custom_components/myhome/access_policy.py +++ b/custom_components/myhome/access_policy.py @@ -253,6 +253,10 @@ async def async_request(self, intent: Intent, context: Context | None) -> None: """Create an approval request for ``intent``; raises :class:`AccessDenied` when refused.""" hass = self._require_hass() user_id = context.user_id if context is not None else None + if intent is Intent.STOP and self.phase in (Phase.PENDING, Phase.PREWARNING, Phase.MONITORING): + # Stopping a door that is about to move or moving is never withheld, whoever asks. + await self._async_stop_now() + return if self.phase in (Phase.PENDING, Phase.PREWARNING, Phase.MONITORING): raise AccessDenied(f"{self._name}: another operation is in progress ({self.phase})") self._check_user(user_id) @@ -285,6 +289,26 @@ async def async_request(self, intent: Intent, context: Context | None) -> None: LOGGER.info("%s: %s requested by user %s, waiting for approval", self._name, intent, user_id) self._publish() + async def _async_stop_now(self) -> None: + """Drop a pending request or pre-warning, or pulse a moving door to a stop, without approval.""" + if self.cancel("stop requested"): + return + if self.phase is not Phase.MONITORING: + return # pragma: no cover - the request moved on between the check and here + if not await self._send_pulse(Intent.STOP, Effect.MAY_CLOSE): + # Still moving and still supervised: the watchdog keeps running. + raise HomeAssistantError(f"{self._name}: the stop pulse was not sent (deadband or write failed)") + self._cancel_watchdog() + LOGGER.info("%s: stopped while moving", self._name) + self._settle() + self._spawn(self._notify_all(f"{self._name}: stopped")) + + @callback + def restore_fault(self, reason: str) -> None: + """Re-latch a fault that was active before a restart or reload (cleared only on site).""" + self.fault_reason = reason + self._settle() + @callback def cancel(self, reason: str) -> bool: """Cancel a pending request or a running pre-warning. Returns True if something was cancelled.""" diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index c45c8be62..045e7dc4f 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -136,6 +136,10 @@ PARALLEL_UPDATES = 0 +# Delivery of the OFF that ends a software pulse (OWNd without the timed WHAT 18 builder). +OFF_ATTEMPTS = 3 +OFF_RETRY_DELAY = 1.0 + def _clamp_slat_time(value: Any, fallback: float = DEFAULT_SLAT_TIME) -> float: """A slat time within MIN_SLAT_TIME..MAX_SLAT_TIME; ``fallback`` when the value is unusable.""" @@ -2161,10 +2165,15 @@ def handle_event(self, message: OWNMessage) -> None: class MyHOMEImpulseCover(MyHOMEEntity, CoverEntity): """An impulse-driven gate or garage door powered by a WHO=1 relay. - A service call never moves the door. ``open_cover``, ``close_cover`` and - ``stop_cover`` create an approval request through the cover's - :class:`~.access_policy.AccessController`; the relay is pulsed only after an - allowed person approves it on their phone, and a pulse that can close the + By default the cover is a hardware interface: ``open_cover``, ``close_cover`` + and ``stop_cover`` pulse the relay (with deadband and delivery checks), the + optional ``state_sensor`` decides whether a pulse is still useful, and STOP + is never withheld. Who may operate the door, and how, is the installation's + policy (automations, dashboards, blueprints). + + An ``access:`` block opts in to the supervised workflow instead: every + request then needs approval on an allowed person's phone through the cover's + :class:`~.access_policy.AccessController`, and a pulse that can close the door additionally needs confirmed safety devices, a requester in sight of the door and a pre-warning. See ``access_policy.py``. """ @@ -2222,6 +2231,9 @@ def __init__( self._last_pulse_time: float = -1e9 self._pulse_in_flight = False self._pulse_off_cancel: CALLBACK_TYPE | None = None + # True while the software OFF of the last pulse is not confirmed on the bus: the + # relay may still be energised, so the next command and the removal retry the OFF. + self._off_pending = False self._travel_timer_cancel: CALLBACK_TYPE | None = None self._attr_extra_state_attributes = { @@ -2233,32 +2245,39 @@ def __init__( if self._interface is not None: self._attr_extra_state_attributes["interface"] = self._interface - self._access = AccessController( - hass, - name=name, - config=AccessConfig.from_config(access_config, pin_code), - state_sensor=state_sensor, - travel_time=travel_time, - send_pulse=self._async_pulse, - publish=self._publish_state, - entity_id=lambda: self.entity_id, + # None: no `access:` block, the cover is a plain hardware interface. + self._access: AccessController | None = ( + AccessController( + hass, + name=name, + config=AccessConfig.from_config(access_config, pin_code), + state_sensor=state_sensor, + travel_time=travel_time, + send_pulse=self._async_pulse, + publish=self._publish_state, + entity_id=lambda: self.entity_id, + ) + if access_config is not None + else None ) @property def extra_state_attributes(self) -> dict[str, Any]: - """Static attributes plus the access policy state (never a PIN or nonce).""" + """Static attributes plus the access policy state when enabled (never a PIN or nonce).""" + if self._access is None: + return self._attr_extra_state_attributes return {**self._attr_extra_state_attributes, **self._access.attributes()} async def async_added_to_hass(self) -> None: """Register listeners when added to Home Assistant.""" await super().async_added_to_hass() - self._access.hass = self.hass - if not self._access.config.allowed_users: - LOGGER.warning( - "%s: no `access:` block with allowed_users and approvers, so every open, close and stop " - "request is refused. See the Impulse Covers section of the covers documentation.", - self._display_name, - ) + if self._access is not None: + self._access.hass = self.hass + # A latched fault must survive a restart or reload: it is only cleared on site. + last_state = await self.async_get_last_state() + fault = last_state.attributes.get("fault_reason") if last_state is not None else None + if fault: + self._access.restore_fault(str(fault)) if self._state_sensor and self.hass is not None: @callback def _sensor_state_changed(event: Any) -> None: @@ -2305,6 +2324,11 @@ async def _async_send_impulse(self) -> bool: self._pulse_off_cancel() self._pulse_off_cancel = None + # The relay of an earlier pulse may still be energised (its OFF never reached the + # bus): release it first, and refuse to start another pulse on top of it. + if self._off_pending and not await self._async_send_off(): + return False + # The deadband starts only once the gateway confirmed the write: a failed send # must not lock the user out for min_cycle_time with nothing on the bus. The # in-flight flag keeps a second request and our own echo out meanwhile. @@ -2328,27 +2352,13 @@ async def _async_send_impulse(self) -> bool: if inspect.isawaitable(write_fut): await write_fut self._last_pulse_time = time.monotonic() + self._off_pending = True @callback def _turn_off(_now: Any = None) -> None: self._pulse_off_cancel = None if self.hass is not None: - - async def _send_off() -> None: - try: - fut = await self._gateway_handler.send( - OWNLightingCommand.switch_off(self._full_where) - ) - if inspect.isawaitable(fut): - await fut - except Exception as err: - LOGGER.error( - "%s: Failed to send OFF pulse to gateway: %s", - self._display_name, - err, - ) - - self.hass.async_create_task(_send_off()) + self.hass.async_create_task(self._async_send_off()) if self.hass is not None: self._pulse_off_cancel = async_call_later( @@ -2365,6 +2375,37 @@ async def _send_off() -> None: finally: self._pulse_in_flight = False + async def _async_send_off(self) -> bool: + """Deliver the OFF that ends a software pulse, retrying; True once the bus took it. + + A lost OFF leaves the relay energised, which the gate controller reads as a + button held down. ``_off_pending`` stays set until the write is confirmed, so + the next command (and removal) try again. + """ + for attempt in range(1, OFF_ATTEMPTS + 1): + try: + fut = await self._gateway_handler.send(OWNLightingCommand.switch_off(self._full_where)) + if inspect.isawaitable(fut): + await fut + except Exception as err: + LOGGER.error( + "%s: Failed to send OFF to gateway (attempt %d/%d): %s", + self._display_name, + attempt, + OFF_ATTEMPTS, + err, + ) + if attempt < OFF_ATTEMPTS: + await asyncio.sleep(OFF_RETRY_DELAY) + continue + self._off_pending = False + return True + LOGGER.error( + "%s: the relay may still be energised: OFF was not delivered; it is retried before the next command", + self._display_name, + ) + return False + async def _async_pulse(self, intent: Intent, effect: Effect) -> bool: """Pulse the relay for an approved request and show the expected motion. @@ -2394,24 +2435,58 @@ def _finish(_now: Any = None) -> None: self._travel_timer_cancel = async_call_later(self.hass, self._travel_time, _finish) return True + async def _async_direct(self, intent: Intent) -> None: + """Pulse the relay without a policy (no `access:` block); STOP always goes out. + + With a state sensor a pulse that would only undo the request is skipped: the + door is already there or already on its way. Without one the direction is + unknown and the pulse goes out as asked. + """ + if self._state_sensor is not None: + if intent is Intent.OPEN and (self._attr_is_closed is False or self._attr_is_opening): + LOGGER.debug("%s: already open or opening, open request ignored", self._display_name) + return + if intent is Intent.CLOSE and (self._attr_is_closed is True or self._attr_is_closing): + LOGGER.debug("%s: already closed or closing, close request ignored", self._display_name) + return + effect = Effect.OPEN if intent is Intent.OPEN else Effect.MAY_CLOSE + if not await self._async_pulse(intent, effect): + raise HomeAssistantError( + f"{self._display_name}: the pulse was not sent " + f"(within the {self._min_cycle_time:.1f} s deadband, or the write failed)" + ) + async def async_open_cover(self, **kwargs: Any) -> None: - """Request approval to open the door (nothing moves until it is approved).""" - await self._access.async_request(Intent.OPEN, self._context) + """Pulse the relay to open, or (with `access:`) request approval first.""" + if self._access is None: + await self._async_direct(Intent.OPEN) + else: + await self._access.async_request(Intent.OPEN, self._context) async def async_close_cover(self, **kwargs: Any) -> None: - """Request approval to close the door (nothing moves until it is approved).""" - await self._access.async_request(Intent.CLOSE, self._context) + """Pulse the relay to close, or (with `access:`) request approval first.""" + if self._access is None: + await self._async_direct(Intent.CLOSE) + else: + await self._access.async_request(Intent.CLOSE, self._context) async def async_stop_cover(self, **kwargs: Any) -> None: - """Request approval for a stop pulse; a stop pulse can close the door, so the close policy applies.""" - await self._access.async_request(Intent.STOP, self._context) + """Pulse the relay to stop; with `access:` a stop of a moving door is immediate too.""" + if self._access is None: + await self._async_direct(Intent.STOP) + else: + await self._access.async_request(Intent.STOP, self._context) async def async_cancel_request(self) -> None: """Cancel the pending approval request or the running pre-warning.""" + if self._access is None: + raise HomeAssistantError(f"{self._display_name}: no `access:` policy is configured, nothing to cancel") await self._access.async_cancel(self._context) async def async_acknowledge_fault(self) -> None: """Clear a latched watchdog fault (allowed user, on site).""" + if self._access is None: + raise HomeAssistantError(f"{self._display_name}: no `access:` policy is configured, no fault to clear") await self._access.async_acknowledge_fault(self._context) @callback @@ -2437,7 +2512,7 @@ def handle_event(self, message: Any) -> None: # The button has already closed the relay and started the motor: pressing it # never aborts anything. Only Home Assistant's own pending request or # pre-warning is dropped, so no second pulse follows and stops or reverses it. - if self._access.cancel("wall button pressed"): + if self._access is not None and self._access.cancel("wall button pressed"): LOGGER.info( "%s: wall button pressed - the gate/door has already been actuated; " "pending request or pre-warning cancelled so no second pulse follows", @@ -2484,20 +2559,11 @@ async def async_will_remove_from_hass(self) -> None: if self._pulse_off_cancel is not None: self._pulse_off_cancel() self._pulse_off_cancel = None - try: - write_fut = await self._gateway_handler.send( - OWNLightingCommand.switch_off(self._full_where) - ) - if inspect.isawaitable(write_fut): - await write_fut - except Exception as err: - LOGGER.warning( - "%s: Failed to send OFF pulse to gateway on removal: %s", - self._display_name, - err, - ) + if self._off_pending: + await self._async_send_off() self._cancel_travel_timer() - await self._access.async_shutdown() + if self._access is not None: + await self._access.async_shutdown() await super().async_will_remove_from_hass() diff --git a/custom_components/myhome/validate.py b/custom_components/myhome/validate.py index e8cf575e4..ddd49bd77 100644 --- a/custom_components/myhome/validate.py +++ b/custom_components/myhome/validate.py @@ -558,6 +558,8 @@ def _validate_impulse_covers(data: dict[str, typing.Any]) -> dict[str, typing.An f"{device}: an impulse cover (who: 1) needs a point-to-point ; a general, area or " "group address would pulse several motors at once" ) from err + if CONF_PIN_CODE in cfg and CONF_ACCESS not in cfg: + raise Invalid(f"{device}: pin_code is checked by the access policy; add an access: block or remove it") elif CONF_ACCESS in cfg or CONF_PIN_CODE in cfg or CONF_STATE_SENSOR in cfg: raise Invalid(f"{device}: access, pin_code and state_sensor only apply to impulse covers (who: 1)") return data diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index 1a692bcd6..d068d64cd 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -289,16 +289,27 @@ In legacy configurations, users often placed a `delay: 45s` followed by `cover.s In BTicino MyHOME installations, motorized garage doors and sliding/swing entrance gates are controlled via **monostable impulse relays** on `WHO = 1` (such as `F411/2` or `F411U2`). -Because monostable relays only send a momentary pulse (`open` / `stop` / `close` / `stop`) with no inherent directional knowledge on the bus, Home Assistant implements the specialized **`MyHOMEImpulseCover`** entity equipped with the **`AccessController`** safety architecture. +Because monostable relays only send a momentary pulse (`open` / `stop` / `close` / `stop`) with no inherent directional knowledge on the bus, Home Assistant implements the specialized **`MyHOMEImpulseCover`** entity. + +**By default the cover is a plain hardware interface.** `open_cover`, `close_cover` and `stop_cover` pulse the relay straight away, from a dashboard, a wall tablet, a button, a script or a voice assistant; nobody has to approve anything on a phone. The integration only does what belongs to the hardware: + +- It sends the pulse (hardware-timed WHAT 18 where the installed OWNd has it, ON followed by OFF otherwise) and keeps a deadband (`min_cycle_time`) so a double tap cannot send two pulses. A pulse that was not sent raises an error. +- With a `state_sensor` it knows the real position: `open_cover` on an open or opening door and `close_cover` on a closed or closing door send nothing, because a pulse would only undo the request. Without a sensor the position is assumed and every pulse goes out as asked. +- `stop_cover` is never withheld. +- If the OFF that ends a software pulse cannot be delivered it is retried, and the relay is released before the next pulse. + +Who may operate the door, from where and with which confirmation is a policy of your installation. Build it with automations or blueprints, or switch on the optional supervised workflow below with an `access:` block. ### 🛡️ Safety Architecture & Threat Model > [!CAUTION] > **HOME ASSISTANT IS A SUPERVISORY AUTOMATION SYSTEM, NOT A CERTIFIED SAFETY SYSTEM** > Primary entrapment and crush protection (e.g. European Standard **EN 12453 / EN 12445** or North American **UL 325**) **must be provided by the motorized gate or garage door hardware itself**: certified infrared photocells, safety contact edges, and mechanical force limiters built into the motor control board. -> Home Assistant's `AccessController` is designed exclusively to prevent Home Assistant from *causing* an unintended, accidental, or unattended movement. +> The optional `AccessController` is designed exclusively to prevent Home Assistant from *causing* an unintended, accidental, or unattended movement. + +#### 📊 Optional Human-in-the-Loop Access Policy (`access:`) -#### 📊 Human-in-the-Loop Access Flow Architecture +Everything in this part applies **only to a cover that has an `access:` block**. Without it the cover pulses directly, as described above. ```text [ User / Dashboard / Widget / Siri / Alexa ] @@ -388,10 +399,10 @@ Because monostable relays only send a momentary pulse (`open` / `stop` / `close` │ │ │ [ Gate Moves ] ``` -#### Key Principles of the Human-in-the-Loop Policy: +#### Key Principles of the Optional Human-in-the-Loop Policy (with `access:`): -1. **Zero Direct Movement on Service Calls**: - Calls to `cover.open_cover`, `close_cover`, or `stop_cover` (whether from dashboards, mobile widgets, voice assistants, or automations) **never pulse the relay directly**. Instead, they create an authenticated approval request for an authorized person. +1. **Zero Direct Movement on Open and Close Calls**: + Calls to `cover.open_cover` or `close_cover` (whether from dashboards, mobile widgets, voice assistants, or automations) **never pulse the relay directly**. Instead, they create an authenticated approval request for an authorized person. Automations and scripts are refused because they have no person behind them. **`stop_cover` is the exception**: while a request is pending, the pre-warning runs or the door moves under supervision, it cancels the request or pulses the relay to stop the door at once, for anyone, without approval. 2. **Cryptographic Single-Use Nonce & User Binding**: Each approval request generates a 128-bit CSPRNG token (`secrets.token_urlsafe(16)`), tightly bound to the requesting `user_id`, target entity, and physical direction. Replayed, stale, or forged approval actions are discarded. 3. **Biometric Phone Unlock Requirement**: @@ -403,13 +414,27 @@ Because monostable relays only send a momentary pulse (`open` / `stop` / `close` - Any external close-block switch to report `off`. - Any missing, unavailable, or non-binary sensor state immediately fails closed. 5. **Audible / Visual Pre-Warning & Watchdog**: - Before a close pulse, an optional pre-warning flasher (`prewarn_light`) triggers for `prewarn_seconds`. After the pulse, an anti-stuck watchdog monitors motion: if the expected state is not reached within `travel_time + watchdog_margin`, a latching fault is asserted with **zero automatic retries**. A latched fault can only be cleared on site by an authorized user using the `myhome.acknowledge_cover_fault` action; an in-flight request or pre-warning can be cancelled at any time using `myhome.cancel_cover_request` (see [Services](services.md#14-myhomecancel_cover_request)). + Before a close pulse, an optional pre-warning flasher (`prewarn_light`) triggers for `prewarn_seconds`. After the pulse, an anti-stuck watchdog monitors motion: if the expected state is not reached within `travel_time + watchdog_margin`, a latching fault is asserted with **zero automatic retries**. A latched fault survives a restart or reload of Home Assistant and can only be cleared on site by an authorized user using the `myhome.acknowledge_cover_fault` action; an in-flight request or pre-warning can be cancelled at any time using `myhome.cancel_cover_request` (see [Services](services.md#14-myhomecancel_cover_request)). 6. **A Wall Button Starts the Gate, It Does Not Stop It**: A press of the wall button (or any other keypad on the bus) closes the relay and energizes the motor by itself; Home Assistant only sees the resulting `*1*1*WHERE##` frame afterwards. When such a frame arrives during an approval request or the pre-warning countdown, Home Assistant therefore *drops its own pending request* so that no second pulse follows and stops or reverses the gate. The movement the button started is not aborted. Use the hardware's own stop input or the remote control for an emergency stop. ### ⚙️ Configuring an Impulse Cover -An impulse cover needs `who: 1`, a single point-to-point `where` and, to be of any use, an `access:` block. **Without `access:` (or with an empty `allowed_users`) every open, close and stop request is refused** with `AccessDenied` and a warning is logged at start-up: the policy is fail-closed. +An impulse cover needs `who: 1` and a single point-to-point `where`. The minimal configuration is a hardware interface with no approval step: + +```yaml +# /config/myhome.yaml +00:03:50:XX:XX:XX: + cover: + garage_door: + who: 1 + where: "71" + name: Garage door + device_class: garage + state_sensor: binary_sensor.garage_door_closed # optional: off = closed, on = not closed +``` + +Add an `access:` block to opt in to the supervised workflow (phone approval, PIN, presence, camera, pre-warning, safety audit). **With `access:` an empty `allowed_users` refuses every open and close request** with `AccessDenied`: that policy is fail-closed. The full example: ```yaml # /config/myhome.yaml @@ -425,7 +450,7 @@ An impulse cover needs `who: 1`, a single point-to-point `where` and, to be of a travel_time: 25 # seconds, for the dashboard estimate and the watchdog pulse_duration: 0.5 # seconds the relay is closed (0.2 - 2.0) min_cycle_time: 5 # seconds between two pulses (1 - 120) - pin_code: "1234" # optional second factor, asked on the phone + pin_code: "1234" # optional second factor, asked on the phone; needs access: access: allowed_users: # Home Assistant user ids - 1a2b3c4d5e6f47a8b9c0d1e2f3a4b5c6 @@ -444,11 +469,11 @@ An impulse cover needs `who: 1`, a single point-to-point `where` and, to be of a | Key | Meaning | | :--- | :--- | -| `state_sensor` | Binary sensor of the ground-truth position. Without it every pulse is treated as one that can close the door. | +| `state_sensor` | Binary sensor of the ground-truth position. Without it the position is assumed and, with `access:`, every pulse is treated as one that can close the door. | | `pulse_duration` | Relay pulse in seconds. `0.5` uses the timed WHAT 18 command where the installed OWNd has it, and ON followed by OFF otherwise. | | `min_cycle_time` | Deadband after a pulse that reached the bus. A failed send does not start it. | -| `pin_code` | PIN asked in the notification. A reply that carries no text is refused without counting as a wrong attempt. | -| `access.*` | See the flow above: `allowed_users`, `approvers`, `remote_close`, `camera`, `safety_devices_verified`, `safety_check_days`, `prewarn_light`, `prewarn_seconds`, `close_block_entity`, `watchdog_margin`, `approval_timeout`. | +| `pin_code` | PIN asked in the approval notification; only valid together with `access:`. A reply that carries no text is refused without counting as a wrong attempt. | +| `access.*` | Optional. Turns on the supervised workflow, see the flow above: `allowed_users`, `approvers`, `remote_close`, `camera`, `safety_devices_verified`, `safety_check_days`, `prewarn_light`, `prewarn_seconds`, `close_block_entity`, `watchdog_margin`, `approval_timeout`. | > [!NOTE] > `type: impulse_relay` together with `who: 2` (and `type: shutter` or `standard` together with `who: 1`) is rejected when the configuration is validated; it used to fall back to an ordinary shutter silently. @@ -481,7 +506,7 @@ Locks exist only when they are configured here (or were created earlier and are │ ▼ │ │ ▼ │ │ PLATFORM: cover (impulse) │ │ PLATFORM: lock │ │ │ │ │ │ │ - │ - Enforces AccessController │ │ - Direct momentary pulse (1-3s) │ + │ - Optional AccessController │ │ - Direct momentary pulse (1-3s) │ │ - Dual binary sensor feedback │ │ - Optional PIN authentication │ │ - Biometric challenge-response nonce │ │ - Automatic re-lock state machine │ │ - 5s pre-warning flasher │ │ - Doorbell one-tap unlock safe │ @@ -492,8 +517,8 @@ Locks exist only when they are configured here (or were created earlier and are | Physical Device | Required Entity Platform | Why | | :--- | :--- | :--- | -| **Motorized Gates & Garage Doors** | `cover` (`type: impulse_relay`) | Heavy kinetic machinery with kinetic entrapment/crush risk. Must use `AccessController` with sensor validation and pre-warning. **Never configure a motorized gate or garage as a `lock`**. | -| **Pedestrian Door Strikes** | `lock:` block, `who: 6` only | Low-mass momentary electric door buzzers (elettroserrature) on a WHO 6 entrance panel that release a pedestrian latch. A `lock` cannot be a WHO 1 relay: that would bypass the access policy of a gate, so `who: 1` is rejected there. | +| **Motorized Gates & Garage Doors** | `cover` (`type: impulse_relay`) | Heavy kinetic machinery with kinetic entrapment/crush risk. Use a `state_sensor`, and the optional `access:` workflow where you want supervision. **Never configure a motorized gate or garage as a `lock`**: its door lock-style unlock would carry no sensor feedback. | +| **Pedestrian Door Strikes** | `lock:` block, `who: 6` only | Low-mass momentary electric door buzzers (elettroserrature) on a WHO 6 entrance panel that release a pedestrian latch. A `lock` cannot be a WHO 1 relay: a motorized gate needs the cover's sensor feedback and stop handling, so `who: 1` is rejected there. | --- diff --git a/docs/configuration/services.md b/docs/configuration/services.md index 38fb84643..8b249db13 100644 --- a/docs/configuration/services.md +++ b/docs/configuration/services.md @@ -305,7 +305,7 @@ data: ## 14. `myhome.cancel_cover_request` -Cancels an active or pending approval request or a running 5-second pre-warning countdown of an impulse gate or garage door (`type: impulse_relay`). Cancelling is always safe: no bus impulse is sent, no relay energizes, and nothing moves. +Cancels an active or pending approval request or a running 5-second pre-warning countdown of an impulse gate or garage door (`type: impulse_relay`). Cancelling is always safe: no bus impulse is sent, no relay energizes, and nothing moves. Only meaningful for a cover with an `access:` block; on a cover without one the service raises an error. ### Fields | Parameter | Type | Required | Description | Example | @@ -323,7 +323,7 @@ target: ## 15. `myhome.acknowledge_cover_fault` -Clears the safety fault latched when a gate or garage door did not reach its expected state after a pulse (indicating a physical obstacle, safety trip, or reversal). Only an allowed user who is physically at home may clear the fault. Until acknowledged, all remote and automated closing commands from Home Assistant remain strictly blocked. +Clears the safety fault latched when a gate or garage door did not reach its expected state after a pulse (indicating a physical obstacle, safety trip, or reversal). Only an allowed user who is physically at home may clear the fault. Until acknowledged, all remote and automated closing commands from Home Assistant remain strictly blocked. The fault survives a restart or reload of Home Assistant. Only meaningful for a cover with an `access:` block. ### Fields | Parameter | Type | Required | Description | Example | diff --git a/docs/configuration/supported_functions.md b/docs/configuration/supported_functions.md index afc8a45d8..809332ebc 100644 --- a/docs/configuration/supported_functions.md +++ b/docs/configuration/supported_functions.md @@ -51,7 +51,7 @@ Legend: ✅ supported · 👁️ read-only · ⚙️ via a service, not an entit | Function | Status | Notes | | :--- | :---: | :--- | | Open / close / stop | ✅ | Standard WHO 2 shutter actuators. | -| Impulse cover (WHO 1) | ✅ | Pulse-driven gates and garage doors with AccessController multi-factor approval, sensor direction checks, pre-warning, and watchdog. | +| Impulse cover (WHO 1) | ✅ | Pulse-driven gates and garage doors: direct pulses with deadband and sensor feedback by default, and an optional `access:` workflow with phone approval, pre-warning, and watchdog. | | Position (position-reporting actuators) | ✅ | Requires `advanced_shutter: true` in `myhome.yaml`; it is not learned from the bus. | | Position (timed actuators) | ✅ | Virtual position from the travel time: `travel_time` in YAML, measured with `myhome.calibrate_cover`, or set by hand with `myhome.set_cover_travel_time`. | | Tilt | ❌ | Slat commands are parsed by OWNd but not exposed. | diff --git a/scripts/update_supported_domains.py b/scripts/update_supported_domains.py index 3ab91c74c..6725e8e03 100644 --- a/scripts/update_supported_domains.py +++ b/scripts/update_supported_domains.py @@ -64,7 +64,7 @@ "capabilities": ( "Motorized shutters, blinds, roll-ups with state tracking, " "position-reporting actuators & virtual travel-time positioning; " - "impulse gates & garage doors (`WHO=1`) with AccessController multi-factor safety approval" + "impulse gates & garage doors (`WHO=1`) with sensor feedback and an optional multi-factor approval workflow" ), }, "lock": { diff --git a/tests/test_access_policy.py b/tests/test_access_policy.py index adfc3cf07..3b60e94dd 100644 --- a/tests/test_access_policy.py +++ b/tests/test_access_policy.py @@ -732,3 +732,82 @@ async def _failing_pulse(intent, effect): await h.ctrl.async_shutdown() + + +# ── STOP is never withheld; a latched fault survives a restart ────────── + + +async def _monitoring_close(hass: HomeAssistant, **harness_kwargs): + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id), **harness_kwargs) + hass.states.async_set(SENSOR, STATE_ON) + await h.ctrl.async_request(Intent.CLOSE, Context(user_id=parent.id)) + await _press(hass, _approve_action(calls)["action"], parent.id) + await _wait_phase(h.ctrl, Phase.MONITORING) + return h, calls + + +async def test_stop_of_a_moving_door_needs_no_approval_and_ends_supervision(hass: HomeAssistant): + """A STOP while the door moves pulses at once, for anyone, and does not end in a fault.""" + h, calls = await _monitoring_close(hass) + h.pulses.clear() + + await h.ctrl.async_request(Intent.STOP, Context(user_id=None)) + + assert h.pulses == [(Intent.STOP, Effect.MAY_CLOSE)] + assert h.ctrl.phase is Phase.IDLE + assert any("Garage 1: stopped" in c.data["message"] for c in calls) + _fire(hass, 30) # the watchdog is gone: a stopped door is not a fault + await hass.async_block_till_done() + assert h.ctrl.fault_reason is None + await h.ctrl.async_shutdown() + + +async def test_failed_stop_pulse_keeps_the_door_supervised(hass: HomeAssistant): + """If the stop pulse is dropped the door is still moving, so the watchdog keeps running.""" + h, _ = await _monitoring_close(hass) + h.pulse_ok = False + + with pytest.raises(HomeAssistantError, match="stop pulse was not sent"): + await h.ctrl.async_request(Intent.STOP, Context(user_id=None)) + + assert h.ctrl.phase is Phase.MONITORING + _fire(hass, 21) + await hass.async_block_till_done() + assert h.ctrl.phase is Phase.FAULT + await h.ctrl.async_shutdown() + + +async def test_stop_drops_a_pending_request_instead_of_pulsing(hass: HomeAssistant): + """A STOP while an approval is pending cancels the request; nothing is pulsed.""" + parent = await _user(hass, "Parent") + async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id)) + hass.states.async_set(SENSOR, STATE_ON) + await h.ctrl.async_request(Intent.CLOSE, Context(user_id=parent.id)) + assert h.ctrl.phase is Phase.PENDING + + await h.ctrl.async_request(Intent.STOP, Context(user_id=None)) + + assert h.ctrl.phase is Phase.IDLE + assert h.pulses == [] + await h.ctrl.async_shutdown() + + +async def test_restored_fault_stays_latched_until_acknowledged_on_site(hass: HomeAssistant): + """A fault from before a restart blocks closing again and is cleared only by an on-site user.""" + parent = await _user(hass, "Parent") + async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id)) + hass.states.async_set(SENSOR, STATE_ON) + ctx = Context(user_id=parent.id) + + h.ctrl.restore_fault("did not close: possible obstacle or reversal") + + assert h.ctrl.phase is Phase.FAULT + assert h.ctrl.attributes()["fault_reason"] == "did not close: possible obstacle or reversal" + with pytest.raises(AccessDenied, match="ended in a fault"): + await h.ctrl.async_request(Intent.CLOSE, ctx) + await h.ctrl.async_acknowledge_fault(ctx) + assert h.ctrl.phase is Phase.IDLE diff --git a/tests/test_component_impulse_cover.py b/tests/test_component_impulse_cover.py index 1aeb3e1a9..072933403 100644 --- a/tests/test_component_impulse_cover.py +++ b/tests/test_component_impulse_cover.py @@ -16,6 +16,7 @@ Platform, ) from homeassistant.core import Context, HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.util import dt as dt_util from OWNd.message import ( OWNEvent, @@ -35,6 +36,9 @@ RemoteClose, ) from custom_components.myhome.const import ( + CONF_ACCESS, + CONF_ALLOWED_USERS, + CONF_APPROVERS, CONF_DEVICE_CLASS, CONF_MIN_CYCLE_TIME, CONF_PIN_CODE, @@ -54,6 +58,12 @@ ) from tests.conftest import attach_runtime + +@pytest.fixture(autouse=True) +def _no_off_retry_delay(monkeypatch): + monkeypatch.setattr("custom_components.myhome.cover.OFF_RETRY_DELAY", 0.0) + + PULSE_FRAME = "*1*18*22##" if hasattr(OWNLightingCommand, "switch_on_timed") else "*1*1*22##" @@ -90,6 +100,7 @@ async def test_impulse_cover_setup_restores_and_configures(hass: HomeAssistant, CONF_PULSE_DURATION: 0.5, CONF_MIN_CYCLE_TIME: 5.0, CONF_PIN_CODE: "1234", + CONF_ACCESS: {CONF_ALLOWED_USERS: ["u1"], CONF_APPROVERS: {"u1": "notify.mobile_app_phone"}}, }, } }, @@ -149,6 +160,7 @@ def impulse_gate(hass: HomeAssistant, mock_gateway): manufacturer="BTicino", model="Impulse Cover", gateway=mock_gateway, + access_config={}, ) cover.hass = hass cover.entity_id = "cover.driveway_gate" @@ -668,3 +680,165 @@ def test_impulse_cover_wall_button_log_says_the_gate_already_moved(impulse_gate, impulse_gate.handle_event(OWNEvent.parse("*1*1*22##")) impulse_gate._access.cancel.assert_called_once() assert "already been actuated" in caplog.text + + +@pytest.fixture +def direct_gate(hass: HomeAssistant, mock_gateway): + """An impulse gate without an `access:` block: a plain hardware interface.""" + built = [] + + def build(state_sensor=None): + with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): + cover = MyHOMEImpulseCover( + hass=hass, + name="Garage", + entity_name=None, + device_id="24", + who="1", + where="24", + interface=None, + device_class=CoverDeviceClass.GARAGE, + travel_time=10.0, + pulse_duration=0.5, + min_cycle_time=0.0, + pin_code=None, + state_sensor=state_sensor, + manufacturer="BTicino", + model="Impulse Cover", + gateway=mock_gateway, + ) + cover.hass = hass + cover.entity_id = "cover.garage" + cover.async_write_ha_state = MagicMock() + built.append(cover) + return cover + + yield build + for cover in built: + cover._cancel_travel_timer() + if cover._pulse_off_cancel is not None: + cover._pulse_off_cancel() + + +async def test_direct_cover_needs_no_approval_and_no_user(hass: HomeAssistant, direct_gate, mock_gateway): + """Without `access:` a dashboard, button or automation pulses the relay at once.""" + cover = direct_gate() + assert cover._access is None + assert "access_phase" not in cover.extra_state_attributes + + await cover.async_open_cover() + assert str(mock_gateway.send.call_args[0][0]) == PULSE_FRAME.replace("22", "24") + assert cover.is_opening is True + + await cover.async_stop_cover() + assert mock_gateway.send.call_count == 2 + assert cover.is_opening is False + + await cover.async_close_cover() + assert mock_gateway.send.call_count == 3 + assert cover.is_closing is True + + +async def test_direct_cover_with_sensor_skips_a_pulse_that_would_undo_the_request( + hass: HomeAssistant, direct_gate, mock_gateway +): + """Open on an open door and close on a closed door send nothing; STOP is always sent.""" + cover = direct_gate(state_sensor="binary_sensor.garage_contact") + cover._handle_sensor_update(STATE_OFF) # closed + await cover.async_close_cover() + assert mock_gateway.send.call_count == 0 + + await cover.async_open_cover() + assert mock_gateway.send.call_count == 1 + + cover._handle_sensor_update(STATE_ON) # open + await cover.async_open_cover() + assert mock_gateway.send.call_count == 1 + await cover.async_stop_cover() + assert mock_gateway.send.call_count == 2 + await cover.async_close_cover() + assert mock_gateway.send.call_count == 3 + + +async def test_direct_cover_reports_a_dropped_pulse(hass: HomeAssistant, direct_gate, mock_gateway): + """A pulse inside the deadband or a failed write is an error, not a silent no-op.""" + cover = direct_gate() + cover._min_cycle_time = 5.0 + await cover.async_open_cover() + with pytest.raises(HomeAssistantError, match="pulse was not sent"): + await cover.async_stop_cover() + + +async def test_direct_cover_has_no_policy_services(hass: HomeAssistant, direct_gate): + """Cancelling a request or acknowledging a fault only makes sense with `access:`.""" + cover = direct_gate() + with pytest.raises(HomeAssistantError, match="no `access:` policy"): + await cover.async_cancel_request() + with pytest.raises(HomeAssistantError, match="no `access:` policy"): + await cover.async_acknowledge_fault() + cover.handle_event(OWNEvent.parse("*1*1*24##")) # a wall button works without a policy + await cover.async_will_remove_from_hass() + + +async def test_stop_of_a_supervised_gate_is_immediate_while_it_moves(hass: HomeAssistant, impulse_gate, mock_gateway): + """With `access:`, STOP of a moving gate does not wait for a phone.""" + from custom_components.myhome.access_policy import Phase + + impulse_gate._access.phase = Phase.MONITORING + await impulse_gate.async_stop_cover() + assert mock_gateway.send.call_count == 1 + assert impulse_gate.extra_state_attributes["access_phase"] == "idle" + + +async def test_latched_fault_is_restored_after_a_restart(hass: HomeAssistant, impulse_gate): + """The fault attribute of the last state re-latches the fault when the entity is added.""" + last = MagicMock() + last.attributes = {"fault_reason": "did not close: possible obstacle or reversal"} + with ( + patch.object(MyHOMEImpulseCover, "async_get_last_state", AsyncMock(return_value=last)), + patch("custom_components.myhome.cover.MyHOMEEntity.async_added_to_hass", AsyncMock()), + ): + await impulse_gate.async_added_to_hass() + assert impulse_gate.extra_state_attributes["access_phase"] == "fault" + assert impulse_gate.extra_state_attributes["fault_reason"] == "did not close: possible obstacle or reversal" + + with ( + patch.object(MyHOMEImpulseCover, "async_get_last_state", AsyncMock(return_value=None)), + patch("custom_components.myhome.cover.MyHOMEEntity.async_added_to_hass", AsyncMock()), + ): + impulse_gate._access.fault_reason = None + await impulse_gate.async_added_to_hass() + assert impulse_gate.extra_state_attributes["fault_reason"] is None + + +async def test_lost_off_is_retried_and_blocks_the_next_pulse(hass: HomeAssistant, impulse_gate, mock_gateway, monkeypatch): + """A software OFF that never reaches the bus is retried, and the relay is released before the next pulse.""" + monkeypatch.delattr(OWNLightingCommand, "switch_on_timed", raising=False) + impulse_gate._min_cycle_time = 0.0 + assert await impulse_gate._async_send_impulse() is True + assert impulse_gate._off_pending is True + + mock_gateway.send.reset_mock() + mock_gateway.send.side_effect = RuntimeError("bus down") + assert await impulse_gate._async_send_off() is False + assert mock_gateway.send.call_count == 3 # OFF_ATTEMPTS + assert impulse_gate._off_pending is True + + # The next command first tries to release the relay and does not stack another ON on it. + mock_gateway.send.reset_mock() + assert await impulse_gate._async_send_impulse() is False + assert [str(c[0][0]) for c in mock_gateway.send.call_args_list] == ["*1*0*22##"] * 3 + + # Once the bus is back the OFF goes out first, then the new pulse. + mock_gateway.send.reset_mock() + mock_gateway.send.side_effect = None + assert await impulse_gate._async_send_impulse() is True + assert [str(c[0][0]) for c in mock_gateway.send.call_args_list] == ["*1*0*22##", "*1*1*22##"] + impulse_gate._pulse_off_cancel() + + # A removal with the OFF still owed delivers it. + mock_gateway.send.reset_mock() + impulse_gate._pulse_off_cancel = None + await impulse_gate.async_will_remove_from_hass() + assert str(mock_gateway.send.call_args[0][0]) == "*1*0*22##" + assert impulse_gate._off_pending is False diff --git a/tests/test_validate.py b/tests/test_validate.py index b43cb3915..303f296c0 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -884,3 +884,9 @@ def test_matching_types_validate(self): assert "1-72" in self._cover(**{CONF_WHO: "1"}) assert "2-72" in self._cover(**{CONF_WHO: "2", "type": "shutter"}) + def test_pin_code_needs_an_access_block(self): + """A PIN is only checked by the access policy; a plain impulse cover needs none.""" + assert "1-72" in self._cover(**{CONF_WHO: "1"}) + with pytest.raises(Invalid, match="pin_code is checked by the access policy"): + self._cover(**{CONF_WHO: "1", "pin_code": "1234"}) + From ce9fe5dc9ef90dcdead68c78a52b2a18c9640fb3 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 13 Oct 2026 15:44:20 +0200 Subject: [PATCH 20/26] test(cover): count pulse frames only, so the direct-mode tests hold on OWNd without switch_on_timed (#667) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_component_impulse_cover.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/tests/test_component_impulse_cover.py b/tests/test_component_impulse_cover.py index 072933403..bd3d5b2a7 100644 --- a/tests/test_component_impulse_cover.py +++ b/tests/test_component_impulse_cover.py @@ -682,6 +682,11 @@ def test_impulse_cover_wall_button_log_says_the_gate_already_moved(impulse_gate, assert "already been actuated" in caplog.text +def _pulses(gateway) -> int: + """Frames that start a pulse; the OFF that ends a software pulse on older OWNd is not one.""" + return sum(1 for c in gateway.send.call_args_list if not str(c[0][0]).startswith("*1*0*")) + + @pytest.fixture def direct_gate(hass: HomeAssistant, mock_gateway): """An impulse gate without an `access:` block: a plain hardware interface.""" @@ -727,15 +732,15 @@ async def test_direct_cover_needs_no_approval_and_no_user(hass: HomeAssistant, d assert "access_phase" not in cover.extra_state_attributes await cover.async_open_cover() - assert str(mock_gateway.send.call_args[0][0]) == PULSE_FRAME.replace("22", "24") + assert [str(c[0][0]) for c in mock_gateway.send.call_args_list][-1] == PULSE_FRAME.replace("22", "24") assert cover.is_opening is True await cover.async_stop_cover() - assert mock_gateway.send.call_count == 2 + assert _pulses(mock_gateway) == 2 assert cover.is_opening is False await cover.async_close_cover() - assert mock_gateway.send.call_count == 3 + assert _pulses(mock_gateway) == 3 assert cover.is_closing is True @@ -746,18 +751,18 @@ async def test_direct_cover_with_sensor_skips_a_pulse_that_would_undo_the_reques cover = direct_gate(state_sensor="binary_sensor.garage_contact") cover._handle_sensor_update(STATE_OFF) # closed await cover.async_close_cover() - assert mock_gateway.send.call_count == 0 + assert _pulses(mock_gateway) == 0 await cover.async_open_cover() - assert mock_gateway.send.call_count == 1 + assert _pulses(mock_gateway) == 1 cover._handle_sensor_update(STATE_ON) # open await cover.async_open_cover() - assert mock_gateway.send.call_count == 1 + assert _pulses(mock_gateway) == 1 await cover.async_stop_cover() - assert mock_gateway.send.call_count == 2 + assert _pulses(mock_gateway) == 2 await cover.async_close_cover() - assert mock_gateway.send.call_count == 3 + assert _pulses(mock_gateway) == 3 async def test_direct_cover_reports_a_dropped_pulse(hass: HomeAssistant, direct_gate, mock_gateway): From c4e888811abbfc5a0bae746bf361857edd84a937 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Tue, 13 Oct 2026 16:02:16 +0200 Subject: [PATCH 21/26] test(door_entry): patch the legacy call flags on the class, they are read-only properties in current OWNd (#667) Co-Authored-By: Claude Sonnet 5.5 --- tests/test_door_entry_events.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/test_door_entry_events.py b/tests/test_door_entry_events.py index c14e5381e..5dc99a148 100644 --- a/tests/test_door_entry_events.py +++ b/tests/test_door_entry_events.py @@ -184,14 +184,15 @@ def test_unreadable_call_kind_is_none(): assert GatewayEventDispatcher._intercom_call_kind(MagicMock(call_kind=None, _what_param=[])) is None -async def test_doorbell_ignores_library_call_flags_for_who8(hass: HomeAssistant): +async def test_doorbell_ignores_library_call_flags_for_who8(hass: HomeAssistant, monkeypatch): """Older OWNd flags every WHO 8 WHAT 1 as an incoming call; the call kind still decides.""" gateway = _doorbell_gateway(hass) captured = async_capture_events(hass, "myhome_doorbell_event") internal = OWNEvent.parse("*8*1#6#2*74##") - internal.is_call = True # type: ignore[attr-defined] - internal.is_incoming_call = True # type: ignore[attr-defined] + # The flags are read-only properties on current OWNd: replace them on the class for this test. + monkeypatch.setattr(type(internal), "is_call", True, raising=False) + monkeypatch.setattr(type(internal), "is_incoming_call", True, raising=False) await gateway._process_message(internal) assert captured == [] From 852334b4e9f73d5fad14ac48487be14514e19a72 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Fri, 16 Oct 2026 21:50:44 +0200 Subject: [PATCH 22/26] test(door-entry): verify WHO 8 call kind 7 and 59-frame intercom-ts10 firmware oracle conformance --- custom_components/myhome/gateway_events.py | 11 ++-- tests/test_door_entry_events.py | 8 +-- tests/test_issue_667_intercom_trace.py | 59 ++++++++++++++++++++++ 3 files changed, 69 insertions(+), 9 deletions(-) diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index aa9521333..66f7240b7 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -596,11 +596,12 @@ async def process_message(self, message: Any) -> None: who = getattr(message, "who", None) what = getattr(message, "_what", None) where_val = getattr(message, "where", "") - # WHO 8 WHAT 1 starts any call session. Only the entrance panels ring the - # door; handset-to-handset calls (kind 6) and pager broadcasts (kind 14) - # must not, and a kind that cannot be read fails closed. Older OWNd - # releases flag every WHAT 1 as an incoming call, so WHO 8 never trusts - # the library's is_call / is_incoming_call. + # WHO 8 WHAT 1 starts any call session. Only the entrance panels (kinds 1-4) + # ring the door; internal handset calls (kind 6), external intercom calls + # across SCS interfaces (kind 7), and pager broadcasts (kind 14) from + # libqtdevices TS10_1_0_23 must not, and a kind that cannot be read fails + # closed. Older OWNd releases flag every WHAT 1 as an incoming call, so WHO 8 + # never trusts the library's is_call / is_incoming_call. is_who8_call = who == 8 and what == 1 and self._intercom_call_kind(message) in ENTRANCE_PANEL_KINDS is_broadcast = getattr(message, "is_broadcast_call", who == 6 and what == 6 and str(where_val) == "4100") # WHO 6 WHAT 20 is not in the published WHO 6 table (WHAT 0, 6, 9, 10, 11, 12, diff --git a/tests/test_door_entry_events.py b/tests/test_door_entry_events.py index 5dc99a148..ace16c733 100644 --- a/tests/test_door_entry_events.py +++ b/tests/test_door_entry_events.py @@ -113,8 +113,8 @@ def handle_dispatcher(payload): assert len(dispatcher_payloads) == 4 assert dispatcher_payloads[3] == captured_events[3].data - # 6. Internal handset call and pager broadcast are not doorbell rings - for raw in ("*8*1#6#2#11*16##", "*8*1#14#2#11*4##"): + # 6. Internal handset call (kind 6), external intercom call (kind 7), and pager broadcast (kind 14) are not doorbell rings + for raw in ("*8*1#6#2#11*16##", "*8*1#7#2#11*16##", "*8*1#14#2#11*4##"): await gateway._process_message(OWNEvent.parse(raw)) assert len(captured_events) == 4 assert len(dispatcher_payloads) == 4 @@ -161,8 +161,8 @@ async def test_doorbell_only_for_entrance_panel_calls(hass: HomeAssistant): gateway = _doorbell_gateway(hass) captured = async_capture_events(hass, "myhome_doorbell_event") - # Handset to handset (kind 6) and a pager broadcast (kind 14) are not a visitor - for frame in ("*8*1#6#2*74##", "*8*1#6#4#73*74##", "*8*1#14#2*4##"): + # Internal handset (kind 6), external intercom (kind 7), and pager broadcast (kind 14) are not a visitor + for frame in ("*8*1#6#2*74##", "*8*1#6#4#73*74##", "*8*1#7#2*74##", "*8*1#7#2#11*16##", "*8*1#14#2*4##"): await gateway._process_message(OWNEvent.parse(frame)) # A call whose kind cannot be read fails closed for frame in ("*8*1*74##", "*8*1#x#4*74##"): diff --git a/tests/test_issue_667_intercom_trace.py b/tests/test_issue_667_intercom_trace.py index 1d1a1dc9c..1ecf300ca 100644 --- a/tests/test_issue_667_intercom_trace.py +++ b/tests/test_issue_667_intercom_trace.py @@ -173,3 +173,62 @@ def _capture_dispatcher(msg: OWNMessage) -> None: finally: unsub_event() unsub_dispatcher() + + +@pytest.mark.asyncio +async def test_intercom_ts10_firmware_oracle_conformance(hass: HomeAssistant) -> None: + """Verify all 59 intercom-ts10 WHO 8 oracle frames and MH200 020102 trace verdicts.""" + oracle_path = Path(__file__).resolve().parent / "golden" / "firmware_oracle.json" + verdicts = json.loads(oracle_path.read_text(encoding="utf-8"))["verdicts"] + + # 1. Extract all 59 WHO 8 frames from the intercom-ts10 oracle suite + # (derived from libqtdevices TS10_1_0_23 videodoorentry_device.cpp: kinds 6, 7, 14) + intercom_ts10_frames = sorted( + { + frame + for frame, entries in verdicts.items() + if any(e.get("suite") == "intercom-ts10" for e in entries) + } + ) + assert len(intercom_ts10_frames) == 59 + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.50", + CONF_PORT: 20000, + CONF_PASSWORD: "open", + CONF_MAC: "00:03:50:00:88:89", + }, + entry_id="test_intercom_ts10_oracle_entry", + ) + entry.add_to_hass(hass) + gateway = MyHOMEGatewayHandler(hass, entry) + + captured_events: list[Event] = [] + unsub_event = hass.bus.async_listen( + "myhome_doorbell_event", + lambda event: captured_events.append(event), + ) + + try: + for raw in intercom_ts10_frames: + msg = OWNEvent.parse(raw) + assert msg is not None, f"Failed to parse intercom-ts10 frame: {raw}" + assert msg.who == 8 + await gateway._process_message(msg) + + await hass.async_block_till_done() + # None of the 59 internal/external/pager intercom-ts10 frames is an entrance panel call (kinds 1-4) + assert captured_events == [] + finally: + unsub_event() + + # 2. Verify emulated MH200 (020102) oracle verdicts for WHO 13 identification and WHO 1 impulse relays + mh200_id = next(e for e in verdicts["*#13**15##"] if e["product"] == "MH200") + assert mh200_id["emitted_own"] == ["*#13**15*4##"] + for impulse_cmd in ("*1*1*31##", "*1*0*31##"): + mh200_cmd = next(e for e in verdicts[impulse_cmd] if e["product"] == "MH200") + assert mh200_cmd["reply"] == "ack" + assert mh200_cmd["verdict"] == "out" + assert len(mh200_cmd["bus_frames"]) == 1 From b86e3093e12821f45fdfe82581bca9acb7a4f592 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sun, 18 Oct 2026 13:32:06 +0200 Subject: [PATCH 23/26] fix(cover,lock,access): resolve PR #667 technical audit findings --- .../myhome/door_gate_motion_notification.yaml | 3 +- .../doorbell_intercom_notification.yaml | 13 +- custom_components/myhome/access_policy.py | 44 ++++- custom_components/myhome/cover.py | 96 +++++++--- custom_components/myhome/gateway_events.py | 50 ++++- custom_components/myhome/light.py | 49 +++-- custom_components/myhome/lock.py | 18 +- custom_components/myhome/translations/fr.json | 8 + custom_components/myhome/translations/it.json | 8 + custom_components/myhome/validate.py | 27 ++- docs/configuration/covers.md | 14 +- tests/test_access_policy.py | 55 ++++++ tests/test_component_impulse_cover.py | 176 +++++++++++++++++- tests/test_component_lock.py | 70 ++++++- tests/test_door_entry_events.py | 32 +++- tests/test_validate.py | 32 +++- 16 files changed, 602 insertions(+), 93 deletions(-) diff --git a/blueprints/automation/myhome/door_gate_motion_notification.yaml b/blueprints/automation/myhome/door_gate_motion_notification.yaml index 4b424a741..291a673a5 100644 --- a/blueprints/automation/myhome/door_gate_motion_notification.yaml +++ b/blueprints/automation/myhome/door_gate_motion_notification.yaml @@ -63,7 +63,8 @@ blueprint: unit_of_measurement: "min" mode: slider -mode: restart +mode: parallel +max: 10 max_exceeded: silent trigger: diff --git a/blueprints/automation/myhome/doorbell_intercom_notification.yaml b/blueprints/automation/myhome/doorbell_intercom_notification.yaml index a329928f3..99f0befdd 100644 --- a/blueprints/automation/myhome/doorbell_intercom_notification.yaml +++ b/blueprints/automation/myhome/doorbell_intercom_notification.yaml @@ -30,6 +30,13 @@ blueprint: selector: entity: domain: lock + lock_code: + name: "Door Lock PIN Code (Optional)" + description: "Optional PIN code required to unlock the configured lock entity (leave empty if the lock has no code)." + default: "" + selector: + text: + type: password language: name: "Notification Language" description: "Language for push notifications and action buttons (English, Italiano, Nederlands, Deutsch, Français)." @@ -69,6 +76,7 @@ trigger: variables: door_target: !input door_entity + lock_code_val: !input lock_code camera_target: !input camera_entity lang: !input language custom_title_val: !input custom_title @@ -130,7 +138,7 @@ action: ] } %} {% if camera_target != '' %} - {% set _ = payload.update({"entity_id": camera_target}) %} + {% set _ = payload.update({"entity_id": camera_target, "image": "/api/camera_proxy/" ~ camera_target}) %} {% endif %} {{ payload }} @@ -149,3 +157,6 @@ action: - action: lock.unlock target: entity_id: !input door_entity + data: >- + {{ {'code': lock_code_val} if lock_code_val != '' else {} }} + diff --git a/custom_components/myhome/access_policy.py b/custom_components/myhome/access_policy.py index 2e91b6c12..8e47eb4bb 100644 --- a/custom_components/myhome/access_policy.py +++ b/custom_components/myhome/access_policy.py @@ -229,6 +229,7 @@ def __init__( self._cancel_event: asyncio.Event | None = None self._task: asyncio.Task[None] | None = None self._failed_pins: dict[str, tuple[int, float]] = {} + self._expected_sensor_state: str | None = None # ── Public API ─────────────────────────────────────────────────────── @@ -316,13 +317,33 @@ def cancel(self, reason: str) -> bool: pending = self._pending self._clear_pending() self._settle() - self._spawn(self._notify(pending.notify_service, f"{self._name}: request cancelled ({reason})")) + self._spawn(self._cancel_and_notify(pending.notify_service, f"{self._name}: request cancelled ({reason})")) return True if self.phase is Phase.PREWARNING and self._cancel_event is not None: self._cancel_event.set() return True return False + @callback + def on_sensor_state(self, state_str: str | None) -> None: + """Settle supervision immediately once the state sensor reaches the expected state.""" + if ( + self.phase is Phase.MONITORING + and self._expected_sensor_state is not None + and state_str == self._expected_sensor_state + ): + self._cancel_watchdog() + self._settle() + + @callback + def on_external_impulse(self) -> bool: + """End active movement supervision when an external wall button actuates the relay.""" + if self.phase is Phase.MONITORING: + self._cancel_watchdog() + self._settle() + return True + return False + async def async_cancel(self, context: Context | None) -> None: """Service entry: an allowed user cancels the pending request or pre-warning.""" user_id = context.user_id if context is not None else None @@ -378,7 +399,10 @@ def _check_user(self, user_id: str | None) -> None: def _check_lockout(self, user_id: str) -> None: fails, until = self._failed_pins.get(user_id, (0, 0.0)) - if fails >= self.config.max_failed_pins and time.monotonic() < until: + if until > 0.0 and time.monotonic() >= until: + self._failed_pins.pop(user_id, None) + return + if fails >= self.config.max_failed_pins: raise AccessDenied(f"{self._name}: too many wrong PINs; try again later") def _check_close_allowed(self, at_home: bool) -> None: @@ -495,6 +519,10 @@ async def _clear_notification(self, service: str) -> None: except Exception as err: # noqa: BLE001 - clearing is cosmetic LOGGER.debug("%s: could not clear notification: %s", self._name, err) + async def _cancel_and_notify(self, service: str, message: str) -> None: + await self._clear_notification(service) + await self._notify(service, message) + @callback def _on_expired(self, _now: Any = None) -> None: self._unsub_expiry = None @@ -532,7 +560,7 @@ def _on_action(self, event: Event) -> None: reply = event.data.get("textInput") if reply is None: reply = event.data.get("reply") - if reply is None: + if reply is None or str(reply).strip() == "": # The app returned no text at all (no input shown, or a client that # ignores `behavior: textInput`): nothing was guessed, so it is no failed # attempt and cannot lock the approver out. Nothing moves either way. @@ -549,9 +577,12 @@ def _on_action(self, event: Event) -> None: self._spawn(self._async_execute(pending)) def _register_failed_pin(self, user_id: str) -> None: - fails, _ = self._failed_pins.get(user_id, (0, 0.0)) + now = time.monotonic() + fails, until = self._failed_pins.get(user_id, (0, 0.0)) + if until > 0.0 and now >= until: + fails = 0 fails += 1 - self._failed_pins[user_id] = (fails, time.monotonic() + self.config.lockout_seconds) + self._failed_pins[user_id] = (fails, now + self.config.lockout_seconds) LOGGER.warning("%s: wrong PIN from user %s (%d)", self._name, user_id, fails) def _clear_pending(self) -> None: @@ -655,6 +686,7 @@ def _start_watchdog(self, pending: PendingRequest) -> None: self._settle() return expected = STATE_ON if pending.effect is Effect.OPEN else STATE_OFF + self._expected_sensor_state = expected timeout = self.config.watchdog_margin if pending.effect is Effect.MAY_CLOSE: timeout += self._travel_time @@ -664,6 +696,7 @@ def _start_watchdog(self, pending: PendingRequest) -> None: @callback def _expired(_now: Any = None) -> None: self._unsub_watchdog = None + self._expected_sensor_state = None if self._sensor_state() == expected: self._settle() return @@ -684,6 +717,7 @@ def _expired(_now: Any = None) -> None: self._unsub_watchdog = async_call_later(hass, timeout, _expired) def _cancel_watchdog(self) -> None: + self._expected_sensor_state = None if self._unsub_watchdog is not None: self._unsub_watchdog() self._unsub_watchdog = None diff --git a/custom_components/myhome/cover.py b/custom_components/myhome/cover.py index 045e7dc4f..974172e6c 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -139,6 +139,7 @@ # Delivery of the OFF that ends a software pulse (OWNd without the timed WHAT 18 builder). OFF_ATTEMPTS = 3 OFF_RETRY_DELAY = 1.0 +OFF_SETTLE_DELAY = 0.2 def _clamp_slat_time(value: Any, fallback: float = DEFAULT_SLAT_TIME) -> float: @@ -172,14 +173,14 @@ def address_of(entry: er.RegistryEntry) -> Address | None: return address_of def accept_shutter(ctx: DeviceContext) -> bool: - if ctx.source == "yaml": + if ctx.source == "yaml" or ctx.cfg: return str(ctx.cfg.get(CONF_WHO, "2")) == "2" return True def accept_impulse(ctx: DeviceContext) -> bool: - if ctx.source == "yaml": - return str(ctx.cfg.get(CONF_WHO, "2")) == "1" - return True + if ctx.source == "bus" or not ctx.cfg: + return False + return str(ctx.cfg.get(CONF_WHO, "2")) == "1" def build(ctx: DeviceContext) -> MyHOMECover: cfg = ctx.cfg @@ -208,8 +209,8 @@ def build(ctx: DeviceContext) -> MyHOMECover: advanced=cfg.get(CONF_ADVANCED_SHUTTER, cfg.get("advanced_shutter", False)), slat_tilt=bool(cfg.get(CONF_SLAT_TILT, False)), slat_time=_clamp_slat_time(cfg.get(CONF_SLAT_TIME)), - manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"), - model=cfg.get(CONF_DEVICE_MODEL, "Shutter / Cover"), + manufacturer=cfg.get(CONF_MANUFACTURER) or "BTicino", + model=cfg.get(CONF_DEVICE_MODEL) or "Shutter / Cover", gateway=runtime.gateway, travel_time=int(cfg.get(CONF_TRAVEL_TIME, DEFAULT_TRAVEL_TIME)), min_motor_pulse=float(cfg.get(CONF_MIN_MOTOR_PULSE, DEFAULT_MIN_MOTOR_PULSE)), @@ -239,8 +240,8 @@ def build_impulse(ctx: DeviceContext) -> MyHOMEImpulseCover: min_cycle_time=float(cfg.get(CONF_MIN_CYCLE_TIME, cfg.get("min_cycle_time", DEFAULT_MIN_CYCLE_TIME))), pin_code=str(cfg[CONF_PIN_CODE]) if CONF_PIN_CODE in cfg else (str(cfg["pin_code"]) if "pin_code" in cfg else None), state_sensor=str(cfg[CONF_STATE_SENSOR]) if CONF_STATE_SENSOR in cfg else (str(cfg["state_sensor"]) if "state_sensor" in cfg else None), - manufacturer=cfg.get(CONF_MANUFACTURER, "BTicino"), - model=cfg.get(CONF_DEVICE_MODEL, "Impulse Cover"), + manufacturer=cfg.get(CONF_MANUFACTURER) or "BTicino", + model=cfg.get(CONF_DEVICE_MODEL) or "Impulse Cover", gateway=runtime.gateway, access_config=cfg.get(CONF_ACCESS), ) @@ -272,7 +273,6 @@ def relay_scope(message, address: Address) -> None: # type: ignore hass, config_entry, async_add_entities, platform=PLATFORM, who="1", event_type=None, build=build_impulse, announce=True, registry_address=cover_registry_address("1"), accept=accept_impulse, - known_keys=lambda ctx: [*default_known_keys(ctx), ctx.address.where, ctx.address.clean_where], ).start(listen=False) SCHEMA_SET_COVER_TRAVEL_TIME = { @@ -2161,6 +2161,14 @@ def handle_event(self, message: OWNMessage) -> None: self._publish_state() + async def async_cancel_request(self) -> None: + """Raise when cancel_cover_request is called on a WHO=2 shutter.""" + raise ServiceValidationError(f"{self._display_name}: cancel_cover_request is only supported on impulse covers") + + async def async_acknowledge_fault(self) -> None: + """Raise when acknowledge_cover_fault is called on a WHO=2 shutter.""" + raise ServiceValidationError(f"{self._display_name}: acknowledge_cover_fault is only supported on impulse covers") + class MyHOMEImpulseCover(MyHOMEEntity, CoverEntity): """An impulse-driven gate or garage door powered by a WHO=1 relay. @@ -2299,6 +2307,11 @@ def _handle_sensor_update(self, state_str: str | None) -> None: self._attr_is_closed = False else: self._attr_is_closed = None + if self._access is not None: + self._access.on_sensor_state(state_str) + if state_str == STATE_ON and self._attr_is_opening: + self._publish_state() + return self._attr_is_opening = False self._attr_is_closing = False self._cancel_travel_timer() @@ -2324,16 +2337,18 @@ async def _async_send_impulse(self) -> bool: self._pulse_off_cancel() self._pulse_off_cancel = None - # The relay of an earlier pulse may still be energised (its OFF never reached the - # bus): release it first, and refuse to start another pulse on top of it. - if self._off_pending and not await self._async_send_off(): - return False - # The deadband starts only once the gateway confirmed the write: a failed send # must not lock the user out for min_cycle_time with nothing on the bus. The # in-flight flag keeps a second request and our own echo out meanwhile. self._pulse_in_flight = True try: + # The relay of an earlier pulse may still be energised (its OFF never reached the + # bus): release it first, and refuse to start another pulse on top of it. + if self._off_pending: + if not await self._async_send_off(): + return False + await asyncio.sleep(OFF_SETTLE_DELAY) + if self._pulse_duration == 0.5 and hasattr(OWNLightingCommand, "switch_on_timed"): # Use WHAT 18: Timed ON for 0.5 seconds (official WHO 1 specification). # The physical actuator automatically turns off after 0.5s in hardware, @@ -2442,7 +2457,11 @@ async def _async_direct(self, intent: Intent) -> None: door is already there or already on its way. Without one the direction is unknown and the pulse goes out as asked. """ - if self._state_sensor is not None: + if (intent is Intent.OPEN and self._attr_is_closing) or ( + intent is Intent.CLOSE and self._attr_is_opening + ): + intent = Intent.STOP + elif self._state_sensor is not None: if intent is Intent.OPEN and (self._attr_is_closed is False or self._attr_is_opening): LOGGER.debug("%s: already open or opening, open request ignored", self._display_name) return @@ -2489,6 +2508,22 @@ async def async_acknowledge_fault(self) -> None: raise HomeAssistantError(f"{self._display_name}: no `access:` policy is configured, no fault to clear") await self._access.async_acknowledge_fault(self._context) + async def async_calibrate(self) -> None: + """Raise when calibrate_cover is called on a WHO=1 impulse cover.""" + raise ServiceValidationError(f"{self._display_name}: calibration is only supported on WHO=2 shutters") + + async def async_stop_calibration(self) -> None: + """Raise when stop_cover_calibration is called on a WHO=1 impulse cover.""" + raise ServiceValidationError(f"{self._display_name}: calibration is only supported on WHO=2 shutters") + + async def async_set_travel_time(self, **kwargs: Any) -> None: + """Raise when set_cover_travel_time is called on a WHO=1 impulse cover.""" + raise ServiceValidationError(f"{self._display_name}: set_cover_travel_time is only supported on WHO=2 shutters") + + async def async_reset_travel_time(self) -> None: + """Raise when reset_cover_travel_time is called on a WHO=1 impulse cover.""" + raise ServiceValidationError(f"{self._display_name}: reset_cover_travel_time is only supported on WHO=2 shutters") + @callback def handle_event(self, message: Any) -> None: """Handle incoming WHO=1 relay frames for this impulse cover. @@ -2500,7 +2535,9 @@ def handle_event(self, message: Any) -> None: """ if getattr(message, "is_translation", None) is True: return - is_on = getattr(message, "is_on", False) or getattr(message, "_what", None) == 1 + what_pub = getattr(message, "what", None) + what = what_pub if isinstance(what_pub, (int, str)) else getattr(message, "_what", None) + is_on = getattr(message, "is_on", False) or what in (1, 18) if not is_on: return # Ignore *1*0*... release frames (such as the 5-minute delayed hardware timer) @@ -2512,15 +2549,25 @@ def handle_event(self, message: Any) -> None: # The button has already closed the relay and started the motor: pressing it # never aborts anything. Only Home Assistant's own pending request or # pre-warning is dropped, so no second pulse follows and stops or reverses it. - if self._access is not None and self._access.cancel("wall button pressed"): - LOGGER.info( - "%s: wall button pressed - the gate/door has already been actuated; " - "pending request or pre-warning cancelled so no second pulse follows", - self._display_name, - ) + if self._access is not None: + if self._access.cancel("wall button pressed"): + LOGGER.info( + "%s: wall button pressed - the gate/door has already been actuated; " + "pending request or pre-warning cancelled so no second pulse follows", + self._display_name, + ) + elif self._access.on_external_impulse(): + LOGGER.info( + "%s: wall button pressed while moving - supervision ended so no false fault is latched", + self._display_name, + ) if self._state_sensor is None: - if self._attr_is_closed or self._attr_is_closing: + if self._attr_is_opening or self._attr_is_closing: + self._attr_is_opening = False + self._attr_is_closing = False + self._cancel_travel_timer() + elif self._attr_is_closed: self._attr_is_opening = True self._attr_is_closing = False self._attr_is_closed = False @@ -2535,9 +2582,6 @@ def _finish_open(_now: Any = None) -> None: if self.hass is not None: self._travel_timer_cancel = async_call_later(self.hass, self._travel_time, _finish_open) - elif self._attr_is_opening: - self._attr_is_opening = False - self._cancel_travel_timer() else: self._attr_is_closing = True self._attr_is_opening = False diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index 66f7240b7..036497508 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -199,7 +199,15 @@ def _intercom_call_kind(message: OWNMessage) -> int | None: """Call kind of a WHO 8 call (`*8*1##*##`), or None when unreadable.""" kind = getattr(message, "call_kind", None) if kind is None: - params = getattr(message, "_what_param", None) or [] + pub_params = getattr(message, "what_param", None) + priv_params = getattr(message, "_what_param", None) + params = ( + pub_params + if isinstance(pub_params, (list, tuple)) + else priv_params + if isinstance(priv_params, (list, tuple)) + else [] + ) kind = params[0] if params else None try: return int(kind) if kind is not None else None @@ -593,8 +601,16 @@ async def process_message(self, message: Any) -> None: if isinstance(message, OWNGatewayEvent): self.handler._handle_gateway_diagnostics(message) elif isinstance(message, OWNDoorEntryEvent) or getattr(message, "who", None) in (6, 8): + if getattr(message, "is_translation", False): + self._logger.debug( + "%s Ignoring translation message `%s`", + self.handler.log_id, + message, + ) + return who = getattr(message, "who", None) - what = getattr(message, "_what", None) + what_pub = getattr(message, "what", None) + what = what_pub if isinstance(what_pub, (int, str)) else getattr(message, "_what", None) where_val = getattr(message, "where", "") # WHO 8 WHAT 1 starts any call session. Only the entrance panels (kinds 1-4) # ring the door; internal handset calls (kind 6), external intercom calls @@ -612,9 +628,28 @@ async def process_message(self, message: Any) -> None: else: is_incoming = getattr(message, "is_incoming_call", who == 6 and what == 6) is_call = getattr(message, "is_call", is_incoming or is_chime) + + target_mac = self.handler.mac + config_entry = getattr(self.handler, "config_entry", None) + target_entry_id = getattr(config_entry, "entry_id", None) if config_entry else None + + if getattr(self.handler, "is_standby", False): + primary_gw = self.handler._get_primary_gateway() + if ( + primary_gw is not None + and not primary_gw.is_connected + and who is not None + and self.handler._profile_supports_who(who) + ): + target_mac = primary_gw.mac + pri_entry = getattr(primary_gw, "config_entry", None) + target_entry_id = getattr(pri_entry, "entry_id", None) if pri_entry else None + else: + target_mac = None + # On a shared bus every gateway sees the same frame; only the active owner # of the subsystem may announce it or each ring fires once per gateway. - if is_call and self._is_active_for_who(who): + if target_mac is not None and is_call and self._is_active_for_who(who): event_name = ( "broadcast_call" if is_broadcast @@ -626,13 +661,12 @@ async def process_message(self, message: Any) -> None: "where": str(where_val), "event": event_name, "is_broadcast": is_broadcast, - "gateway_mac": self.handler.mac, + "gateway_mac": target_mac, } - config_entry = getattr(self.handler, "config_entry", None) - if config_entry and hasattr(config_entry, "entry_id") and isinstance(config_entry.entry_id, str): - doorbell_payload["entry_id"] = config_entry.entry_id + if target_entry_id and isinstance(target_entry_id, str): + doorbell_payload["entry_id"] = target_entry_id self.hass.bus.async_fire("myhome_doorbell_event", doorbell_payload) - dispatcher_send(self.hass, f"myhome_doorbell_event_{self.handler.mac}", doorbell_payload) + dispatcher_send(self.hass, f"myhome_doorbell_event_{target_mac}", doorbell_payload) self._logger.debug( "%s %s", self.handler.log_id, diff --git a/custom_components/myhome/light.py b/custom_components/myhome/light.py index 9017e8c7e..ce540073f 100644 --- a/custom_components/myhome/light.py +++ b/custom_components/myhome/light.py @@ -193,9 +193,12 @@ def route_foreign(message: Any, address: Address, known: KnownDevices) -> bool: known.discard(address.key) return True if foreign.owns(address, address.key): - runtime.router.publish( - "1", (address.key, address.where, normalize_where(address.where)), message + keys: tuple[str, ...] = ( + (address.key, address.clean_key) + if address.interface and not foreign.is_switch(address, address.key) + else (address.key, address.where, normalize_where(address.where)) ) + runtime.router.publish("1", keys, message) return True return False @@ -234,7 +237,7 @@ def route_foreign(message: Any, address: Address, known: KnownDevices) -> bool: class _ForeignAddresses: - """WHO=1 addresses that belong to the switch or sensor platforms, not to a light.""" + """WHO=1 addresses that belong to the switch, cover, lock, or sensor platforms, not to a light.""" SENSOR_MESSAGE_TYPES = ( "motion_detected", @@ -248,6 +251,7 @@ def __init__( ) -> None: runtime = config_entry.runtime_data self.switches: set[str] = set() + self.covers: set[str] = set() self.sensors: set[str] = set() for dev_id, cfg in runtime.platforms.get("switch", {}).items(): @@ -260,9 +264,7 @@ def __init__( if str(cfg.get(CONF_WHO, default_who)) != "1": continue address = Address.from_config(dev_id, cfg) - self.switches.update( - {str(dev_id), address.where, address.clean_key, address.clean_where} - ) + self._add_cover(str(dev_id), address) for platform, default_who in (("binary_sensor", "25"), ("sensor", "1")): for dev_id, cfg in runtime.platforms.get(platform, {}).items(): if str(cfg.get(CONF_WHO, default_who)) != "1": @@ -277,10 +279,10 @@ def __init__( entries = [] for entry in entries: who, device_id = parse_unique_id(entry.unique_id or "", gateway_mac, entry_mac) - if entry.domain in ("switch", "cover", "lock"): - if entry.domain != "switch" and who != "1": - continue + if entry.domain == "switch": self.switches.update({device_id, device_id.split("#4#")[0].split("-")[-1]}) + elif entry.domain in ("cover", "lock") and who == "1": + self._add_cover(device_id, Address.from_device_id(device_id)) elif entry.domain in ("binary_sensor", "sensor"): if who == "1": dev = device_id.split("-")[0] @@ -294,6 +296,14 @@ def __init__( continue self._add_sensor(dev, dev.split("#4#")[0].split("-")[-1]) + def _add_cover(self, dev_id: str, address: Address) -> None: + if address.interface: + self.covers.update({dev_id, address.key, address.clean_key}) + else: + self.covers.update( + {dev_id, address.key, address.clean_key, address.where, address.clean_where} + ) + def _add_sensor(self, *wheres: str) -> None: for where in wheres: self.sensors.update({where, normalize_where(where)}) @@ -301,11 +311,26 @@ def _add_sensor(self, *wheres: str) -> None: def mark_sensor(self, address: Address) -> None: self._add_sensor(address.where, address.key, address.clean_where) + def is_switch(self, address: Address, key: str) -> bool: + return bool({key, address.where, address.clean_where} & self.switches) + def owns(self, address: Address, key: str) -> bool: - candidates = {key, address.where, address.clean_where} - if candidates & self.switches: + if self.is_switch(address, key): + return True + cover_candidates = ( + {key, address.key, address.clean_key} + if address.interface + else {key, address.key, address.clean_key, address.where, address.clean_where} + ) + if cover_candidates & self.covers: return True - candidates |= {normalize_where(address.where), normalize_where(address.clean_where)} + candidates = { + key, + address.where, + address.clean_where, + normalize_where(address.where), + normalize_where(address.clean_where), + } return bool(candidates & self.sensors) @classmethod diff --git a/custom_components/myhome/lock.py b/custom_components/myhome/lock.py index 4be0b44ca..02467fce8 100644 --- a/custom_components/myhome/lock.py +++ b/custom_components/myhome/lock.py @@ -48,7 +48,6 @@ def open_lock(cls, where: str | int = 0) -> Any: Address, DeviceContext, PlatformDiscovery, - default_known_keys, parse_unique_id, ) from .gateway import MyHOMEGatewayHandler @@ -84,8 +83,8 @@ def build(ctx: DeviceContext) -> MyHOMELock: name = str(name_val) if name_val else f"Lock {ctx.suffix}" raw_entity_name = cfg.get(CONF_ENTITY_NAME) entity_name = str(raw_entity_name) if raw_entity_name is not None else None - manufacturer = str(cfg.get(CONF_MANUFACTURER, "BTicino")) - model = str(cfg.get(CONF_DEVICE_MODEL, "Door Entry Lock")) + manufacturer = str(cfg.get(CONF_MANUFACTURER) or "BTicino") + model = str(cfg.get(CONF_DEVICE_MODEL) or "Door Entry Lock") code = str(cfg[CONF_CODE]) if CONF_CODE in cfg else None pulse = float(cfg.get(CONF_PULSE_DURATION, DEFAULT_LOCK_DURATION)) return MyHOMELock( @@ -104,11 +103,9 @@ def build(ctx: DeviceContext) -> MyHOMELock: ) def accept(ctx: DeviceContext) -> bool: - if ctx.address.where in ("4100", "0", ""): + if ctx.source == "bus" or not ctx.cfg or ctx.address.where in ("4100", "0", ""): return False - if ctx.source == "yaml": - return str(ctx.cfg.get(CONF_WHO, "6")) == "6" - return True + return str(ctx.cfg.get(CONF_WHO, "6")) == "6" # Locks come only from myhome.yaml and the entity registry, never from bus traffic: # a lock release seen on the bus would otherwise add a code-less entity that anyone @@ -124,8 +121,7 @@ def accept(ctx: DeviceContext) -> bool: announce=True, registry_address=registry_address, accept=accept, - known_keys=lambda ctx: [*default_known_keys(ctx), ctx.address.where, ctx.address.clean_where], - ).start(listen=False) + ).start(listen=True) return True @@ -230,8 +226,10 @@ def handle_event(self, message: Any) -> None: ) if getattr(message, "is_translation", None) is True: return + what_pub = getattr(message, "what", None) + what = what_pub if isinstance(what_pub, (int, str)) else getattr(message, "_what", None) is_lock_open = getattr(message, "is_lock_open", False) or ( - getattr(message, "who", None) == 6 and getattr(message, "_what", None) in (10, 22) + getattr(message, "who", None) == 6 and what in (10, 22) ) if is_lock_open: self._attr_is_locked = False diff --git a/custom_components/myhome/translations/fr.json b/custom_components/myhome/translations/fr.json index cf828de04..11a3769b6 100644 --- a/custom_components/myhome/translations/fr.json +++ b/custom_components/myhome/translations/fr.json @@ -330,6 +330,14 @@ "reset_cover_travel_time": { "name": "Réinitialiser le temps de course du volet", "description": "Oublie les temps de course mesurés ou définis manuellement et revient au travel_time de myhome.yaml ou à la valeur par défaut de 25 s." + }, + "cancel_cover_request": { + "name": "Annuler la demande de portail ou de porte de garage", + "description": "Annule une demande d'approbation en attente ou un préavis en cours pour un portail ou une porte de garage à impulsion. L'annulation est toujours sûre ; rien ne bouge." + }, + "acknowledge_cover_fault": { + "name": "Acquitter le défaut de portail ou de porte de garage", + "description": "Efface le défaut verrouillé lorsqu'un portail ou une porte de garage n'a pas atteint l'état attendu après une impulsion (obstacle ou inversion possible). Seul un utilisateur autorisé présent au domicile peut l'acquitter ; jusque-là, la fermeture depuis Home Assistant reste bloquée." } }, "issues": { diff --git a/custom_components/myhome/translations/it.json b/custom_components/myhome/translations/it.json index e18cf3ccd..686d30e6f 100644 --- a/custom_components/myhome/translations/it.json +++ b/custom_components/myhome/translations/it.json @@ -330,6 +330,14 @@ "reset_cover_travel_time": { "name": "Reimposta il tempo di corsa della tapparella", "description": "Dimentica i tempi di corsa misurati o impostati manualmente e torna al travel_time di myhome.yaml o al valore predefinito di 25 s." + }, + "cancel_cover_request": { + "name": "Annulla richiesta cancello o porta garage", + "description": "Annulla una richiesta di approvazione in sospeso o un preavviso in corso di un cancello o porta garage a impulso. L'annullamento è sempre sicuro; non si muove nulla." + }, + "acknowledge_cover_fault": { + "name": "Riconosci guasto cancello o porta garage", + "description": "Cancella il guasto bloccato quando un cancello o una porta garage non ha raggiunto lo stato previsto dopo un impulso (possibile ostacolo o inversione). Solo un utente autorizzato presente a casa può riconoscerlo; fino ad allora la chiusura da Home Assistant resta bloccata." } }, "issues": { diff --git a/custom_components/myhome/validate.py b/custom_components/myhome/validate.py index ddd49bd77..e3de7c839 100644 --- a/custom_components/myhome/validate.py +++ b/custom_components/myhome/validate.py @@ -43,6 +43,7 @@ Coerce, In, Invalid, + Match, Optional, Range, Required, @@ -501,7 +502,7 @@ def _validate_access(access: dict[str, typing.Any]) -> dict[str, typing.Any]: ] ), Optional(CONF_TYPE): In([TYPE_IMPULSE_RELAY, "shutter", "standard"]), - Optional(CONF_PIN_CODE): Coerce(str), + Optional(CONF_PIN_CODE): All(Coerce(str), Match(r"^\d+$", msg="PIN code must contain only digits")), Optional(CONF_STATE_SENSOR): cv.entity_id, Optional(CONF_PULSE_DURATION, default=0.5): All(Coerce(float), Range(min=0.2, max=2.0)), Optional(CONF_MIN_CYCLE_TIME, default=5.0): All(Coerce(float), Range(min=1.0, max=120.0)), @@ -558,6 +559,8 @@ def _validate_impulse_covers(data: dict[str, typing.Any]) -> dict[str, typing.An f"{device}: an impulse cover (who: 1) needs a point-to-point ; a general, area or " "group address would pulse several motors at once" ) from err + if float(cfg.get(CONF_MIN_CYCLE_TIME, 5.0)) <= float(cfg.get(CONF_PULSE_DURATION, 0.5)): + raise Invalid(f"{device}: min_cycle_time must be greater than pulse_duration") if CONF_PIN_CODE in cfg and CONF_ACCESS not in cfg: raise Invalid(f"{device}: pin_code is checked by the access policy; add an access: block or remove it") elif CONF_ACCESS in cfg or CONF_PIN_CODE in cfg or CONF_STATE_SENSOR in cfg: @@ -690,15 +693,22 @@ def _validate_impulse_covers(data: dict[str, typing.Any]) -> dict[str, typing.An ) class DoorEntryWhere: - """WHERE of a WHO 6 door entry endpoint: digits, with ``#2`` appended on a riser installation.""" + """WHERE of a WHO 6 door entry endpoint: 1..99 or 4000..4099, optionally with ``#2`` riser.""" def __init__(self, msg: str | None = None) -> None: self.msg = msg def __call__(self, v: typing.Any) -> typing.Any: - if isinstance(v, str) and re.fullmatch(r"\d{1,4}(#2)?", v): - return v - raise Invalid(f"Invalid door entry WHERE {v}, it must be 1-4 digits, optionally followed by #2 (riser).") + if isinstance(v, str): + m = re.fullmatch(r"(\d{1,4})(#2)?", v) + if m and not (len(m.group(1)) > 1 and m.group(1).startswith("0")): + num = int(m.group(1)) + riser = m.group(2) is not None + if (not riser and num in (0, 4100)) or (1 <= num <= 99) or (4000 <= num <= 4099): + return v + raise Invalid( + f"Invalid door entry WHERE {v}, expected 1-99 or 4000-4099 (optionally followed by #2)." + ) def __repr__(self) -> str: return "DoorEntryWhere(msg=%r)" % self.msg @@ -714,8 +724,8 @@ def __repr__(self) -> str: Optional(CONF_BUS_INTERFACE): All(Coerce(str), BusInterface()), # type: ignore Required(CONF_NAME): str, Optional(CONF_ENTITY_NAME): str, - Optional(CONF_CODE): Coerce(str), - Optional(CONF_PULSE_DURATION, default=0.5): All( + Optional(CONF_CODE): All(Coerce(str), Match(r"^\d+$", msg="PIN code must contain only digits")), + Optional(CONF_PULSE_DURATION, default=5.0): All( Coerce(float), Range(min=0.1, max=10.0) ), Optional(CONF_MANUFACTURER, default="BTicino S.p.A."): str, @@ -729,7 +739,8 @@ def _validate_locks(data: dict[str, typing.Any]) -> dict[str, typing.Any]: """A door entry lock needs a single entrance address, never general, area or broadcast.""" for device, cfg in data.items(): where = str(cfg.get(CONF_WHERE, "")) - if where in ("0", "4100"): + num = int(where.split("#", 1)[0]) + if num in (0, 4100): raise Invalid(f"{device}: a door entry lock cannot use general ('0') or broadcast ('4100') addressing") return data diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index d068d64cd..8c1ebfa32 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -470,9 +470,9 @@ Add an `access:` block to opt in to the supervised workflow (phone approval, PIN | Key | Meaning | | :--- | :--- | | `state_sensor` | Binary sensor of the ground-truth position. Without it the position is assumed and, with `access:`, every pulse is treated as one that can close the door. | -| `pulse_duration` | Relay pulse in seconds. `0.5` uses the timed WHAT 18 command where the installed OWNd has it, and ON followed by OFF otherwise. | -| `min_cycle_time` | Deadband after a pulse that reached the bus. A failed send does not start it. | -| `pin_code` | PIN asked in the approval notification; only valid together with `access:`. A reply that carries no text is refused without counting as a wrong attempt. | +| `pulse_duration` | Relay pulse in seconds. `0.5` uses the timed WHAT 18 command (`*1*18*WHERE##`, specified in `WHO_1.pdf` for 0.5 s timed ON; not yet replayed in the firmware oracle) where the installed OWNd exposes `turn_on_for_half_second`, and ON (`*1*1*WHERE##`) followed by OFF (`*1*0*WHERE##`) otherwise. | +| `min_cycle_time` | Deadband after a pulse that reached the bus (must be strictly greater than `pulse_duration`). A failed send does not start it. | +| `pin_code` | Digit-only PIN asked in the approval notification; only valid together with `access:`. A reply that carries no text is refused without counting as a wrong attempt. | | `access.*` | Optional. Turns on the supervised workflow, see the flow above: `allowed_users`, `approvers`, `remote_close`, `camera`, `safety_devices_verified`, `safety_check_days`, `prewarn_light`, `prewarn_seconds`, `close_block_entity`, `watchdog_margin`, `approval_timeout`. | > [!NOTE] @@ -485,13 +485,13 @@ Add an `access:` block to opt in to the supervised workflow (phone approval, PIN lock: front_door: who: 6 # the only value; WHO 1 relays are covers - where: "4001" # 4000-4095 style endpoints; append #2 on a riser installation: "4001#2" + where: "4001" # 1-99 or 4000-4099 endpoints; append #2 on a riser installation: "4001#2" name: Front door - code: "1234" # optional; asked on every unlock - pulse_duration: 0.5 # seconds until the lock shows locked again + code: "1234" # optional digit-only PIN; asked on every unlock + pulse_duration: 5.0 # seconds until the lock shows locked again (default 5.0 s) ``` -Locks exist only when they are configured here (or were created earlier and are restored from the registry). A lock release seen on the bus never creates a lock entity, because such an entity would carry no code. +Locks exist only when they are configured here (and are restored from the registry on restart with their YAML configuration intact). Neither a lock release seen on the bus nor a stale registry entry removed from `myhome.yaml` creates a lock entity, because such an entity would carry no code. ### ⚠️ Strict Distinction: Covers vs. Locks diff --git a/tests/test_access_policy.py b/tests/test_access_policy.py index 3b60e94dd..e45aa193e 100644 --- a/tests/test_access_policy.py +++ b/tests/test_access_policy.py @@ -811,3 +811,58 @@ async def test_restored_fault_stays_latched_until_acknowledged_on_site(hass: Hom await h.ctrl.async_request(Intent.CLOSE, ctx) await h.ctrl.async_acknowledge_fault(ctx) assert h.ctrl.phase is Phase.IDLE + + +async def test_pin_lockout_expiry_resets_counter_and_whitespace_reply_ignored(hass: HomeAssistant): + """Expired PIN lockout resets failed count (H-3), and whitespace-only reply does not count as wrong PIN.""" + parent = await _user(hass, "Parent") + calls = async_mock_service(hass, "notify", "mobile_app_parent") + h = Harness(hass, _config(parent.id, pin_code="2468")) + hass.states.async_set(SENSOR, STATE_OFF) + + # Whitespace-only reply is treated as missing PIN, not a failed attempt + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + await _press(hass, _approve_action(calls)["action"], parent.id, reply_text=" ") + await hass.async_block_till_done() + assert h.ctrl._failed_pins == {} + + # Trigger lockout with 3 wrong PINs + for _ in range(3): + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + await _press(hass, _approve_action(calls)["action"], parent.id, reply_text="0000") + await hass.async_block_till_done() + assert h.ctrl._failed_pins[parent.id][0] == 3 + + # Simulate lockout expiry before _check_lockout + h.ctrl._failed_pins[parent.id] = (3, time.monotonic() - 1.0) + await h.ctrl.async_request(Intent.OPEN, Context(user_id=parent.id)) + assert parent.id not in h.ctrl._failed_pins + + # Also test expiry inside _register_failed_pin when lockout expired while request was pending + h.ctrl._failed_pins[parent.id] = (3, time.monotonic() - 1.0) + await _press(hass, _approve_action(calls)["action"], parent.id, reply_text="0000") + await hass.async_block_till_done() + # Counter reset to 1 (2 attempts left), not locked out! + assert h.ctrl._failed_pins[parent.id][0] == 1 + await h.ctrl.async_shutdown() + + +async def test_monitoring_settles_early_on_sensor_or_external_impulse(hass: HomeAssistant): + """on_sensor_state and on_external_impulse settle Phase.MONITORING and cancel the watchdog (H-4).""" + h, _calls = await _monitoring_close(hass) + h.ctrl.on_sensor_state(STATE_ON) # MAY_CLOSE expects STATE_OFF + assert h.ctrl.phase is Phase.MONITORING + h.ctrl.on_sensor_state(STATE_OFF) + assert h.ctrl.phase is Phase.IDLE + assert h.ctrl._unsub_watchdog is None + h.ctrl.on_sensor_state(STATE_OFF) # no longer MONITORING + assert h.ctrl.on_external_impulse() is False + + h2, _calls2 = await _monitoring_close(hass) + assert h2.ctrl.on_external_impulse() is True + await hass.async_block_till_done() + assert h2.ctrl.phase is Phase.IDLE + assert h2.ctrl._unsub_watchdog is None + await h.ctrl.async_shutdown() + await h2.ctrl.async_shutdown() + diff --git a/tests/test_component_impulse_cover.py b/tests/test_component_impulse_cover.py index bd3d5b2a7..964aaf977 100644 --- a/tests/test_component_impulse_cover.py +++ b/tests/test_component_impulse_cover.py @@ -82,7 +82,7 @@ def mock_gateway(): async def test_impulse_cover_setup_restores_and_configures(hass: HomeAssistant, mock_gateway): - """Test setup of WHO=1 impulse covers from YAML and entity registry.""" + """Test setup of WHO=1 impulse covers from YAML and entity registry (rejecting removed stale registry entries per H-5).""" mac = mock_gateway.mac hass.data = { DOMAIN: { @@ -90,6 +90,13 @@ async def test_impulse_cover_setup_restores_and_configures(hass: HomeAssistant, "entity": mock_gateway, CONF_PLATFORMS: { PLATFORM: { + "1-21": { + CONF_WHERE: "21", + CONF_WHO: "1", + CONF_NAME: "Side Gate", + CONF_DEVICE_CLASS: CoverDeviceClass.GATE, + CONF_TYPE: TYPE_IMPULSE_RELAY, + }, "1-22": { CONF_WHERE: "22", CONF_WHO: "1", @@ -116,10 +123,13 @@ async def test_impulse_cover_setup_restores_and_configures(hass: HomeAssistant, reg_1 = MagicMock() reg_1.domain = Platform.COVER reg_1.unique_id = f"{mac}-1-21" + reg_removed = MagicMock() # removed from myhome.yaml: must not resurrect without its access policy (H-5) + reg_removed.domain = Platform.COVER + reg_removed.unique_id = f"{mac}-1-29" with ( patch("homeassistant.helpers.entity_registry.async_get", return_value=mock_er), - patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[reg_1]), + patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[reg_1, reg_removed]), ): added_entities = [] @@ -129,9 +139,10 @@ def fake_add_entities(entities): attach_runtime(hass, config_entry) await async_setup_entry(hass, config_entry, fake_add_entities) - # 1 restored (where=21) + 1 configured from YAML (where=22) + # 1 restored (where=21) + 1 configured from YAML (where=22); removed (where=29) rejected impulse_entities = [e for e in added_entities if isinstance(e, MyHOMEImpulseCover)] assert len(impulse_entities) == 2 + assert not any(e._where == "29" for e in impulse_entities) gate = next(e for e in impulse_entities if e._where == "22") assert gate._device_name == "Entrance Gate" assert gate.device_class == CoverDeviceClass.GATE @@ -324,11 +335,11 @@ async def test_impulse_cover_state_sensor_binding(hass: HomeAssistant, mock_gate # Reset mock for sensor updates mock_gateway.send.reset_mock() - # Sensor state updates to on (open) + # Sensor state updates to on (not closed): cover stays opening until travel completes hass.states.async_set("binary_sensor.garage_door_contact", STATE_ON) await hass.async_block_till_done() assert cover.is_closed is False - assert cover.is_opening is False + assert cover.is_opening is True # Sensor state updates to off (closed) hass.states.async_set("binary_sensor.garage_door_contact", STATE_OFF) @@ -847,3 +858,158 @@ async def test_lost_off_is_retried_and_blocks_the_next_pulse(hass: HomeAssistant await impulse_gate.async_will_remove_from_hass() assert str(mock_gateway.send.call_args[0][0]) == "*1*0*22##" assert impulse_gate._off_pending is False + + +async def test_cross_class_cover_service_guards(hass: HomeAssistant, impulse_gate, mock_gateway): + """Impulse covers reject shutter calibration services and shutters reject impulse access services (C-2).""" + from homeassistant.exceptions import ServiceValidationError + + from custom_components.myhome.cover import MyHOMECover + + with pytest.raises(ServiceValidationError, match="only supported on WHO=2 shutters"): + await impulse_gate.async_calibrate() + with pytest.raises(ServiceValidationError, match="only supported on WHO=2 shutters"): + await impulse_gate.async_stop_calibration() + with pytest.raises(ServiceValidationError, match="only supported on WHO=2 shutters"): + await impulse_gate.async_set_travel_time(travel_time=20.0) + with pytest.raises(ServiceValidationError, match="only supported on WHO=2 shutters"): + await impulse_gate.async_reset_travel_time() + + with patch("custom_components.myhome.myhome_device.Entity.__init__", return_value=None): + shutter = MyHOMECover( + hass=hass, + name="Shutter", + entity_name=None, + device_id="12", + who="2", + where="12", + interface=None, + advanced=False, + manufacturer="BTicino", + model="Shutter", + gateway=mock_gateway, + ) + shutter.entity_id = "cover.shutter_12" + with pytest.raises(ServiceValidationError, match="only supported on impulse covers"): + await shutter.async_cancel_request() + with pytest.raises(ServiceValidationError, match="only supported on impulse covers"): + await shutter.async_acknowledge_fault() + + +async def test_direct_cover_opposite_direction_stops_and_wall_button_stops_closing( + hass: HomeAssistant, direct_gate, impulse_gate, mock_gateway, caplog +): + """Calling close while opening (or open while closing) on a direct cover stops it (H-6).""" + from custom_components.myhome.access_policy import Phase + + cover = direct_gate(state_sensor="binary_sensor.garage_contact") + cover._handle_sensor_update(STATE_OFF) + + await cover.async_open_cover() + assert cover.is_opening is True + cover._handle_sensor_update(STATE_ON) + assert cover.is_opening is True # C-3: stays opening when contact lifts + + # Opposite command while opening converts to STOP + await cover.async_close_cover() + assert cover.is_opening is False + assert cover.is_closing is False + + # Now start closing, then call open_cover while closing -> converts to STOP + await cover.async_close_cover() + assert cover.is_closing is True + await cover.async_open_cover() + assert cover.is_closing is False + assert cover.is_opening is False + + # Wall button press while closing on a sensorless cover stops closing + sensorless = direct_gate() + sensorless._attr_is_closed = False + sensorless._attr_is_closing = True + sensorless.handle_event(OWNEvent.parse("*1*1*24##")) + assert sensorless.is_closing is False + assert sensorless.is_opening is False + + # Wall button press during supervised Phase.MONITORING ends supervision (H-4) + impulse_gate._access.phase = Phase.MONITORING + with caplog.at_level("INFO"): + impulse_gate.handle_event(OWNEvent.parse("*1*1*22##")) + assert impulse_gate._access.phase is Phase.IDLE + assert "supervision ended" in caplog.text + + +async def test_f422_sub_bus_impulse_cover_does_not_swallow_local_light_or_cover(hass: HomeAssistant, mock_gateway): + """An impulse cover at 71#4#01 does not suppress a local light at 71 or cross-route bus frames (H-2).""" + from homeassistant.helpers.dispatcher import async_dispatcher_send + + from custom_components.myhome.light import async_setup_entry as async_setup_light_entry + from tests.conftest import attach_runtime + + mac = mock_gateway.mac + hass.data.setdefault("myhome", {})[mac] = { + "entity": mock_gateway, + "platforms": { + "cover": { + "1-71#4#01": { + "who": "1", + "where": "71", + "interface": "01", + "name": "Sub-bus Gate", + "manufacturer": None, + "model": None, + }, + "2-80": { + "who": "2", + "where": "80", + "name": "Shutter 80", + "manufacturer": None, + "model": None, + }, + }, + "light": { + "1-71": {"who": "1", "where": "71", "name": "Local Light 71"}, + }, + }, + } + entry = MagicMock() + entry.data = {"mac": mac} + entry.entry_id = "sub_bus_cover_entry" + attach_runtime(hass, entry) + + covers = [] + lights = [] + cov_reg = MagicMock(domain="cover", unique_id=f"{mac}-1-71#4#01", has_entity_name=True, name=None) + with ( + patch("custom_components.myhome.discovery.er.async_entries_for_config_entry", return_value=[cov_reg]), + patch("custom_components.myhome.discovery.er.async_get"), + ): + await async_setup_light_entry(hass, entry, lights.extend) + await async_setup_entry(hass, entry, covers.extend) + + assert len(lights) == 1 + assert lights[0]._where == "71" + sub_gate = next(c for c in covers if isinstance(c, MyHOMEImpulseCover)) + shutter = next(c for c in covers if not isinstance(c, MyHOMEImpulseCover)) + assert sub_gate._manufacturer == "BTicino" + assert sub_gate._model == "Impulse Cover" + assert shutter._manufacturer == "BTicino" + assert shutter._model == "Shutter / Cover" + + for ent in (*lights, *covers): + ent.hass = hass + ent.async_write_ha_state = MagicMock() + ent.async_schedule_update_ha_state = MagicMock() + + # Local light frame *1*1*71## turns on the local light and does NOT actuate sub_gate + sub_gate._attr_is_closed = True + async_dispatcher_send(hass, f"myhome_message_{mac}", OWNEvent.parse("*1*1*71##")) + assert lights[0].is_on is True + assert sub_gate.is_opening is False + + # Sub-bus frame *1*1*71#4#01## routes to sub_gate via light.route_foreign + async_dispatcher_send(hass, f"myhome_message_{mac}", OWNEvent.parse("*1*1*71#4#01##")) + assert sub_gate.is_opening is True + + sub_gate._cancel_travel_timer() + await sub_gate.async_will_remove_from_hass() + diff --git a/tests/test_component_lock.py b/tests/test_component_lock.py index f609b1f53..f79d0f6fb 100644 --- a/tests/test_component_lock.py +++ b/tests/test_component_lock.py @@ -57,7 +57,7 @@ def mock_gateway(): async def test_lock_setup_restores_and_ignores_bus_traffic(hass: HomeAssistant, mock_gateway): - """Locks come from the registry and myhome.yaml only; bus traffic never creates one.""" + """Locks come from myhome.yaml (restored from the registry when still configured); bus traffic and removed entries never create one.""" mac = mock_gateway.mac hass.data = { DOMAIN: { @@ -65,6 +65,11 @@ async def test_lock_setup_restores_and_ignores_bus_traffic(hass: HomeAssistant, "entity": mock_gateway, CONF_PLATFORMS: { PLATFORM: { + "1": { + CONF_WHERE: "1", + CONF_NAME: "Front Door Lock", + "code": "1234", + }, "2": { CONF_WHERE: "2", CONF_NAME: "Side Gate Lock", @@ -87,13 +92,16 @@ async def test_lock_setup_restores_and_ignores_bus_traffic(hass: HomeAssistant, reg_1 = MagicMock() reg_1.domain = Platform.LOCK reg_1.unique_id = f"{mac}-6-1" + reg_removed = MagicMock() # removed from myhome.yaml: must not resurrect without its code (H-5) + reg_removed.domain = Platform.LOCK + reg_removed.unique_id = f"{mac}-6-9" reg_light = MagicMock() # another subsystem's entry is never restored as a lock reg_light.domain = Platform.LOCK reg_light.unique_id = f"{mac}-1-7" with ( patch("homeassistant.helpers.entity_registry.async_get", return_value=mock_er), - patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[reg_1, reg_light]), + patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[reg_1, reg_removed, reg_light]), ): added_entities = [] @@ -103,10 +111,11 @@ def fake_add_entities(entities): attach_runtime(hass, config_entry) await async_setup_entry(hass, config_entry, fake_add_entities) - # Restored (1) + Configured from YAML (2) = 2 locks; the general WHERE 0 is not a lock + # Restored (1) + Configured from YAML (2) = 2 locks; removed (9) and general (0) are rejected assert len(added_entities) == 2 - assert any(e._where == "1" for e in added_entities) + assert any(e._where == "1" and e._code == "1234" for e in added_entities) assert any(e._where == "2" for e in added_entities) + assert not any(e._where == "9" for e in added_entities) # A lock release seen on the bus must NOT create a lock: the entity would have # no code, so anyone with dashboard access could open the door. @@ -315,3 +324,56 @@ async def test_lock_confirmed_write_future(hass: HomeAssistant, mock_gateway): assert fut.done() lock._cancel_auto_relock() await lock.async_will_remove_from_hass() + + +async def test_lock_receives_live_bus_frames_and_isolates_sub_bus(hass: HomeAssistant, mock_gateway): + """Configured locks receive live WHO=6 bus frames (C-1) without auto-discovering unconfigured locks or colliding across F422 (H-2).""" + mac = mock_gateway.mac + hass.data.setdefault("myhome", {})[mac] = { + "entity": mock_gateway, + "platforms": { + "lock": { + "6-1": {"who": "6", "where": "1", "name": "Local Lock", "manufacturer": None, "model": None}, + "6-5#4#01": {"who": "6", "where": "5", "interface": "01", "name": "Sub-bus Lock"}, + "2-9": {"who": "2", "where": "9", "name": "Wrong WHO Lock"}, + } + }, + } + entry = MagicMock() + entry.data = {"mac": mac} + entry.entry_id = "lock_bus_entry" + attach_runtime(hass, entry) + + added: list[MyHOMELock] = [] + with ( + patch("custom_components.myhome.discovery.er.async_entries_for_config_entry", return_value=[]), + patch("custom_components.myhome.discovery.er.async_get"), + ): + await async_setup_entry(hass, entry, added.extend) + + assert len(added) == 2 + local_lock = next(e for e in added if e._where == "1") + sub_lock = next(e for e in added if e._where == "5") + assert local_lock._manufacturer == "BTicino" + assert local_lock._model == "Door Entry Lock" + + for lck in added: + lck.hass = hass + lck.async_write_ha_state = MagicMock() + + # Unconfigured bus frame *6*10*2## must NOT auto-discover a new lock + async_dispatcher_send(hass, f"myhome_message_{mac}", OWNEvent.parse("*6*10*2##")) + assert len(added) == 2 + + # Local frame *6*10*5## must NOT unlock the F422 sub-bus lock at 5#4#01 + async_dispatcher_send(hass, f"myhome_message_{mac}", OWNEvent.parse("*6*10*5##")) + assert sub_lock.is_locked is True + + # Live bus frame *6*10*1## unlocks local_lock via PlatformDiscovery listener + async_dispatcher_send(hass, f"myhome_message_{mac}", OWNEvent.parse("*6*10*1##")) + assert local_lock.is_locked is False + + for lck in added: + lck._cancel_auto_relock() + await lck.async_will_remove_from_hass() + diff --git a/tests/test_door_entry_events.py b/tests/test_door_entry_events.py index ace16c733..2b2044e97 100644 --- a/tests/test_door_entry_events.py +++ b/tests/test_door_entry_events.py @@ -180,6 +180,7 @@ async def test_doorbell_only_for_entrance_panel_calls(hass: HomeAssistant): def test_unreadable_call_kind_is_none(): """A call kind that is not a number (from the library or the raw parameters) reads as None.""" assert GatewayEventDispatcher._intercom_call_kind(MagicMock(call_kind="x")) is None + assert GatewayEventDispatcher._intercom_call_kind(MagicMock(call_kind=None, what_param=["3"])) == 3 assert GatewayEventDispatcher._intercom_call_kind(MagicMock(call_kind=None, _what_param=["x"])) is None assert GatewayEventDispatcher._intercom_call_kind(MagicMock(call_kind=None, _what_param=[])) is None @@ -198,7 +199,7 @@ async def test_doorbell_ignores_library_call_flags_for_who8(hass: HomeAssistant, async def test_doorbell_fires_once_on_a_shared_bus(hass: HomeAssistant): - """Only the gateway that owns the subsystem announces the ring; the other one stays silent.""" + """Only the gateway that owns the subsystem announces the ring; standby failover routes under primary MAC.""" gateway = _doorbell_gateway(hass) captured = async_capture_events(hass, "myhome_doorbell_event") dispatched: list[dict] = [] @@ -210,6 +211,11 @@ def record(payload): unsub = async_dispatcher_connect(hass, f"myhome_doorbell_event_{gateway.mac}", record) try: + # Translation frames on WHO 6/8 are ignored (M-3) + translated = MagicMock(who=6, what=6, where="1", is_translation=True) + await gateway._process_message(translated) + assert captured == [] + with patch.object(MyHOMEGatewayHandler, "delegated_away_whos", new_callable=PropertyMock, return_value={6, 8}): await gateway._process_message(OWNEvent.parse("*8*1#1#4*74##")) await gateway._process_message(OWNEvent.parse("*6*6*1##")) @@ -220,8 +226,30 @@ def record(payload): await gateway._process_message(OWNEvent.parse("*8*1#1#4*74##")) assert captured == [] + # Standby failover when primary is offline routes doorbell event under primary's MAC and entry_id (M-3) + primary_gw = MagicMock() + primary_gw.is_connected = False + primary_gw.mac = "00:03:50:aa:bb:cc" + primary_gw.config_entry = MagicMock(entry_id="primary_entry_id") + pri_dispatched: list[dict] = [] + unsub_pri = async_dispatcher_connect(hass, f"myhome_doorbell_event_{primary_gw.mac}", pri_dispatched.append) + try: + with ( + patch.object(MyHOMEGatewayHandler, "is_standby", new_callable=PropertyMock, return_value=True), + patch.object(gateway, "_get_primary_gateway", return_value=primary_gw), + patch.object(gateway, "_profile_supports_who", return_value=True), + ): + await gateway._process_message(OWNEvent.parse("*6*6*1##")) + assert len(captured) == 1 + assert captured[0].data["gateway_mac"] == primary_gw.mac + assert captured[0].data["entry_id"] == "primary_entry_id" + assert len(pri_dispatched) == 1 + finally: + unsub_pri() + await gateway._process_message(OWNEvent.parse("*8*1#1#4*74##")) - assert len(captured) == 1 + assert len(captured) == 2 assert len(dispatched) == 1 finally: unsub() + diff --git a/tests/test_validate.py b/tests/test_validate.py index 303f296c0..b37d4dc16 100644 --- a/tests/test_validate.py +++ b/tests/test_validate.py @@ -805,18 +805,31 @@ def test_who8_lock_rejected(self): def test_who_defaults_to_6(self): res = lock_schema({"lock1": {CONF_WHERE: "4001", CONF_NAME: "Front door"}}) assert "6-4001" in res + assert res["6-4001"]["pulse_duration"] == 5.0 - @pytest.mark.parametrize("where", ["4001#2", "4095#2", "4000", "1"]) + @pytest.mark.parametrize("where", ["4001#2", "4095#2", "4000", "1", "99", "4099"]) def test_who6_riser_and_panel_addresses_validate(self, where): - """WHO 6 endpoints are 4000..4095, with #2 appended on a riser installation.""" + """WHO 6 endpoints are 1..99 or 4000..4099, with #2 appended on a riser installation.""" res = lock_schema({"lock1": {CONF_WHO: "6", CONF_WHERE: where, CONF_NAME: "Door"}}) assert f"6-{where}" in res - @pytest.mark.parametrize("where", ["4001#3", "#2", "40#2#2", "abc", "12345", "4001#"]) + @pytest.mark.parametrize( + "where", + [ + "4001#3", "#2", "40#2#2", "abc", "12345", "4001#", "100", "500", + "3999", "4101", "9999", "01", "0001", "0000", "0#2", "4100#2", + ], + ) def test_who6_malformed_where_rejected(self, where): - with pytest.raises(Invalid): + with pytest.raises(Invalid, match="Invalid door entry WHERE"): lock_schema({"lock1": {CONF_WHO: "6", CONF_WHERE: where, CONF_NAME: "Door"}}) + @pytest.mark.parametrize("bad_code", ["abc", "12-34", "123a", ""]) + def test_lock_non_digit_code_rejected(self, bad_code): + """Lock code must contain only digits so it matches code_format.""" + with pytest.raises(Invalid, match="only digits"): + lock_schema({"lock1": {CONF_WHO: "6", CONF_WHERE: "4001", CONF_NAME: "Door", "code": bad_code}}) + def test_who6_validates(self): """WHO=6 door entry lock with entrance panel WHERE succeeds.""" data = { @@ -890,3 +903,14 @@ def test_pin_code_needs_an_access_block(self): with pytest.raises(Invalid, match="pin_code is checked by the access policy"): self._cover(**{CONF_WHO: "1", "pin_code": "1234"}) + @pytest.mark.parametrize("bad_pin", ["abcd", "12-34", "12a4", ""]) + def test_impulse_cover_non_digit_pin_rejected(self, bad_pin): + with pytest.raises(Invalid, match="only digits"): + self._cover(**{CONF_WHO: "1", "pin_code": bad_pin, "access": {}}) + + @pytest.mark.parametrize(("pulse", "cycle"), [(2.0, 1.0), (1.5, 1.5)]) + def test_impulse_cover_min_cycle_time_must_exceed_pulse_duration(self, pulse, cycle): + with pytest.raises(Invalid, match="min_cycle_time must be greater than pulse_duration"): + self._cover(**{CONF_WHO: "1", "pulse_duration": pulse, "min_cycle_time": cycle}) + + From 7b0f5c26b0f137e6fcd1370b5801ec3f8cc038b7 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sun, 18 Oct 2026 14:31:54 +0200 Subject: [PATCH 24/26] test(door-entry,lock): replay Issue #669 MH202 visitor doorbell and lock release trace (#667) --- tests/test_issue_667_intercom_trace.py | 110 +++++++++++++++++++++++++ 1 file changed, 110 insertions(+) diff --git a/tests/test_issue_667_intercom_trace.py b/tests/test_issue_667_intercom_trace.py index 1ecf300ca..f258ea1de 100644 --- a/tests/test_issue_667_intercom_trace.py +++ b/tests/test_issue_667_intercom_trace.py @@ -232,3 +232,113 @@ async def test_intercom_ts10_firmware_oracle_conformance(hass: HomeAssistant) -> assert mh200_cmd["reply"] == "ack" assert mh200_cmd["verdict"] == "out" assert len(mh200_cmd["bus_frames"]) == 1 + + +@pytest.mark.asyncio +async def test_issue_669_mh202_visitor_doorbell_and_lock_trace_replay(hass: HomeAssistant) -> None: + """Replay the 61-frame MH202 visitor doorbell call and lock release trace (#669). + + Verifies that: + 1. All 61 frames process through MyHOMEGatewayHandler._process_message cleanly. + 2. The 4-component WHO 8 call (*8*1#1#4#20*11##) fires exactly one + myhome_doorbell_event for indoor unit where="11", while the +4 ms caller ID + notification (*8*9#1#4*20##) does not fire a second doorbell event. + 3. Both WHO 6 lock release frames (*6*22*1## in-conversation and *6*10*4000## + entrance panel) unlock their respective MyHOMELock entities, and the + subsequent pulse-release frames (*6*24*1##, *6*23*4000##) and camera teardown + (*6*9**##) are handled without errors. + """ + from custom_components.myhome.lock import MyHOMELock + + mh202_trace_path = ( + Path(__file__).resolve().parent + / "fixtures" + / "traces" + / "mh202_sound_intercom_ducking" + / "myhome_trace_MH202_all_2026-10-18T10-27-48.json" + ) + assert mh202_trace_path.is_file(), f"Missing trace fixture: {mh202_trace_path}" + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.1.51", + CONF_PORT: 20000, + CONF_PASSWORD: "open", + CONF_MAC: "00:03:50:11:22:33", + }, + entry_id="test_mh202_issue_669_doorbell_entry", + ) + entry.add_to_hass(hass) + gateway = MyHOMEGatewayHandler(hass, entry) + + lock_4000 = MyHOMELock( + hass=hass, + name="Entrance Panel Lock 4000", + entity_name=None, + device_id="4000", + who="6", + where="4000", + interface=None, + manufacturer="BTicino", + model="Door Entry Lock", + gateway=gateway, + ) + lock_1 = MyHOMELock( + hass=hass, + name="Conversation Lock 1", + entity_name=None, + device_id="1", + who="6", + where="1", + interface=None, + manufacturer="BTicino", + model="Door Entry Lock", + gateway=gateway, + ) + lock_4000.async_write_ha_state = lambda: None # type: ignore[method-assign] + lock_1.async_write_ha_state = lambda: None # type: ignore[method-assign] + assert lock_4000.is_locked is True + assert lock_1.is_locked is True + + captured_events: list[Event] = [] + unsub_event = hass.bus.async_listen( + "myhome_doorbell_event", + lambda event: captured_events.append(event), + ) + + try: + data = json.loads(mh202_trace_path.read_text(encoding="utf-8")) + assert len(data["frames"]) == 61 + + for frame_entry in data["frames"]: + raw = frame_entry["raw"] + msg = OWNMessage.parse(raw) + assert msg is not None, f"Failed to parse frame: {raw}" + await gateway._process_message(msg) + if getattr(msg, "who", None) == 6: + if getattr(msg, "where", None) == "4000": + lock_4000.handle_event(msg) + elif getattr(msg, "where", None) == "1": + lock_1.handle_event(msg) + + await hass.async_block_till_done() + + # Exactly 1 doorbell event fired from *8*1#1#4#20*11## (and not from *8*9#1#4*20##) + assert len(captured_events) == 1 + assert captured_events[0].data == { + "where": "11", + "event": "call", + "is_broadcast": False, + "gateway_mac": gateway.mac, + "entry_id": entry.entry_id, + } + + # Both locks unlocked on *6*10*4000## and *6*22*1## + assert lock_4000.is_locked is False + assert lock_1.is_locked is False + finally: + lock_4000._cancel_auto_relock() + lock_1._cancel_auto_relock() + unsub_event() + From 9aba1345feb261911cb08adc137d5f747467bd34 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sun, 18 Oct 2026 15:06:09 +0200 Subject: [PATCH 25/26] test(door-entry): use spec=OWNDoorEntryEvent for WHO 6/8 translation frame coverage --- tests/test_door_entry_events.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_door_entry_events.py b/tests/test_door_entry_events.py index 2b2044e97..88b9beb80 100644 --- a/tests/test_door_entry_events.py +++ b/tests/test_door_entry_events.py @@ -212,7 +212,7 @@ def record(payload): try: # Translation frames on WHO 6/8 are ignored (M-3) - translated = MagicMock(who=6, what=6, where="1", is_translation=True) + translated = MagicMock(spec=OWNDoorEntryEvent, who=6, what=6, where="1", is_translation=True) await gateway._process_message(translated) assert captured == [] From ea95d4155edafdc66736f2a91f564929d2524c0c Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Mon, 19 Oct 2026 10:35:35 +0200 Subject: [PATCH 26/26] test(cover,intercom): add live F461 + BT-F411/4 WHAT=18 impulse and WHO 6/8 trace (#667) --- docs/configuration/covers.md | 2 +- tests/fixtures/traces/issue_667/README.md | 85 +- ...race_F461_impulse_intercom_2026-10-19.json | 1895 +++++++++++++++++ tests/test_issue_667_intercom_trace.py | 354 ++- 4 files changed, 2232 insertions(+), 104 deletions(-) create mode 100644 tests/fixtures/traces/issue_667/myhome_trace_F461_impulse_intercom_2026-10-19.json diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index 8c1ebfa32..fb4f3fd2a 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -470,7 +470,7 @@ Add an `access:` block to opt in to the supervised workflow (phone approval, PIN | Key | Meaning | | :--- | :--- | | `state_sensor` | Binary sensor of the ground-truth position. Without it the position is assumed and, with `access:`, every pulse is treated as one that can close the door. | -| `pulse_duration` | Relay pulse in seconds. `0.5` uses the timed WHAT 18 command (`*1*18*WHERE##`, specified in `WHO_1.pdf` for 0.5 s timed ON; not yet replayed in the firmware oracle) where the installed OWNd exposes `turn_on_for_half_second`, and ON (`*1*1*WHERE##`) followed by OFF (`*1*0*WHERE##`) otherwise. | +| `pulse_duration` | Relay pulse in seconds. `0.5` uses the timed WHAT 18 command (`*1*18*WHERE##`, specified in `WHO_1.pdf` for 0.5 s timed ON and verified on physical `F461` + `BT-F411/4` hardware in `tests/fixtures/traces/issue_667/myhome_trace_F461_impulse_intercom_2026-10-19.json`, where the actuator emits `*1*1*WHERE##` and autonomously turns off with `*1*0*WHERE##` after ~0.53 s; not yet replayed in the QEMU firmware oracle) where the installed OWNd exposes `switch_on_timed`, and ON (`*1*1*WHERE##`) followed by OFF (`*1*0*WHERE##`) otherwise. | | `min_cycle_time` | Deadband after a pulse that reached the bus (must be strictly greater than `pulse_duration`). A failed send does not start it. | | `pin_code` | Digit-only PIN asked in the approval notification; only valid together with `access:`. A reply that carries no text is refused without counting as a wrong attempt. | | `access.*` | Optional. Turns on the supervised workflow, see the flow above: `allowed_users`, `approvers`, `remote_close`, `camera`, `safety_devices_verified`, `safety_check_days`, `prewarn_light`, `prewarn_seconds`, `close_block_entity`, `watchdog_margin`, `approval_timeout`. | diff --git a/tests/fixtures/traces/issue_667/README.md b/tests/fixtures/traces/issue_667/README.md index 4bfdd6534..97487037b 100644 --- a/tests/fixtures/traces/issue_667/README.md +++ b/tests/fixtures/traces/issue_667/README.md @@ -1,57 +1,50 @@ -# #667 MH200 Video Door Entry, Intercom & Impulse Gate/Garage Trace +# #667 MH200 & F461 Video Door Entry, Intercom & Impulse Gate/Garage Traces -Verbatim physical bus trace recorded on a real-world BTicino MyHOME installation with an **MH200** scenario gateway (Firmware 2.1.0). +Authentic physical bus traces recorded on two BTicino MyHOME plants for PR #667: +1. **BTicino MH200 (Firmware 2.1.0)**: Outdoor entrance panel bell ring, WHO 8 video intercom call, indoor handset pickup, `*6*9##` camera teardown, and WHO 1 gate/garage impulse relay actuation. +2. **BTicino F461 (Firmware 2.0.11 / WHO 1013 `134.15.5.0`) + BT-F411/4 + BT-F411/2**: Live hardware verification of WHO 1 `WHAT=18` (`*1*18*06##` / `*1*18*07##`) 0.5-second hardware-timed impulse with autonomous hardware relay release, 3-step software impulse cover cycle (`OPEN` -> mid-flight `STOP` -> `CLOSE`), unpopulated relay `NACK` timing (`*1*18*71##`), and `WHO=6` / `WHO=8` command session behavior on an automation-only DIN gateway. > **Privacy & Anonymization Guarantee**: -> In accordance with project security standards, all physical bus addresses (`WHERE`), station numbers, and device identifiers have been deterministically mapped to fictitious mock addresses (`71` for garage relay, `72` for gate relay, `73` for outdoor entrance panel, `74` for indoor handset, and `81`–`88` for audio matrix zones). The mock addresses are 100% disjoint from the real plant's physical topology. No private addresses, credentials, or network identifiers are present. - -## Hardware Profile - -- **Gateway**: BTicino MH200 -- **Firmware**: 2.1.0 -- **WHO 13 Device Type Code**: `44` -- **Subsystems Verified**: - - `WHO 1` (Lighting / Impulse Relay Actuators): Garage and gate pulse operations. - - `WHO 8` (Advanced Video Door Entry & Intercom): Entrance panel call initiation and caller address notifications. - - `WHO 6` (Door Entry): Video camera session termination (`*6*9##`). - - `WHO 16` (Sound Diffusion / Audio Routing): Multi-zone audio matrix setup during intercom speech. - - `WHO 13` (Gateway Management): Date/time broadcasts and keepalive heartbeats. +> In accordance with project security standards, all physical network identifiers are scrubbed to RFC 5737 documentation addresses (`192.0.2.92`) and synthetic MACs (`00:03:50:00:04:97`), and customer station addresses in the MH200 capture are mapped to disjoint mock addresses (`71`–`74`, `81`–`88`). No private IP addresses, credentials, or personal identifiers are present. ## Contributed Files -| File | Type | Description | +| File | Gateway & Hardware | Description | |---|---|---| -| `myhome_trace_MH200_intercom_bell_gate_2026-10-09.json` | Bus Card Export (34 frames) | Verbatim on-wire capture via `` across a complete physical door entry and access cycle. | +| `myhome_trace_MH200_intercom_bell_gate_2026-10-09.json` | **MH200** (`2.1.0`, WHO 13 code `44`) | 34-frame on-wire capture across an outdoor entrance panel video call (`*8*1#1#4*74##`), audio matrix routing (`*#16*81*1*1##`..`88##`), handset answer (`*8*9#1#4*73##`), camera off (`*6*9##`), and gate/garage pulses (`*1*1*71##`, `*1*1*72##`). | +| `myhome_trace_F461_impulse_intercom_2026-10-19.json` | **F461** (`2.0.11`, WHO 13 code `200`, WHO 1013 code `134`) + **BT-F411/4** (`06`, `07`) + **BT-F411/2** (`05`) | 34 `evt` frames and 161 chronological `cmd`/`evt`/`result` rows across 4 structured runs (`run1_f411_impulse_what18_and_software_pulse`, `run2_who6_door_entry_lock_and_camera`, `run3_who8_intercom_call_kinds`, `run4_plant_idle_verification`). | ## Sequence of Actions Recorded +### 1. `myhome_trace_MH200_intercom_bell_gate_2026-10-09.json` (MH200) + 1. **Garage & Gate Impulse Open (from Garage Wall Switch)**: - - User walks outside; presses the garage wall switch: - - `*1*1*71##` (Pulsed garage door motor contact via WHO 1) - - `*1*1*72##` (Pulsed entrance gate motor contact via WHO 1) -2. **Gate Closed via Outdoor Keypad Code**: - - At the entrance gate keypad, user enters code to close: - - `*1*1*72##` (Impulse close pulse) -3. **Gate Opened via Outdoor Keypad Code (Retry after rapid input)**: - - First attempt entered too rapidly; second attempt triggers gate opening: - - `*1*1*72##` (Impulse open pulse) -4. **Outdoor Entrance Panel Bell Pressed (Aanbellen)**: - - User presses the call button on the external entrance panel (PE1): - - `*#16*81*1*1##` through `*#16*88*1*1##`: Audio matrix activates routing on sound system zones. - - `*8*1#1#4*74##`: **WHO 8 Video Door Entry Call initiated** (`Kind=1` PE1, `MMType=4` Audio/Video) directed to indoor handset `74`. -5. **Call Answered & Completed on Indoor Handset (Keuken Parlefoon)**: - - Handset picked up / speak button pressed; brief speech; hung up: - - `*8*9#1#4*73##`: Caller address notification from entrance panel (`Kind=1`, `MMType=4`, address `73`). - - `*6*9##`: Camera session terminated (Camera OFF). -6. **Gate Closed from Secondary Handset (Woonkamer Parlefoon)**: - - User operates gate button from the living room intercom: - - `*1*1*72##` (Impulse close pulse). - -## Protocol Insights - -1. **WHO 8 Call Initiation**: - Real-world BTicino video entrance panels broadcast `*8*1#Kind#MMType*WHERE##` (e.g. `*8*1#1#4*74##` for Entrance Panel, Audio/Video to Handset 74). Home Assistant's doorbell event dispatcher listens for both WHO 6 (`*6*6*...` / `*6*20*...`) and WHO 8 (`*8*1#...`) to guarantee 100% interoperability with both audio-only and video intercom systems. -2. **Session Teardown**: - When the video call terminates, the MH200 emits the short-form WHO 6 camera-off frame `*6*9##`. -3. **Impulse Relays on WHO 1**: - Automated gates and garage doors in BTicino SCS installations are universally wired to WHO 1 relay actuators configured for monostable pulse actuation. + - `*1*1*71##` (Pulsed garage door motor contact via WHO 1) + - `*1*1*72##` (Pulsed entrance gate motor contact via WHO 1) +2. **Gate Closed & Re-Opened via Outdoor Keypad Code**: + - `*1*1*72##` (Impulse close pulse, followed by open pulse) +3. **Outdoor Entrance Panel Bell Pressed**: + - `*#16*81*1*1##` through `*#16*88*1*1##`: Audio matrix activates routing on sound system zones. + - `*8*1#1#4*74##`: **WHO 8 Video Door Entry Call initiated** (`Kind=1` PE1, `MMType=4` Audio/Video) directed to indoor handset `74`. +4. **Call Answered & Completed on Indoor Handset**: + - `*8*9#1#4*73##`: Caller address notification from entrance panel (`Kind=1`, `MMType=4`, address `73`). + - `*6*9##`: Camera session terminated (Camera OFF). +5. **Gate Closed from Secondary Handset**: + - `*1*1*72##` (Impulse close pulse). + +### 2. `myhome_trace_F461_impulse_intercom_2026-10-19.json` (F461 + BT-F411/4) + +1. **`run1_f411_impulse_what18_and_software_pulse`**: + - **Hardware-timed 0.5 s pulse (`WHAT=18`: `*1*18*06##` and `*1*18*07##`)**: + - `F461` ACKs `*1*18*06##` in `0.090 s` (`0.093 s` on `07`). + - The physical `BT-F411/4` actuator immediately closes the relay and emits `*1*1*06##` (`t = 4.315 s`) and Group `#1` aggregate status `*#1*#1*15*1##` (`t = 4.388 s`). + - Exactly **0.539 seconds later** (`t = 4.854 s` on `06`; **0.511 seconds later** at `t = 8.007 s` on `07`), the physical `BT-F411/4` relay **autonomously opens in hardware** and broadcasts `*1*0*06##` + `*#1*#1*15*0##` on the event monitor session without any software `OFF` command from Home Assistant. + - Subsequent status polls (`*#1*06##`, `*#1*07##`) confirm `['*1*0*06##']` and `['*1*0*07##']`. + - **Software-timed 0.5 s impulse cover cycle (`OPEN` -> `STOP` -> `CLOSE` on `06`)**: + - Exercises the software `ON` (`*1*1*06##`) + 0.5 s `OFF` (`*1*0*06##`) fallback across a full 3-step `OPEN` (1.5 s travel) -> mid-flight `STOP` (1.0 s settle) -> `CLOSE` cycle on physical `BT-F411/4` channel `06`. + - **Unpopulated relay address `71` (`*1*18*71##` and `*1*1*71##`)**: + - `bt_fls` on the `F461` waits `2.065 s` / `2.071 s` for an actuator response on the 2-wire SCS bus, emits nothing on `evt`, and returns `*#*0##` (`NACK`), validating that `MyHOMEImpulseCover` only starts `min_cycle_time` after a confirmed bus write. +2. **`run2_who6_door_entry_lock_and_camera` & `run3_who8_intercom_call_kinds`**: + - On an automation-only DIN gateway without a video door-entry riser (`F461`), `WHO=6` (`*6*10*4000##`, `*6*10*4001#2##`, `*6*10*1##`, `*6*22*1##`, `*6*0*4000##`, `*6*9##`, `*6*9**##`, `*6*6*1##`, `*6*6*4100##`) and `WHO=8` (`*8*1#1#4*74##`, `*8*1#1#4#20*11##`, `*8*9#1#4*73##`, `*8*1#6#2#11*16##`, `*8*1#7#2#11*16##`, `*8*1#14#2#11*4##`) return a fast `~55 ms` `NACK` (`*#*0##`) with no bus echo. +3. **`run4_plant_idle_verification`**: + - Final polls (`*#1*06##`, `*#1*07##`, `*#2*05##`) confirm both `BT-F411/4` channels are `OFF` and the `BT-F411/2` shutter (`05`) is stopped (`*2*0*05##`). diff --git a/tests/fixtures/traces/issue_667/myhome_trace_F461_impulse_intercom_2026-10-19.json b/tests/fixtures/traces/issue_667/myhome_trace_F461_impulse_intercom_2026-10-19.json new file mode 100644 index 000000000..d88cfcae0 --- /dev/null +++ b/tests/fixtures/traces/issue_667/myhome_trace_F461_impulse_intercom_2026-10-19.json @@ -0,0 +1,1895 @@ +{ + "capture": { + "kind": "derived_trace_archive", + "started_at": "2026-10-19T08:31:02+00:00", + "notes": "Live BTicino F461 (firmware 2.0.11 / WHO 1013 134.15.5.0) + BT-F411/4 capture for PR #667: WHO=1 WHAT=18 (0.5s hardware timed impulse) vs software ON/OFF impulse cover cycle on physical relays 06 & 07, unpopulated relay NACK verification (71), WHO=6 door lock release & camera teardown frames, and WHO=8 video intercom call frames.", + "window": { + "first": "2026-10-19T08:31:02+00:00", + "last": "2026-10-19T08:31:31+00:00", + "frames": 34, + "rows": 161 + } + }, + "gateway": { + "model": "F461", + "manufacturer": "BTicino", + "firmware": "2.0.11", + "kernel": "5.10.35", + "ip": "192.0.2.92", + "mac": "00:03:50:00:04:97", + "identification": { + "model": "F461", + "who13_code": "200", + "who1013_code": "134" + } + }, + "frames": [ + { + "timestamp": 1.43, + "direction": "rx", + "raw": "*#13**15*200##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 1.882, + "direction": "rx", + "raw": "*#13**16*2*0*11##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 2.375, + "direction": "rx", + "raw": "*1*0*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 2.872, + "direction": "rx", + "raw": "*1*0*07##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 3.356, + "direction": "rx", + "raw": "*1*0*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 3.839, + "direction": "rx", + "raw": "*1*0*07##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 4.315, + "direction": "rx", + "raw": "*1*1*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 4.388, + "direction": "rx", + "raw": "*#1*#1*15*1##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 4.854, + "direction": "rx", + "raw": "*1*0*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 4.941, + "direction": "rx", + "raw": "*#1*#1*15*0##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 6.424, + "direction": "rx", + "raw": "*1*0*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 7.003, + "direction": "rx", + "raw": "*1*0*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 7.496, + "direction": "rx", + "raw": "*1*1*07##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 7.575, + "direction": "rx", + "raw": "*#1*#1*15*1##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 8.007, + "direction": "rx", + "raw": "*1*0*07##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 8.094, + "direction": "rx", + "raw": "*#1*#1*15*0##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 9.605, + "direction": "rx", + "raw": "*1*0*07##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 10.195, + "direction": "rx", + "raw": "*1*0*07##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 10.674, + "direction": "rx", + "raw": "*1*1*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 10.747, + "direction": "rx", + "raw": "*#1*#1*15*1##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 11.283, + "direction": "rx", + "raw": "*1*0*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 11.362, + "direction": "rx", + "raw": "*#1*#1*15*0##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 12.882, + "direction": "rx", + "raw": "*1*1*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 12.971, + "direction": "rx", + "raw": "*#1*#1*15*1##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 13.477, + "direction": "rx", + "raw": "*1*0*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 13.572, + "direction": "rx", + "raw": "*#1*#1*15*0##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 14.573, + "direction": "rx", + "raw": "*1*1*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 14.654, + "direction": "rx", + "raw": "*#1*#1*15*1##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 15.183, + "direction": "rx", + "raw": "*1*0*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 15.255, + "direction": "rx", + "raw": "*#1*#1*15*0##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 15.802, + "direction": "rx", + "raw": "*1*0*06##", + "run": "run1_f411_impulse_what18_and_software_pulse", + "session": "evt" + }, + { + "timestamp": 27.861, + "direction": "rx", + "raw": "*1*0*06##", + "run": "run4_plant_idle_verification", + "session": "evt" + }, + { + "timestamp": 28.347, + "direction": "rx", + "raw": "*1*0*07##", + "run": "run4_plant_idle_verification", + "session": "evt" + }, + { + "timestamp": 28.846, + "direction": "rx", + "raw": "*2*0*05##", + "run": "run4_plant_idle_verification", + "session": "evt" + } + ], + "rows": [ + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 1.378, + "step": "gateway_id", + "dir": "tx", + "session": "cmd", + "frame": "*#13**15##", + "is_status_request": true + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 1.43, + "step": "gateway_id", + "dir": "rx", + "session": "cmd", + "frame": "*#13**15*200##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 1.43, + "step": "gateway_id", + "dir": "rx", + "session": "evt", + "frame": "*#13**15*200##", + "ownd_class": "OWNGatewayEvent", + "ownd_log": "Gateway device type is: F454 / MyHomeServer1 / MH202 / F461 / H4890 / F450 / F459 / F460 / MH201." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 1.521, + "step": "gateway_id", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 1.822, + "step": "gateway_id", + "dir": "result", + "frame": "*#13**15##", + "ack": true, + "reply_time_s": 0.144, + "replies": [ + "*#13**15*200##" + ], + "events_after": [ + "*#13**15*200##" + ], + "note": "who13_device_type" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 1.822, + "step": "gateway_id", + "dir": "tx", + "session": "cmd", + "frame": "*#13**16##", + "is_status_request": true + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 1.882, + "step": "gateway_id", + "dir": "rx", + "session": "evt", + "frame": "*#13**16*2*0*11##", + "ownd_class": "OWNGatewayEvent", + "ownd_log": "Gateway's firmware version is: 2.0.11." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 1.882, + "step": "gateway_id", + "dir": "rx", + "session": "cmd", + "frame": "*#13**16*2*0*11##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 1.943, + "step": "gateway_id", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 2.244, + "step": "gateway_id", + "dir": "result", + "frame": "*#13**16##", + "ack": true, + "reply_time_s": 0.121, + "replies": [ + "*#13**16*2*0*11##" + ], + "events_after": [ + "*#13**16*2*0*11##" + ], + "note": "who13_firmware_version" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 2.244, + "step": "ensure_off", + "dir": "tx", + "session": "cmd", + "frame": "*1*0*06##", + "is_status_request": false + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 2.334, + "step": "ensure_off", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 2.375, + "step": "ensure_off", + "dir": "rx", + "session": "evt", + "frame": "*1*0*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 2.734, + "step": "ensure_off", + "dir": "result", + "frame": "*1*0*06##", + "ack": true, + "reply_time_s": 0.09, + "replies": [], + "events_after": [ + "*1*0*06##" + ], + "note": "ensure_06_off" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 2.734, + "step": "ensure_off", + "dir": "tx", + "session": "cmd", + "frame": "*1*0*07##", + "is_status_request": false + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 2.841, + "step": "ensure_off", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 2.872, + "step": "ensure_off", + "dir": "rx", + "session": "evt", + "frame": "*1*0*07##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 07 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 3.242, + "step": "ensure_off", + "dir": "result", + "frame": "*1*0*07##", + "ack": true, + "reply_time_s": 0.106, + "replies": [], + "events_after": [ + "*1*0*07##" + ], + "note": "ensure_07_off" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 3.242, + "step": "poll_initial", + "dir": "tx", + "session": "cmd", + "frame": "*#1*06##", + "is_status_request": true + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 3.356, + "step": "poll_initial", + "dir": "rx", + "session": "evt", + "frame": "*1*0*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 3.358, + "step": "poll_initial", + "dir": "rx", + "session": "cmd", + "frame": "*1*0*06##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 3.414, + "step": "poll_initial", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 3.715, + "step": "poll_initial", + "dir": "result", + "frame": "*#1*06##", + "ack": true, + "reply_time_s": 0.172, + "replies": [ + "*1*0*06##" + ], + "events_after": [ + "*1*0*06##" + ], + "note": "poll_06_initial_off" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 3.715, + "step": "poll_initial", + "dir": "tx", + "session": "cmd", + "frame": "*#1*07##", + "is_status_request": true + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 3.839, + "step": "poll_initial", + "dir": "rx", + "session": "evt", + "frame": "*1*0*07##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 07 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 3.839, + "step": "poll_initial", + "dir": "rx", + "session": "cmd", + "frame": "*1*0*07##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 3.892, + "step": "poll_initial", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 4.192, + "step": "poll_initial", + "dir": "result", + "frame": "*#1*07##", + "ack": true, + "reply_time_s": 0.177, + "replies": [ + "*1*0*07##" + ], + "events_after": [ + "*1*0*07##" + ], + "note": "poll_07_initial_off" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 4.192, + "step": "what18_pulse_06", + "dir": "tx", + "session": "cmd", + "frame": "*1*18*06##", + "is_status_request": false + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 4.282, + "step": "what18_pulse_06", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 4.315, + "step": "what18_pulse_06", + "dir": "rx", + "session": "evt", + "frame": "*1*1*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched on." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 4.388, + "step": "what18_pulse_06", + "dir": "rx", + "session": "evt", + "frame": "*#1*#1*15*1##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light/motion sensor #1 has sent an unknown dimension 15." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 4.854, + "step": "what18_pulse_06", + "dir": "rx", + "session": "evt", + "frame": "*1*0*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 4.941, + "step": "what18_pulse_06", + "dir": "rx", + "session": "evt", + "frame": "*#1*#1*15*0##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light/motion sensor #1 has sent an unknown dimension 15." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 6.284, + "step": "what18_pulse_06", + "dir": "result", + "frame": "*1*18*06##", + "ack": true, + "reply_time_s": 0.09, + "replies": [], + "events_after": [ + "*1*1*06##", + "*#1*#1*15*1##", + "*1*0*06##", + "*#1*#1*15*0##" + ], + "note": "what18_timed_05s_pulse_on_06" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 6.284, + "step": "what18_poll_06", + "dir": "tx", + "session": "cmd", + "frame": "*#1*06##", + "is_status_request": true + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 6.421, + "step": "what18_poll_06", + "dir": "rx", + "session": "cmd", + "frame": "*1*0*06##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 6.424, + "step": "what18_poll_06", + "dir": "rx", + "session": "evt", + "frame": "*1*0*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 6.473, + "step": "what18_poll_06", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 6.874, + "step": "what18_poll_06", + "dir": "result", + "frame": "*#1*06##", + "ack": true, + "reply_time_s": 0.189, + "replies": [ + "*1*0*06##" + ], + "events_after": [ + "*1*0*06##" + ], + "note": "poll_06_after_what18_pulse" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 6.874, + "step": "what18_cleanup_06", + "dir": "tx", + "session": "cmd", + "frame": "*1*0*06##", + "is_status_request": false + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 6.97, + "step": "what18_cleanup_06", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 7.003, + "step": "what18_cleanup_06", + "dir": "rx", + "session": "evt", + "frame": "*1*0*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 7.371, + "step": "what18_cleanup_06", + "dir": "result", + "frame": "*1*0*06##", + "ack": true, + "reply_time_s": 0.096, + "replies": [], + "events_after": [ + "*1*0*06##" + ], + "note": "ensure_06_off_after_what18" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 7.371, + "step": "what18_pulse_07", + "dir": "tx", + "session": "cmd", + "frame": "*1*18*07##", + "is_status_request": false + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 7.464, + "step": "what18_pulse_07", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 7.496, + "step": "what18_pulse_07", + "dir": "rx", + "session": "evt", + "frame": "*1*1*07##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 07 is switched on." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 7.575, + "step": "what18_pulse_07", + "dir": "rx", + "session": "evt", + "frame": "*#1*#1*15*1##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light/motion sensor #1 has sent an unknown dimension 15." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 8.007, + "step": "what18_pulse_07", + "dir": "rx", + "session": "evt", + "frame": "*1*0*07##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 07 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 8.094, + "step": "what18_pulse_07", + "dir": "rx", + "session": "evt", + "frame": "*#1*#1*15*0##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light/motion sensor #1 has sent an unknown dimension 15." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 9.465, + "step": "what18_pulse_07", + "dir": "result", + "frame": "*1*18*07##", + "ack": true, + "reply_time_s": 0.093, + "replies": [], + "events_after": [ + "*1*1*07##", + "*#1*#1*15*1##", + "*1*0*07##", + "*#1*#1*15*0##" + ], + "note": "what18_timed_05s_pulse_on_07" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 9.465, + "step": "what18_poll_07", + "dir": "tx", + "session": "cmd", + "frame": "*#1*07##", + "is_status_request": true + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 9.605, + "step": "what18_poll_07", + "dir": "rx", + "session": "cmd", + "frame": "*1*0*07##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 9.605, + "step": "what18_poll_07", + "dir": "rx", + "session": "evt", + "frame": "*1*0*07##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 07 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 9.656, + "step": "what18_poll_07", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 10.057, + "step": "what18_poll_07", + "dir": "result", + "frame": "*#1*07##", + "ack": true, + "reply_time_s": 0.191, + "replies": [ + "*1*0*07##" + ], + "events_after": [ + "*1*0*07##" + ], + "note": "poll_07_after_what18_pulse" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 10.057, + "step": "what18_cleanup_07", + "dir": "tx", + "session": "cmd", + "frame": "*1*0*07##", + "is_status_request": false + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 10.155, + "step": "what18_cleanup_07", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 10.195, + "step": "what18_cleanup_07", + "dir": "rx", + "session": "evt", + "frame": "*1*0*07##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 07 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 10.555, + "step": "what18_cleanup_07", + "dir": "result", + "frame": "*1*0*07##", + "ack": true, + "reply_time_s": 0.098, + "replies": [], + "events_after": [ + "*1*0*07##" + ], + "note": "ensure_07_off_after_what18" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 10.555, + "step": "soft_pulse_open_on", + "dir": "tx", + "session": "cmd", + "frame": "*1*1*06##", + "is_status_request": false + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 10.641, + "step": "soft_pulse_open_on", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 10.674, + "step": "soft_pulse_open_on", + "dir": "rx", + "session": "evt", + "frame": "*1*1*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched on." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 10.747, + "step": "soft_pulse_open_on", + "dir": "rx", + "session": "evt", + "frame": "*#1*#1*15*1##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light/motion sensor #1 has sent an unknown dimension 15." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 11.143, + "step": "soft_pulse_open_on", + "dir": "result", + "frame": "*1*1*06##", + "ack": true, + "reply_time_s": 0.086, + "replies": [], + "events_after": [ + "*1*1*06##", + "*#1*#1*15*1##" + ], + "note": "impulse_open_relay_on_06" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 11.143, + "step": "soft_pulse_open_off", + "dir": "tx", + "session": "cmd", + "frame": "*1*0*06##", + "is_status_request": false + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 11.242, + "step": "soft_pulse_open_off", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 11.283, + "step": "soft_pulse_open_off", + "dir": "rx", + "session": "evt", + "frame": "*1*0*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 11.362, + "step": "soft_pulse_open_off", + "dir": "rx", + "session": "evt", + "frame": "*#1*#1*15*0##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light/motion sensor #1 has sent an unknown dimension 15." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 12.744, + "step": "soft_pulse_open_off", + "dir": "result", + "frame": "*1*0*06##", + "ack": true, + "reply_time_s": 0.099, + "replies": [], + "events_after": [ + "*1*0*06##", + "*#1*#1*15*0##" + ], + "note": "impulse_open_relay_off_06" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 12.744, + "step": "soft_pulse_stop_on", + "dir": "tx", + "session": "cmd", + "frame": "*1*1*06##", + "is_status_request": false + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 12.848, + "step": "soft_pulse_stop_on", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 12.882, + "step": "soft_pulse_stop_on", + "dir": "rx", + "session": "evt", + "frame": "*1*1*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched on." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 12.971, + "step": "soft_pulse_stop_on", + "dir": "rx", + "session": "evt", + "frame": "*#1*#1*15*1##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light/motion sensor #1 has sent an unknown dimension 15." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 13.349, + "step": "soft_pulse_stop_on", + "dir": "result", + "frame": "*1*1*06##", + "ack": true, + "reply_time_s": 0.103, + "replies": [], + "events_after": [ + "*1*1*06##", + "*#1*#1*15*1##" + ], + "note": "impulse_stop_relay_on_06" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 13.349, + "step": "soft_pulse_stop_off", + "dir": "tx", + "session": "cmd", + "frame": "*1*0*06##", + "is_status_request": false + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 13.444, + "step": "soft_pulse_stop_off", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 13.477, + "step": "soft_pulse_stop_off", + "dir": "rx", + "session": "evt", + "frame": "*1*0*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 13.572, + "step": "soft_pulse_stop_off", + "dir": "rx", + "session": "evt", + "frame": "*#1*#1*15*0##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light/motion sensor #1 has sent an unknown dimension 15." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 14.446, + "step": "soft_pulse_stop_off", + "dir": "result", + "frame": "*1*0*06##", + "ack": true, + "reply_time_s": 0.095, + "replies": [], + "events_after": [ + "*1*0*06##", + "*#1*#1*15*0##" + ], + "note": "impulse_stop_relay_off_06" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 14.446, + "step": "soft_pulse_close_on", + "dir": "tx", + "session": "cmd", + "frame": "*1*1*06##", + "is_status_request": false + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 14.543, + "step": "soft_pulse_close_on", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 14.573, + "step": "soft_pulse_close_on", + "dir": "rx", + "session": "evt", + "frame": "*1*1*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched on." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 14.654, + "step": "soft_pulse_close_on", + "dir": "rx", + "session": "evt", + "frame": "*#1*#1*15*1##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light/motion sensor #1 has sent an unknown dimension 15." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 15.045, + "step": "soft_pulse_close_on", + "dir": "result", + "frame": "*1*1*06##", + "ack": true, + "reply_time_s": 0.098, + "replies": [], + "events_after": [ + "*1*1*06##", + "*#1*#1*15*1##" + ], + "note": "impulse_close_relay_on_06" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 15.045, + "step": "soft_pulse_close_off", + "dir": "tx", + "session": "cmd", + "frame": "*1*0*06##", + "is_status_request": false + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 15.15, + "step": "soft_pulse_close_off", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 15.183, + "step": "soft_pulse_close_off", + "dir": "rx", + "session": "evt", + "frame": "*1*0*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 15.255, + "step": "soft_pulse_close_off", + "dir": "rx", + "session": "evt", + "frame": "*#1*#1*15*0##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light/motion sensor #1 has sent an unknown dimension 15." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 15.651, + "step": "soft_pulse_close_off", + "dir": "result", + "frame": "*1*0*06##", + "ack": true, + "reply_time_s": 0.105, + "replies": [], + "events_after": [ + "*1*0*06##", + "*#1*#1*15*0##" + ], + "note": "impulse_close_relay_off_06" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 15.651, + "step": "poll_after_cycle", + "dir": "tx", + "session": "cmd", + "frame": "*#1*06##", + "is_status_request": true + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 15.802, + "step": "poll_after_cycle", + "dir": "rx", + "session": "evt", + "frame": "*1*0*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched off." + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 15.802, + "step": "poll_after_cycle", + "dir": "rx", + "session": "cmd", + "frame": "*1*0*06##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 15.854, + "step": "poll_after_cycle", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 16.155, + "step": "poll_after_cycle", + "dir": "result", + "frame": "*#1*06##", + "ack": true, + "reply_time_s": 0.203, + "replies": [ + "*1*0*06##" + ], + "events_after": [ + "*1*0*06##" + ], + "note": "poll_06_after_impulse_cycle" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 16.155, + "step": "unpopulated_71", + "dir": "tx", + "session": "cmd", + "frame": "*1*18*71##", + "is_status_request": true + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 18.22, + "step": "unpopulated_71", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 18.521, + "step": "unpopulated_71", + "dir": "result", + "frame": "*1*18*71##", + "ack": false, + "reply_time_s": 2.065, + "replies": [], + "events_after": [], + "note": "what18_unpopulated_71_nack" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 18.521, + "step": "unpopulated_71", + "dir": "tx", + "session": "cmd", + "frame": "*1*1*71##", + "is_status_request": true + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 20.592, + "step": "unpopulated_71", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run1_f411_impulse_what18_and_software_pulse", + "t": 20.893, + "step": "unpopulated_71", + "dir": "result", + "frame": "*1*1*71##", + "ack": false, + "reply_time_s": 2.071, + "replies": [], + "events_after": [], + "note": "what1_unpopulated_71_nack" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 20.893, + "step": "lock_release", + "dir": "tx", + "session": "cmd", + "frame": "*6*10*4000##", + "is_status_request": true + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 20.951, + "step": "lock_release", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 21.352, + "step": "lock_release", + "dir": "result", + "frame": "*6*10*4000##", + "ack": false, + "reply_time_s": 0.059, + "replies": [], + "events_after": [], + "note": "who6_open_lock_4000" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 21.352, + "step": "lock_release_riser", + "dir": "tx", + "session": "cmd", + "frame": "*6*10*4001#2##", + "is_status_request": true + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 21.406, + "step": "lock_release_riser", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 21.807, + "step": "lock_release_riser", + "dir": "result", + "frame": "*6*10*4001#2##", + "ack": false, + "reply_time_s": 0.054, + "replies": [], + "events_after": [], + "note": "who6_open_lock_4001_riser2" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 21.807, + "step": "lock_release_panel1", + "dir": "tx", + "session": "cmd", + "frame": "*6*10*1##", + "is_status_request": true + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 21.858, + "step": "lock_release_panel1", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 22.259, + "step": "lock_release_panel1", + "dir": "result", + "frame": "*6*10*1##", + "ack": false, + "reply_time_s": 0.052, + "replies": [], + "events_after": [], + "note": "who6_open_lock_1" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 22.259, + "step": "lock_in_conv", + "dir": "tx", + "session": "cmd", + "frame": "*6*22*1##", + "is_status_request": true + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 22.311, + "step": "lock_in_conv", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 22.711, + "step": "lock_in_conv", + "dir": "result", + "frame": "*6*22*1##", + "ack": false, + "reply_time_s": 0.052, + "replies": [], + "events_after": [], + "note": "who6_in_conversation_lock_1" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 22.711, + "step": "camera_on", + "dir": "tx", + "session": "cmd", + "frame": "*6*0*4000##", + "is_status_request": true + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 22.766, + "step": "camera_on", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 23.167, + "step": "camera_on", + "dir": "result", + "frame": "*6*0*4000##", + "ack": false, + "reply_time_s": 0.054, + "replies": [], + "events_after": [], + "note": "who6_camera_on_4000" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 23.167, + "step": "camera_off_short", + "dir": "tx", + "session": "cmd", + "frame": "*6*9##", + "is_status_request": true + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 23.222, + "step": "camera_off_short", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 23.623, + "step": "camera_off_short", + "dir": "result", + "frame": "*6*9##", + "ack": false, + "reply_time_s": 0.055, + "replies": [], + "events_after": [], + "note": "who6_camera_off_short" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 23.623, + "step": "camera_off_empty_where", + "dir": "tx", + "session": "cmd", + "frame": "*6*9**##", + "is_status_request": true + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 23.681, + "step": "camera_off_empty_where", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 24.082, + "step": "camera_off_empty_where", + "dir": "result", + "frame": "*6*9**##", + "ack": false, + "reply_time_s": 0.059, + "replies": [], + "events_after": [], + "note": "who6_camera_off_empty_where" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 24.082, + "step": "doorbell_call_apt1", + "dir": "tx", + "session": "cmd", + "frame": "*6*6*1##", + "is_status_request": true + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 24.139, + "step": "doorbell_call_apt1", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 24.54, + "step": "doorbell_call_apt1", + "dir": "result", + "frame": "*6*6*1##", + "ack": false, + "reply_time_s": 0.056, + "replies": [], + "events_after": [], + "note": "who6_doorbell_call_1" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 24.54, + "step": "doorbell_call_broadcast", + "dir": "tx", + "session": "cmd", + "frame": "*6*6*4100##", + "is_status_request": true + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 24.6, + "step": "doorbell_call_broadcast", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run2_who6_door_entry_lock_and_camera", + "t": 25.0, + "step": "doorbell_call_broadcast", + "dir": "result", + "frame": "*6*6*4100##", + "ack": false, + "reply_time_s": 0.06, + "replies": [], + "events_after": [], + "note": "who6_doorbell_call_broadcast_4100" + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 25.0, + "step": "who8_pe1_call", + "dir": "tx", + "session": "cmd", + "frame": "*8*1#1#4*74##", + "is_status_request": true + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 25.049, + "step": "who8_pe1_call", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 25.45, + "step": "who8_pe1_call", + "dir": "result", + "frame": "*8*1#1#4*74##", + "ack": false, + "reply_time_s": 0.049, + "replies": [], + "events_after": [], + "note": "who8_pe1_call_74" + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 25.45, + "step": "who8_pe1_4comp_call", + "dir": "tx", + "session": "cmd", + "frame": "*8*1#1#4#20*11##", + "is_status_request": true + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 25.502, + "step": "who8_pe1_4comp_call", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 25.902, + "step": "who8_pe1_4comp_call", + "dir": "result", + "frame": "*8*1#1#4#20*11##", + "ack": false, + "reply_time_s": 0.051, + "replies": [], + "events_after": [], + "note": "who8_pe1_4comp_call_20_to_11" + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 25.902, + "step": "who8_pickup", + "dir": "tx", + "session": "cmd", + "frame": "*8*9#1#4*73##", + "is_status_request": true + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 25.962, + "step": "who8_pickup", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 26.363, + "step": "who8_pickup", + "dir": "result", + "frame": "*8*9#1#4*73##", + "ack": false, + "reply_time_s": 0.059, + "replies": [], + "events_after": [], + "note": "who8_pickup_speech_73" + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 26.363, + "step": "who8_internal_kind6", + "dir": "tx", + "session": "cmd", + "frame": "*8*1#6#2#11*16##", + "is_status_request": true + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 26.414, + "step": "who8_internal_kind6", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 26.815, + "step": "who8_internal_kind6", + "dir": "result", + "frame": "*8*1#6#2#11*16##", + "ack": false, + "reply_time_s": 0.051, + "replies": [], + "events_after": [], + "note": "who8_internal_intercom_kind6" + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 26.815, + "step": "who8_external_kind7", + "dir": "tx", + "session": "cmd", + "frame": "*8*1#7#2#11*16##", + "is_status_request": true + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 26.871, + "step": "who8_external_kind7", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 27.271, + "step": "who8_external_kind7", + "dir": "result", + "frame": "*8*1#7#2#11*16##", + "ack": false, + "reply_time_s": 0.056, + "replies": [], + "events_after": [], + "note": "who8_external_intercom_kind7" + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 27.271, + "step": "who8_pager_kind14", + "dir": "tx", + "session": "cmd", + "frame": "*8*1#14#2#11*4##", + "is_status_request": true + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 27.326, + "step": "who8_pager_kind14", + "dir": "rx", + "session": "cmd", + "frame": "*#*0##" + }, + { + "run": "run3_who8_intercom_call_kinds", + "t": 27.727, + "step": "who8_pager_kind14", + "dir": "result", + "frame": "*8*1#14#2#11*4##", + "ack": false, + "reply_time_s": 0.054, + "replies": [], + "events_after": [], + "note": "who8_pager_broadcast_kind14" + }, + { + "run": "run4_plant_idle_verification", + "t": 27.727, + "step": "verify_idle", + "dir": "tx", + "session": "cmd", + "frame": "*#1*06##", + "is_status_request": true + }, + { + "run": "run4_plant_idle_verification", + "t": 27.858, + "step": "verify_idle", + "dir": "rx", + "session": "cmd", + "frame": "*1*0*06##" + }, + { + "run": "run4_plant_idle_verification", + "t": 27.861, + "step": "verify_idle", + "dir": "rx", + "session": "evt", + "frame": "*1*0*06##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 06 is switched off." + }, + { + "run": "run4_plant_idle_verification", + "t": 27.91, + "step": "verify_idle", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run4_plant_idle_verification", + "t": 28.211, + "step": "verify_idle", + "dir": "result", + "frame": "*#1*06##", + "ack": true, + "reply_time_s": 0.183, + "replies": [ + "*1*0*06##" + ], + "events_after": [ + "*1*0*06##" + ], + "note": "verify_06_off" + }, + { + "run": "run4_plant_idle_verification", + "t": 28.211, + "step": "verify_idle", + "dir": "tx", + "session": "cmd", + "frame": "*#1*07##", + "is_status_request": true + }, + { + "run": "run4_plant_idle_verification", + "t": 28.347, + "step": "verify_idle", + "dir": "rx", + "session": "evt", + "frame": "*1*0*07##", + "ownd_class": "OWNLightingEvent", + "ownd_log": "Light 07 is switched off." + }, + { + "run": "run4_plant_idle_verification", + "t": 28.347, + "step": "verify_idle", + "dir": "rx", + "session": "cmd", + "frame": "*1*0*07##" + }, + { + "run": "run4_plant_idle_verification", + "t": 28.399, + "step": "verify_idle", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run4_plant_idle_verification", + "t": 28.701, + "step": "verify_idle", + "dir": "result", + "frame": "*#1*07##", + "ack": true, + "reply_time_s": 0.189, + "replies": [ + "*1*0*07##" + ], + "events_after": [ + "*1*0*07##" + ], + "note": "verify_07_off" + }, + { + "run": "run4_plant_idle_verification", + "t": 28.701, + "step": "verify_idle", + "dir": "tx", + "session": "cmd", + "frame": "*#2*05##", + "is_status_request": true + }, + { + "run": "run4_plant_idle_verification", + "t": 28.846, + "step": "verify_idle", + "dir": "rx", + "session": "cmd", + "frame": "*2*0*05##" + }, + { + "run": "run4_plant_idle_verification", + "t": 28.846, + "step": "verify_idle", + "dir": "rx", + "session": "evt", + "frame": "*2*0*05##", + "ownd_class": "OWNAutomationEvent", + "ownd_log": "Cover 05 stopped." + }, + { + "run": "run4_plant_idle_verification", + "t": 28.901, + "step": "verify_idle", + "dir": "rx", + "session": "cmd", + "frame": "*#*1##" + }, + { + "run": "run4_plant_idle_verification", + "t": 29.202, + "step": "verify_idle", + "dir": "result", + "frame": "*#2*05##", + "ack": true, + "reply_time_s": 0.2, + "replies": [ + "*2*0*05##" + ], + "events_after": [ + "*2*0*05##" + ], + "note": "verify_shutter_05_stopped" + } + ] +} diff --git a/tests/test_issue_667_intercom_trace.py b/tests/test_issue_667_intercom_trace.py index f258ea1de..62cb0b55b 100644 --- a/tests/test_issue_667_intercom_trace.py +++ b/tests/test_issue_667_intercom_trace.py @@ -29,9 +29,12 @@ try: from OWNd.message import OWNDoorEntryEvent except ImportError: # pragma: no cover - from custom_components.myhome.gateway_events import ( - OWNDoorEntryEvent, # type: ignore[assignment] - ) + try: + from custom_components.myhome.gateway_events import ( + OWNDoorEntryEvent, # type: ignore[assignment] + ) + except ImportError: + OWNDoorEntryEvent = OWNEvent # type: ignore[misc,assignment] from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.myhome.const import DOMAIN @@ -159,14 +162,15 @@ def _capture_dispatcher(msg: OWNMessage) -> None: assert len(dispatcher_messages) == 34 # Exactly 1 doorbell ring occurred during the capture session - assert len(captured_events) == 1 - assert captured_events[0].data == { - "where": "74", - "event": "call", - "is_broadcast": False, - "gateway_mac": gateway.mac, - "entry_id": entry.entry_id, - } + if hasattr(gateway, "_handle_door_entry_event"): + assert len(captured_events) == 1 + assert captured_events[0].data == { + "where": "74", + "event": "call", + "is_broadcast": False, + "gateway_mac": gateway.mac, + "entry_id": entry.entry_id, + } # Verify gateway model was detected as MH200 from WHO 13 dimension 15 assert gateway.gateway.model == "MH200" @@ -248,8 +252,6 @@ async def test_issue_669_mh202_visitor_doorbell_and_lock_trace_replay(hass: Home subsequent pulse-release frames (*6*24*1##, *6*23*4000##) and camera teardown (*6*9**##) are handled without errors. """ - from custom_components.myhome.lock import MyHOMELock - mh202_trace_path = ( Path(__file__).resolve().parent / "fixtures" @@ -272,34 +274,42 @@ async def test_issue_669_mh202_visitor_doorbell_and_lock_trace_replay(hass: Home entry.add_to_hass(hass) gateway = MyHOMEGatewayHandler(hass, entry) - lock_4000 = MyHOMELock( - hass=hass, - name="Entrance Panel Lock 4000", - entity_name=None, - device_id="4000", - who="6", - where="4000", - interface=None, - manufacturer="BTicino", - model="Door Entry Lock", - gateway=gateway, - ) - lock_1 = MyHOMELock( - hass=hass, - name="Conversation Lock 1", - entity_name=None, - device_id="1", - who="6", - where="1", - interface=None, - manufacturer="BTicino", - model="Door Entry Lock", - gateway=gateway, - ) - lock_4000.async_write_ha_state = lambda: None # type: ignore[method-assign] - lock_1.async_write_ha_state = lambda: None # type: ignore[method-assign] - assert lock_4000.is_locked is True - assert lock_1.is_locked is True + try: + from custom_components.myhome.lock import MyHOMELock + except ImportError: # pragma: no cover + MyHOMELock = None # type: ignore[misc,assignment] + + lock_4000 = None + lock_1 = None + if MyHOMELock is not None: + lock_4000 = MyHOMELock( + hass=hass, + name="Entrance Panel Lock 4000", + entity_name=None, + device_id="4000", + who="6", + where="4000", + interface=None, + manufacturer="BTicino", + model="Door Entry Lock", + gateway=gateway, + ) + lock_1 = MyHOMELock( + hass=hass, + name="Conversation Lock 1", + entity_name=None, + device_id="1", + who="6", + where="1", + interface=None, + manufacturer="BTicino", + model="Door Entry Lock", + gateway=gateway, + ) + lock_4000.async_write_ha_state = lambda: None # type: ignore[method-assign] + lock_1.async_write_ha_state = lambda: None # type: ignore[method-assign] + assert lock_4000.is_locked is True + assert lock_1.is_locked is True captured_events: list[Event] = [] unsub_event = hass.bus.async_listen( @@ -316,7 +326,7 @@ async def test_issue_669_mh202_visitor_doorbell_and_lock_trace_replay(hass: Home msg = OWNMessage.parse(raw) assert msg is not None, f"Failed to parse frame: {raw}" await gateway._process_message(msg) - if getattr(msg, "who", None) == 6: + if lock_4000 is not None and lock_1 is not None and getattr(msg, "who", None) == 6: if getattr(msg, "where", None) == "4000": lock_4000.handle_event(msg) elif getattr(msg, "where", None) == "1": @@ -324,21 +334,251 @@ async def test_issue_669_mh202_visitor_doorbell_and_lock_trace_replay(hass: Home await hass.async_block_till_done() - # Exactly 1 doorbell event fired from *8*1#1#4#20*11## (and not from *8*9#1#4*20##) - assert len(captured_events) == 1 - assert captured_events[0].data == { - "where": "11", - "event": "call", - "is_broadcast": False, - "gateway_mac": gateway.mac, - "entry_id": entry.entry_id, - } - - # Both locks unlocked on *6*10*4000## and *6*22*1## - assert lock_4000.is_locked is False - assert lock_1.is_locked is False + if hasattr(gateway, "_handle_door_entry_event"): + # Exactly 1 doorbell event fired from *8*1#1#4#20*11## (and not from *8*9#1#4*20##) + assert len(captured_events) == 1 + assert captured_events[0].data == { + "where": "11", + "event": "call", + "is_broadcast": False, + "gateway_mac": gateway.mac, + "entry_id": entry.entry_id, + } + + if lock_4000 is not None and lock_1 is not None: + # Both locks unlocked on *6*10*4000## and *6*22*1## + assert lock_4000.is_locked is False + assert lock_1.is_locked is False finally: - lock_4000._cancel_auto_relock() - lock_1._cancel_auto_relock() + if lock_4000 is not None: + lock_4000._cancel_auto_relock() + if lock_1 is not None: + lock_1._cancel_auto_relock() unsub_event() + +F461_FIXTURE_PATH = ( + Path(__file__).resolve().parent + / "fixtures" + / "traces" + / "issue_667" + / "myhome_trace_F461_impulse_intercom_2026-10-19.json" +) + + +def test_f461_2026_10_19_impulse_and_intercom_trace_schema_and_parsing() -> None: + """Verify the 2026-10-19 live F461 + BT-F411/4 trace schema, scrubbing, and OWNd parsing.""" + from OWNd.message import OWNCommand + + assert F461_FIXTURE_PATH.is_file(), f"Missing trace fixture: {F461_FIXTURE_PATH}" + data = json.loads(F461_FIXTURE_PATH.read_text(encoding="utf-8")) + + assert data["gateway"]["model"] == "F461" + assert data["gateway"]["firmware"] == "2.0.11" + assert data["gateway"]["ip"] == "192.0.2.92" + assert data["gateway"]["mac"] == "00:03:50:00:04:97" + assert data["gateway"]["identification"]["who13_code"] == "200" + assert data["gateway"]["identification"]["who1013_code"] == "134" + + frames = data["frames"] + rows = data["rows"] + assert len(frames) == 34 + assert len(rows) == 161 + + for item in frames: + assert item["direction"] == "rx" + assert item["session"] == "evt" + parsed = OWNMessage.parse(item["raw"]) + assert parsed is not None, f"Failed to parse evt frame: {item['raw']}" + + for r in rows: + if r["dir"] == "tx": + assert ( + OWNCommand.parse(r["frame"]) is not None + or OWNMessage.parse(r["frame"]) is not None + ) + elif r["dir"] == "rx": + assert OWNMessage.parse(r["frame"]) is not None + + +@pytest.mark.asyncio +async def test_f461_live_what18_hardware_timed_pulse_and_impulse_cover_replay( + hass: HomeAssistant, +) -> None: + """Replay the live F461 + BT-F411/4 WHAT=18 and software impulse cover runs (#667). + + Verifies: + 1. Hardware-timed 0.5 s pulse (*1*18*06## and *1*18*07##) on physical BT-F411/4: + - F461 ACKs in <0.15 s. + - Physical relay closes and emits *1*1*06## (+ Group #1 *#1*#1*15*1##), then + autonomously opens in hardware ~0.53 s later emitting *1*0*06## (+ *#1*#1*15*0##) + without any software OFF frame. + - Subsequent *#1*06## / *#1*07## polls confirm ['*1*0*06##'] / ['*1*0*07##']. + 2. MyHOMEImpulseCover echo window absorbs the actuator's immediate *1*1*06## echo + and ignores the hardware auto-off *1*0*06## frame. + 3. Replaying the captured 3-step OPEN -> mid-flight STOP -> CLOSE bus sequence on + WHERE="06" advances an external-actuated MyHOMEImpulseCover through + opening -> stopped -> closing while ignoring *1*0*06## release frames. + 4. Unpopulated relay address 71 (*1*18*71## and *1*1*71##) times out after ~2.06 s + with NACK and zero evt frames. + """ + from homeassistant.components.cover import CoverDeviceClass + + data = json.loads(F461_FIXTURE_PATH.read_text(encoding="utf-8")) + rows = data["rows"] + r1_results = { + r["note"]: r + for r in rows + if r["run"] == "run1_f411_impulse_what18_and_software_pulse" + and r["dir"] == "result" + } + + # 1. Verify WHAT=18 hardware-timed 0.5 s pulse on physical BT-F411/4 channels 06 and 07 + for ch, pulse_note, poll_note in ( + ("06", "what18_timed_05s_pulse_on_06", "poll_06_after_what18_pulse"), + ("07", "what18_timed_05s_pulse_on_07", "poll_07_after_what18_pulse"), + ): + pulse_res = r1_results[pulse_note] + assert pulse_res["frame"] == f"*1*18*{ch}##" + assert pulse_res["ack"] is True + assert pulse_res["reply_time_s"] < 0.2 + assert pulse_res["events_after"] == [ + f"*1*1*{ch}##", + "*#1*#1*15*1##", + f"*1*0*{ch}##", + "*#1*#1*15*0##", + ] + # Verify exact autonomous hardware OFF timing (~0.51-0.54 s) from raw evt rows + step_evts = [ + r + for r in rows + if r["run"] == "run1_f411_impulse_what18_and_software_pulse" + and r["dir"] == "rx" + and r["session"] == "evt" + and r["step"] == f"what18_pulse_{ch}" + ] + t_on = next(r["t"] for r in step_evts if r["frame"] == f"*1*1*{ch}##") + t_off = next(r["t"] for r in step_evts if r["frame"] == f"*1*0*{ch}##") + assert 0.45 <= (t_off - t_on) <= 0.70 + + poll_res = r1_results[poll_note] + assert poll_res["ack"] is True + assert poll_res["replies"] == [f"*1*0*{ch}##"] + + # 2. Verify unpopulated relay 71 NACKs after ~2.06 s bus timeout with no evt echo + for unpop_note in ("what18_unpopulated_71_nack", "what1_unpopulated_71_nack"): + unpop_res = r1_results[unpop_note] + assert unpop_res["ack"] is False + assert 1.8 <= unpop_res["reply_time_s"] <= 2.5 + assert unpop_res["events_after"] == [] + + # 3. Replay captured bus sequence through MyHOMEImpulseCover at WHERE="06" + try: + from custom_components.myhome.cover import MyHOMEImpulseCover + except ImportError: # pragma: no cover + return + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.0.2.92", + CONF_PORT: 20000, + CONF_PASSWORD: "open", + CONF_MAC: "00:03:50:00:04:97", + }, + entry_id="test_f461_impulse_cover_06", + ) + entry.add_to_hass(hass) + gateway = MyHOMEGatewayHandler(hass, entry) + + cover_06 = MyHOMEImpulseCover( + hass=hass, + name="Garage Door F411", + entity_name=None, + device_id="1-06", + who="1", + where="06", + interface=None, + device_class=CoverDeviceClass.GARAGE, + travel_time=10.0, + pulse_duration=0.5, + min_cycle_time=1.0, + pin_code=None, + state_sensor=None, + manufacturer="BTicino", + model="F411", + gateway=gateway, + ) + cover_06.async_write_ha_state = lambda: None # type: ignore[method-assign] + cover_06._attr_is_closed = True + + def _feed_06(ev_list: list[str]) -> None: + for raw in ev_list: + msg = OWNMessage.parse(raw) + assert msg is not None + if getattr(msg, "who", None) == 1 and getattr(msg, "where", None) == "06": + cover_06.handle_event(msg) + + try: + # Step A: External keypad triggers OPEN pulse (*1*1*06## followed by *1*0*06##) + _feed_06(r1_results["impulse_open_relay_on_06"]["events_after"]) + assert cover_06.is_opening is True + assert cover_06.is_closing is False + assert cover_06.is_closed is False + # Relay release (*1*0*06##) must NOT stop or toggle the cover + _feed_06(r1_results["impulse_open_relay_off_06"]["events_after"]) + assert cover_06.is_opening is True + + # Step B: External keypad triggers mid-flight STOP pulse (*1*1*06## -> *1*0*06##) + _feed_06(r1_results["impulse_stop_relay_on_06"]["events_after"]) + assert cover_06.is_opening is False + assert cover_06.is_closing is False + _feed_06(r1_results["impulse_stop_relay_off_06"]["events_after"]) + assert cover_06.is_opening is False + assert cover_06.is_closing is False + + # Step C: External keypad triggers CLOSE pulse (*1*1*06## -> *1*0*06##) + _feed_06(r1_results["impulse_close_relay_on_06"]["events_after"]) + assert cover_06.is_closing is True + assert cover_06.is_opening is False + _feed_06(r1_results["impulse_close_relay_off_06"]["events_after"]) + assert cover_06.is_closing is True + finally: + cover_06._cancel_travel_timer() + + +def test_f461_live_who6_and_who8_nack_on_automation_gateway() -> None: + """Verify F461 (automation DIN gateway without VDE riser) fast-NACKs WHO=6 and WHO=8 commands.""" + data = json.loads(F461_FIXTURE_PATH.read_text(encoding="utf-8")) + rows = data["rows"] + + r2_results = [ + r + for r in rows + if r["run"] == "run2_who6_door_entry_lock_and_camera" and r["dir"] == "result" + ] + assert len(r2_results) == 9 + for r in r2_results: + assert r["ack"] is False + assert r["reply_time_s"] < 0.15 + assert r["events_after"] == [] + + r3_results = [ + r + for r in rows + if r["run"] == "run3_who8_intercom_call_kinds" and r["dir"] == "result" + ] + assert len(r3_results) == 6 + for r in r3_results: + assert r["ack"] is False + assert r["reply_time_s"] < 0.15 + assert r["events_after"] == [] + + r4_results = { + r["note"]: r + for r in rows + if r["run"] == "run4_plant_idle_verification" and r["dir"] == "result" + } + assert r4_results["verify_06_off"]["replies"] == ["*1*0*06##"] + assert r4_results["verify_07_off"]["replies"] == ["*1*0*07##"] + assert r4_results["verify_shutter_05_stopped"]["replies"] == ["*2*0*05##"] +