diff --git a/README.md b/README.md index 5fa24dd80..dbe241a23 100644 --- a/README.md +++ b/README.md @@ -122,7 +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 | +| **`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)) | | **`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..291a673a5 --- /dev/null +++ b/blueprints/automation/myhome/door_gate_motion_notification.yaml @@ -0,0 +1,209 @@ +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: + language: + name: "Notification Language" + description: "Language for push notifications (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 + 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: parallel +max: 10 +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 + 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." + 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." + 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." + 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." + 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." + dict: "{{ i18n[lang] if lang in i18n else i18n['en'] }}" + +action: + - choose: + # Opening + - conditions: + - condition: trigger + id: "opening" + - condition: template + value_template: "{{ notify_opening_enabled }}" + sequence: + - action: !input notify_service + data: + title: "{{ dict.opening_title }}" + message: "{{ dict.opening_msg }}" + 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: "{{ dict.closing_title }}" + message: "{{ dict.closing_msg }}" + 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: "{{ dict.closed_title }}" + message: "{{ dict.closed_msg }}" + 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: "{{ dict.left_open_title }}" + message: "{{ dict.left_open_msg }}" + data: + tag: "myhome_door_left_open_{{ cover_entity }}" diff --git a/blueprints/automation/myhome/doorbell_intercom_notification.yaml b/blueprints/automation/myhome/doorbell_intercom_notification.yaml new file mode 100644 index 000000000..99f0befdd --- /dev/null +++ b/blueprints/automation/myhome/doorbell_intercom_notification.yaml @@ -0,0 +1,162 @@ +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: "Pedestrian Door Lock to Release (Optional)" + description: >- + 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: 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)." + 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: "Custom Notification Title (Optional)" + description: "Leave empty to use the standard translated title." + default: "" + selector: + text: + custom_message: + name: "Custom Notification Message (Optional)" + description: "Leave empty to use the standard translated message." + default: "" + selector: + text: + +mode: restart +max_exceeded: silent + +trigger: + - platform: event + event_type: myhome_doorbell_event + +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 + custom_message_val: !input custom_message + 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: "πŸ”“ Release Lock" + dismiss_action: "❌ Dismiss" + it: + title: "πŸ”” Citofono: Qualcuno sta suonando!" + message: "Chiamata in arrivo dal posto esterno" + open_action: "πŸ”“ Apri Serratura" + dismiss_action: "❌ Ignora" + nl: + title: "πŸ”” Deurbel: Iemand belt aan!" + message: "Er wordt aangebeld bij de buitenpost" + open_action: "πŸ”“ Ontgrendel Deur" + dismiss_action: "❌ Negeren" + de: + title: "πŸ”” TΓΌrklingel: Jemand klingelt!" + message: "Eingehender Anruf von der TΓΌrstation" + open_action: "πŸ”“ TΓΌr ΓΆffnen" + dismiss_action: "❌ Ablehnen" + fr: + title: "πŸ”” Sonnette: Quelqu'un sonne!" + message: "Appel entrant depuis la platine de rue" + 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) }} + 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: + - action: !input notify_service + data: + title: "{{ resolved_title }}" + message: "{{ resolved_message }}" + 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, "image": "/api/camera_proxy/" ~ camera_target}) %} + {% endif %} + {{ payload }} + + - 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 + - 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 new file mode 100644 index 000000000..8e47eb4bb --- /dev/null +++ b/custom_components/myhome/access_policy.py @@ -0,0 +1,723 @@ +"""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]] = {} + self._expected_sensor_state: str | None = None + + # ── 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 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) + 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() + + 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.""" + if self.phase is Phase.PENDING and self._pending is not None: + pending = self._pending + self._clear_pending() + self._settle() + 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 + 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 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: + 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) + + 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 + 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") + if reply is None: + reply = event.data.get("textInput") + if reply is None: + reply = event.data.get("reply") + 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. + 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")) + return + self._failed_pins.pop(pending.user_id, None) + self._spawn(self._async_execute(pending)) + + def _register_failed_pin(self, user_id: str) -> None: + 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, now + 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: asyncio.Task[Any] = 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: + 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 + 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 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 + 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.""" + 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 + self._expected_sensor_state = expected + 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 + self._expected_sensor_state = 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: + 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/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..974172e6c 100644 --- a/custom_components/myhome/cover.py +++ b/custom_components/myhome/cover.py @@ -2,7 +2,9 @@ from __future__ import annotations import asyncio +import inspect import time +from collections.abc import Callable, Mapping from datetime import timedelta from typing import TYPE_CHECKING, Any @@ -21,22 +23,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 +61,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 +88,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 +121,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 @@ -105,6 +136,11 @@ 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 +OFF_SETTLE_DELAY = 0.2 + 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.""" @@ -116,14 +152,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) -> 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" + 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" or ctx.cfg: + return str(ctx.cfg.get(CONF_WHO, "2")) == "2" + return True + + def accept_impulse(ctx: DeviceContext) -> bool: + 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 kwargs: dict[str, Any] = {} @@ -151,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)), @@ -161,6 +219,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) or "BTicino", + model=cfg.get(CONF_DEVICE_MODEL) or "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 +264,17 @@ 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, + ).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 +298,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 @@ -2059,6 +2161,455 @@ 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. + + 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``. + """ + + _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_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 = { + "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 + + # 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 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() + 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: + 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 + 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() + 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 with confirmed delivery to the bus.""" + now = time.monotonic() + 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 + + 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: + # 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, + # 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 + 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 + 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: + self.hass.async_create_task(self._async_send_off()) + + if self.hass is not None: + self._pulse_off_cancel = async_call_later( + self.hass, self._pulse_duration, _turn_off + ) + return True + except Exception as err: + LOGGER.error( + "%s: Failed to write impulse command to gateway: %s", + self._display_name, + err, + ) + return False + 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. + + 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_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 (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 + 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: + """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: + """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: + """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) + + 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. + + 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 + 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) + + # 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 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_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 + 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) + 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 + if self._off_pending: + await self._async_send_off() + self._cancel_travel_timer() + if self._access is not None: + await self._access.async_shutdown() + await super().async_will_remove_from_hass() + def __getattr__(name: str) -> Any: if name in ("CoverFamily", "CoverScope", "MyHOMEScopeCover"): @@ -2081,6 +2632,7 @@ def __dir__() -> list[str]: "MOTOR_START_DELAY", "MOTOR_STOP_DELAY", "MyHOMECover", + "MyHOMEImpulseCover", "MyHOMEScopeCover", "WRITE_TIMEOUT", "_calibration_lock", 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/gateway_events.py b/custom_components/myhome/gateway_events.py index 46ac7deb4..036497508 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -34,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, @@ -51,6 +58,9 @@ class OWNScenarioPlusEvent(OWNMessage): # type: ignore[misc,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.""" @@ -184,6 +194,26 @@ 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: + 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 + 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): @@ -570,6 +600,78 @@ 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_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 + # 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, + # 18, 22) and no bus capture of it exists in this repository: unverified. + is_chime = getattr(message, "is_chime", who == 6 and what == 20) + 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) + + 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 target_mac is not None and is_call and self._is_active_for_who(who): + event_name = ( + "broadcast_call" + if is_broadcast + else "chime" + if is_chime + else "call" + ) + doorbell_payload = { + "where": str(where_val), + "event": event_name, + "is_broadcast": is_broadcast, + "gateway_mac": target_mac, + } + 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_{target_mac}", doorbell_payload) + self._logger.debug( + "%s %s", + self.handler.log_id, + getattr(message, "human_readable_log", str(message)), + ) 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..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(): @@ -255,6 +259,12 @@ 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._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": @@ -271,6 +281,8 @@ def __init__( who, device_id = parse_unique_id(entry.unique_id or "", gateway_mac, entry_mac) 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] @@ -284,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)}) @@ -291,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 - candidates |= {normalize_where(address.where), normalize_where(address.clean_where)} + 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 = { + 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 new file mode 100644 index 000000000..02467fce8 --- /dev/null +++ b/custom_components/myhome/lock.py @@ -0,0 +1,261 @@ +"""Support for MyHome locks (door entry electric strikes, WHO=6).""" +from __future__ import annotations + +import hmac +import inspect +from typing import Any + +from homeassistant.components.lock import ( # type: ignore[attr-defined, unused-ignore] + 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 OWNCommand + +try: + 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.""" + + @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) + +from .const import ( + CONF_DEVICE_MODEL, + CONF_ENTITY_NAME, + CONF_MANUFACTURER, + CONF_PULSE_DURATION, + CONF_WHO, + LOGGER, +) +from .data import get_runtime_data +from .discovery import ( + Address, + DeviceContext, + PlatformDiscovery, + 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.""" + 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 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(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) 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( + 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(ctx: DeviceContext) -> bool: + if ctx.source == "bus" or not ctx.cfg or ctx.address.where in ("4100", "0", ""): + return False + 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 + # with dashboard access can unlock. + PlatformDiscovery( + hass, + config_entry, + async_add_entities, + platform=PLATFORM, + who="6", + event_type=None, + build=build, + announce=True, + registry_address=registry_address, + accept=accept, + ).start(listen=True) + + 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).""" + + _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._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}") + + 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() + 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 + 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 what 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: + 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() + 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/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/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..e3de7c839 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, @@ -36,6 +43,7 @@ Coerce, In, Invalid, + Match, Optional, Range, Required, @@ -48,9 +56,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 +82,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 +107,7 @@ DEFAULT_SLAT_TIME, MAX_SLAT_TIME, MIN_SLAT_TIME, + TYPE_IMPULSE_RELAY, ) @@ -399,16 +425,88 @@ def _validate_light_members(data: dict[str, typing.Any]) -> dict[str, typing.Any } ) +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] + 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(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)), + 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 +541,34 @@ 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[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" + 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])) + 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 + 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: + 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 +692,61 @@ def _validate_cover_members(data: dict[str, typing.Any]) -> dict[str, typing.Any } ) +class DoorEntryWhere: + """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): + 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 + + +lock_schema = MyHomeDeviceSchema( + { + Required(str): { + # 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): 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, + Optional(CONF_DEVICE_MODEL, default="Door Entry Lock"): Coerce(str), + } + } +) + + +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, "")) + 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 + + +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 @@ -583,6 +763,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/covers.md b/docs/configuration/covers.md index 1e0d4e2b4..fb4f3fd2a 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -285,7 +285,245 @@ 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. + +**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. +> 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:`) + +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 ] + β”‚ + β”‚ 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 Optional Human-in-the-Loop Policy (with `access:`): + +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**: + 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 (`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`. 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` 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 +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; needs access: + 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 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 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`. | + +> [!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" # 1-99 or 4000-4099 endpoints; append #2 on a riser installation: "4001#2" + name: Front door + 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 (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 + +```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 β”‚ + β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ + β”‚ - 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 β”‚ + β”‚ - 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. 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. | + +--- + ## πŸ”„ 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/services.md b/docs/configuration/services.md index 566621add..8b249db13 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. 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 | +| :--- | :---: | :---: | :--- | :--- | +| `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. 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 | +| :--- | :---: | :---: | :--- | :--- | +| `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..809332ebc 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. | @@ -45,16 +46,24 @@ 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: 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. | | Echo suppression | βœ… | The gateway's relay of our own command is not mistaken for a keypad press. | +### `lock` (WHO 6) + +| Function | Status | Notes | +| :--- | :---: | :--- | +| 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) | Function | Status | Notes | 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: diff --git a/scripts/update_supported_domains.py b/scripts/update_supported_domains.py index 76934eab5..6725e8e03 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", @@ -62,7 +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 sensor feedback and an optional multi-factor approval workflow" + ), + }, + "lock": { + "label": "**`lock`**", + "capabilities": ( + "Native video door entry strikes (`WHO=6`, configured in `myhome.yaml`) with constant-time PIN " + "authentication and auto-relock" ), }, "climate": { 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/fixtures/traces/issue_667/README.md b/tests/fixtures/traces/issue_667/README.md new file mode 100644 index 000000000..97487037b --- /dev/null +++ b/tests/fixtures/traces/issue_667/README.md @@ -0,0 +1,50 @@ +# #667 MH200 & F461 Video Door Entry, Intercom & Impulse Gate/Garage Traces + +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 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 | Gateway & Hardware | Description | +|---|---|---| +| `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)**: + - `*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/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_access_policy.py b/tests/test_access_policy.py new file mode 100644 index 000000000..e45aa193e --- /dev/null +++ b/tests/test_access_policy.py @@ -0,0 +1,868 @@ +"""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, + PendingRequest, + 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() + + +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") + 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 ──────────────────────────────────────────────────────── + + +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)) + + +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) + + +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() + + + +# ── 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 + + +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 new file mode 100644 index 000000000..964aaf977 --- /dev/null +++ b/tests/test_component_impulse_cover.py @@ -0,0 +1,1015 @@ +"""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 + +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.exceptions import HomeAssistantError +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_fire_time_changed_exact, + async_mock_service, +) + +from custom_components.myhome.access_policy import ( + AccessConfig, + AccessDenied, + Effect, + Intent, + RemoteClose, +) +from custom_components.myhome.const import ( + CONF_ACCESS, + CONF_ALLOWED_USERS, + CONF_APPROVERS, + 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(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##" + + +@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 (rejecting removed stale registry entries per H-5).""" + mac = mock_gateway.mac + hass.data = { + DOMAIN: { + mac: { + "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", + 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", + CONF_ACCESS: {CONF_ALLOWED_USERS: ["u1"], CONF_APPROVERS: {"u1": "notify.mobile_app_phone"}}, + }, + } + }, + } + } + } + + 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" + 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, reg_removed]), + ): + 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); 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 + 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, + access_config={}, + ) + 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 + + # 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) == PULSE_FRAME + assert impulse_gate.is_opening is True + assert impulse_gate.is_closed is False + + # 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() + + assert impulse_gate.is_opening is False + assert impulse_gate.is_closed is False + + # 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) == PULSE_FRAME + assert impulse_gate.is_closing is True + + # 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 + + # Reset mock for sensor updates + mock_gateway.send.reset_mock() + + # 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 True + + # 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, 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 + + # 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 + 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): + """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._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 + 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 + + +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() + + + + + + +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 + + +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.""" + 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(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 _pulses(mock_gateway) == 2 + assert cover.is_opening is False + + await cover.async_close_cover() + assert _pulses(mock_gateway) == 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 _pulses(mock_gateway) == 0 + + await cover.async_open_cover() + assert _pulses(mock_gateway) == 1 + + cover._handle_sensor_update(STATE_ON) # open + await cover.async_open_cover() + assert _pulses(mock_gateway) == 1 + await cover.async_stop_cover() + assert _pulses(mock_gateway) == 2 + await cover.async_close_cover() + assert _pulses(mock_gateway) == 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 + + +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_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 new file mode 100644 index 000000000..f79d0f6fb --- /dev/null +++ b/tests/test_component_lock.py @@ -0,0 +1,379 @@ +"""Tests for MyHOME lock platform (WHO=6 door entry electric strikes).""" +import asyncio +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 ( + OWNCommand, + OWNEvent, +) + +try: + from OWNd.message import ( + OWNDoorEntryCommand, + OWNDoorEntryEvent, + ) +except ImportError: # pragma: no cover + 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, +) + +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_ignores_bus_traffic(hass: HomeAssistant, mock_gateway): + """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: { + mac: { + "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", + }, + "0": { + CONF_WHERE: "0", + CONF_NAME: "General 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" + 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_removed, reg_light]), + ): + 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; removed (9) and general (0) are rejected + assert len(added_entities) == 2 + 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. + 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) + await hass.async_block_till_done() + assert len(added_entities) == 2 + assert not any(e._where == "3" for e in added_entities) + + # 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) + 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, OWNCommand)) + 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, 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 + + # 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_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##") + 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 + + 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_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="Front Door Lock", + entity_name=None, + device_id="4001#2", + who="6", + where="4001#2", + interface=None, + manufacturer="BTicino", + model="Door Entry Lock", + gateway=mock_gateway, + code="1234", + ) + lock.hass = hass + lock.entity_id = "lock.front_door_lock" + lock.async_write_ha_state = MagicMock() + + assert lock.code_format == r"^\d+$" + for bad in ({}, {"code": "9999"}): + with pytest.raises(ServiceValidationError, match="Invalid code"): + await lock.async_unlock(**bad) + mock_gateway.send.assert_not_called() + + await lock.async_unlock(code="1234") + assert str(mock_gateway.send.call_args[0][0]) == "*6*10*4001#2##" + assert lock.is_locked is False + lock._cancel_auto_relock() + + +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 = 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() + + 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() + + +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 new file mode 100644 index 000000000..88b9beb80 --- /dev/null +++ b/tests/test_door_entry_events.py @@ -0,0 +1,255 @@ +"""Tests for Door Entry (WHO=6) doorbell events and gateway handling.""" +from unittest.mock import MagicMock, PropertyMock, patch + +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +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 +from custom_components.myhome.gateway_events import GatewayEventDispatcher + + +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 = [] + + @callback + 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, OWNEvent)) + 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, OWNEvent)) + 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, OWNEvent)) + 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, OWNEvent)) + await gateway._process_message(lock_event) + + # 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 + + # 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 + 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, + ) + + +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") + + # 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##"): + 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"} + + +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 + + +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##") + # 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 == [] + + +async def test_doorbell_fires_once_on_a_shared_bus(hass: HomeAssistant): + """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] = [] + + @callback + def record(payload): + dispatched.append(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(spec=OWNDoorEntryEvent, 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##")) + 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 == [] + + # 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) == 2 + assert len(dispatched) == 1 + 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..62cb0b55b --- /dev/null +++ b/tests/test_issue_667_intercom_trace.py @@ -0,0 +1,584 @@ +"""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 ( + OWNEvent, + OWNLightingEvent, + OWNMessage, + OWNSoundEvent, +) + +try: + from OWNd.message import OWNDoorEntryEvent +except ImportError: # pragma: no cover + 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 +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, 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) + 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 + 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" + 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 + + +@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. + """ + 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) + + 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( + "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 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": + lock_1.handle_event(msg) + + await hass.async_block_till_done() + + 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: + 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##"] + 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_supported_domains.py b/tests/test_supported_domains.py index 91af065f7..9ffd42686 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(): @@ -44,7 +45,8 @@ 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=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 f598f36e3..b37d4dc16 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, @@ -45,6 +46,7 @@ cover_schema, format_mac, light_schema, + lock_schema, sensor_schema, switch_schema, ) @@ -784,3 +786,131 @@ def test_device_class_remapping_and_defaults(self): "slat_time": invalid_time, } }) + + +class TestLockSchemaValidation: + """Test validation and restrictions on lock_schema.""" + + @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_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_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", "99", "4099"]) + def test_who6_riser_and_panel_addresses_validate(self, where): + """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#", "100", "500", + "3999", "4101", "9999", "01", "0001", "0000", "0#2", "4100#2", + ], + ) + def test_who6_malformed_where_rejected(self, where): + 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 = { + "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_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_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"}}) + + 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: "6", + CONF_WHERE: "12", + CONF_NAME: "Negative Pulse Lock", + "pulse_duration": -3.0, + } + }) + with pytest.raises(Invalid): + lock_schema({ + "lock1": { + 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"}) + + 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"}) + + @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}) + +