From 5464597101d5ea864c0fd6f250f14eb36cad22c8 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 16:24:00 +0300 Subject: [PATCH 1/6] fix(alarm): start unknown, restore the last state and skip *#5*0## where the firmware refuses it The panel claimed `disarmed` from creation and skipped the RestoreEntity path, so an armed system showed disarmed after every restart. No issue reports it; the motivation is the live F461 observation of the panel's default state. Start with alarm_state None, restore the last valid state flagged `restored: true` until a frame that names a state clears it, and do not poll `*#5*0##` on gateways whose firmware NACKs it (own-firmware-oracle trace-corpus rows). The gate reads an OWNd profile flag first and falls back to a model table; the flag belongs in OWNd. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 1 + .../myhome/alarm_control_panel.py | 64 ++++++++-- docs/configuration/alarm.md | 2 + tests/test_alarm_restore_unknown.py | 120 ++++++++++++++++++ tests/test_component_alarm.py | 10 +- 5 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 tests/test_alarm_restore_unknown.py diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 196af1a42..13d85da98 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -20,6 +20,7 @@ See the cover docs for the details and the known limits. - **`myhome.send_message` and `myhome.sync_time` reject unknown keys.** A misspelt key such as `gw_mac` or a missing `message` raises a validation error instead of sending the frame to the default gateway; both services get text selectors in the visual editor (#189). - **Climate zones show the setpoint in force (dimension 12).** When the thermostat regulates to another setpoint than the commanded one (dimension 14; 23.5 in force while 21.0 commanded on an F461), the entity and `local_target_temperature` now show the one in force instead of the commanded value plus the knob offset. Known limit: once a zone has reported dimension 12, a dimension 14 that is not the echo of a Home Assistant write does not move the slider until the next dimension 12 report or poll (see Known Limitations). +- **The burglar alarm panel starts `unknown` instead of `disarmed`.** It restores its last state after a restart (attribute `restored: true` until the central unit confirms it) and no longer polls `*#5*0##` on gateways whose firmware NACKs it (F450, F453AV, F454, F459, F460, F461, MyHomeServer1) (#177). - **`myhome.sync_time` refuses time zones with a half-hour offset** (e.g. `Asia/Kolkata`, UTC+5:30) with a translated error. The WHO 13 time-zone field carries whole hours only; b15 (on OWNd 2.0.0b10) set the gateway clock with a truncated offset without saying so (#764). - **`advanced: true` on a standard F411 shutter now logs a startup warning.** Those actuators have no position reporting, so the position stayed at 50 % and calibration was refused; the error now says to remove `advanced: true` (#761, #762). diff --git a/custom_components/myhome/alarm_control_panel.py b/custom_components/myhome/alarm_control_panel.py index a137f955c..07c7fa0e2 100644 --- a/custom_components/myhome/alarm_control_panel.py +++ b/custom_components/myhome/alarm_control_panel.py @@ -12,7 +12,7 @@ CONF_NAME, Platform, ) -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, State, callback from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -41,6 +41,12 @@ STATE_TRIGGERED = AlarmControlPanelState.TRIGGERED CENTRAL_UNIT_KEY = "0" +# Gateways that NACK the WHO 5 status request ``*#5*0##`` (own-firmware-oracle +# ``results///oracle/full/trace-corpus.tsv``: F450 020010, F453AV 030014, +# F454 020051, F459 020105, F460 020012, F461 020011, MyHomeServer1 028206 answer +# ``nack`` / ``silent``). Fallback for OWNd profiles that carry no +# ``supports_alarm_status_request`` flag yet; the flag belongs in OWNd (#381 pattern). +ALARM_STATUS_REFUSED_MODELS = frozenset({"F450", "F453AV", "F454", "F459", "F460", "F461", "MYHOMESERVER1"}) async def async_setup_entry( @@ -159,10 +165,11 @@ def __init__( self._attr_supported_features = AlarmControlPanelEntityFeature(0) # The central unit takes no code over the bus. self._attr_code_arm_required = False - self._attr_alarm_state = STATE_DISARMED + # Unknown until a bus frame or the restored state says otherwise (#177). + self._attr_alarm_state: AlarmControlPanelState | None = None self._attr_extra_state_attributes = { "where": self._where, - "raw_state": "disarmed", + "raw_state": None, } @property @@ -170,13 +177,43 @@ def alarm_state(self) -> AlarmControlPanelState | None: """Return the state of the device.""" return self._attr_alarm_state - async def async_added_to_hass(self) -> None: - """Register dispatcher listener when added to hass.""" - self._register_availability_listener() - await self.async_update() + async def async_restore_last_state(self, last_state: State) -> None: + """Restore the last known state, flagged as restored until a bus frame confirms it.""" + try: + state = AlarmControlPanelState(last_state.state) + except ValueError: + return + self._attr_alarm_state = state + attrs = self._attr_extra_state_attributes + attrs["restored"] = True + attrs["raw_state"] = last_state.attributes.get("raw_state") + if "state_code" in last_state.attributes: + attrs["state_code"] = last_state.attributes["state_code"] + + @property + def _status_request_refused(self) -> bool: + """Whether this gateway NACKs ``*#5*0##``: the OWNd profile flag first, then the model table. + + ``None`` on the profile means unverified and falls through to the table; a gateway + in neither is polled as before. + """ + profile = getattr(self._gateway_handler, "profile", None) + flag = getattr(profile, "supports_alarm_status_request", None) + if isinstance(flag, bool): + return not flag + model = getattr(self._gateway_handler, "model", None) + return isinstance(model, str) and model.strip().upper().replace(" ", "") in ALARM_STATUS_REFUSED_MODELS async def async_update(self) -> None: - """Request status from the gateway.""" + """Request status from the gateway, unless the gateway refuses the request.""" + if self._status_request_refused: + LOGGER.debug( + "%s %s: %s refuses *#5*0##; waiting for alarm frames on the bus", + self._gateway_handler.log_id, + self._display_name, + self._gateway_handler.model, + ) + return await self._gateway_handler.send_status_request(OWNAlarmCommand.status(self._where)) async def async_alarm_disarm(self, code: str | None = None) -> None: # pylint: disable=unused-argument @@ -196,12 +233,17 @@ def handle_event(self, message: OWNAlarmEvent) -> None: self._gateway_handler.log_id, message.human_readable_log, ) + live_state: AlarmControlPanelState | None = None if message.is_alarm: - self._attr_alarm_state = STATE_TRIGGERED + live_state = STATE_TRIGGERED elif message.is_armed_away: - self._attr_alarm_state = STATE_ARMED_AWAY + live_state = STATE_ARMED_AWAY elif message.is_disarmed: - self._attr_alarm_state = STATE_DISARMED + live_state = STATE_DISARMED + if live_state is not None: + self._attr_alarm_state = live_state + # Only a frame that sets the state confirms it; activation or zone frames do not. + self._attr_extra_state_attributes.pop("restored", None) self._attr_extra_state_attributes["raw_state"] = message.state_name self._attr_extra_state_attributes["state_code"] = message.state_code diff --git a/docs/configuration/alarm.md b/docs/configuration/alarm.md index 2d9773702..5b5fd1afd 100644 --- a/docs/configuration/alarm.md +++ b/docs/configuration/alarm.md @@ -43,6 +43,8 @@ The panel follows what the central unit reports on the bus: * `*5*1*##` (*activation*) is **not** an armed state. The central unit sends it while disarming (`*5*2*0##` → `*5*1*0##` → `*5*9*0##`) and in the status of a disarmed system, as well as while arming (`*5*1*0##` → `*5*8*0##`). * **`armed_home`** is never reported: home and away arming look the same on the bus. * Zones (`*5*11*#n##` active, `*5*18*#n##` not active) never change the panel's state. +* A new panel is **unknown** until the central unit reports a state. After a restart it shows its last known state with the attribute `restored: true` until the next frame confirms it. +* F450, F453AV, F454, F459, F460, F461 and MyHomeServer1 refuse the status request `*#5*0##`, so the panel is not polled there and waits for the central unit's own frames. --- diff --git a/tests/test_alarm_restore_unknown.py b/tests/test_alarm_restore_unknown.py new file mode 100644 index 000000000..2ad7dd93e --- /dev/null +++ b/tests/test_alarm_restore_unknown.py @@ -0,0 +1,120 @@ +"""The alarm panel is unknown until the bus says otherwise, and restores its last state (#177).""" +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.components.alarm_control_panel import AlarmControlPanelState +from homeassistant.core import HomeAssistant, State +from OWNd.message import OWNEvent + +from custom_components.myhome.alarm_control_panel import MyHOMEAlarmControlPanel + + +def _gateway(model: str) -> MagicMock: + gw = MagicMock() + gw.mac = "00:03:50:00:55:55" + gw.unique_id = gw.mac + gw.log_id = "[Test Alarm Gateway]" + gw.model = model + gw.profile = None + gw.availability_signal = "myhome_avail_alarm" + gw.sweep_active = False + gw.initial_discovery_pending = False + gw.send = AsyncMock() + gw.send_status_request = AsyncMock() + return gw + + +def _panel(hass: HomeAssistant, gateway: MagicMock) -> MyHOMEAlarmControlPanel: + panel = MyHOMEAlarmControlPanel( + hass=hass, + name="Central Burglar Alarm", + entity_name="Central Burglar Alarm", + device_id="0", + who="5", + where="0", + manufacturer="BTicino", + model="Burglar Alarm 3486", + gateway=gateway, + ) + panel.async_write_ha_state = MagicMock() + panel.async_schedule_update_ha_state = MagicMock() + return panel + + +async def test_new_panel_is_unknown_not_disarmed(hass: HomeAssistant) -> None: + """With no bus frame and nothing to restore the panel does not claim `disarmed`.""" + panel = _panel(hass, _gateway("MH202")) + panel.async_get_last_state = AsyncMock(return_value=None) + + await panel.async_added_to_hass() + + assert panel.alarm_state is None + assert panel.extra_state_attributes.get("raw_state") is None + # MH202 answers *#5*0## (oracle: ack): it is polled + panel._gateway_handler.send_status_request.assert_awaited_once() + + +async def test_last_state_is_restored_and_flagged(hass: HomeAssistant) -> None: + """A restart restores the last known state, flagged as restored until the bus confirms it.""" + panel = _panel(hass, _gateway("MH202")) + panel.async_get_last_state = AsyncMock( + return_value=State( + "alarm_control_panel.central", + AlarmControlPanelState.ARMED_AWAY, + {"raw_state": "engage", "state_code": 8}, + ) + ) + + await panel.async_added_to_hass() + + assert panel.alarm_state == AlarmControlPanelState.ARMED_AWAY + assert panel.extra_state_attributes["restored"] is True + assert panel.extra_state_attributes["raw_state"] == "engage" + + # Activation names no state: the restored state stays flagged as restored + panel.handle_event(OWNEvent.parse("*5*1*0##")) + assert panel.extra_state_attributes["restored"] is True + + panel.handle_event(OWNEvent.parse("*5*2*0##")) + assert panel.alarm_state == AlarmControlPanelState.DISARMED + assert "restored" not in panel.extra_state_attributes + + +async def test_unavailable_last_state_is_not_restored(hass: HomeAssistant) -> None: + panel = _panel(hass, _gateway("MH202")) + panel.async_get_last_state = AsyncMock(return_value=State("alarm_control_panel.central", "unavailable")) + + await panel.async_added_to_hass() + + assert panel.alarm_state is None + assert "restored" not in panel.extra_state_attributes + + +@pytest.mark.parametrize("model", ["F454", "F459", "F460", "F461", "MyHomeServer1", "F450", "F453AV"]) +async def test_no_status_poll_where_the_gateway_refuses_it(hass: HomeAssistant, model: str) -> None: + """`*#5*0##` is NACKed by these gateways (own-firmware-oracle): no poll, state stays restored.""" + panel = _panel(hass, _gateway(model)) + panel.async_get_last_state = AsyncMock(return_value=None) + + await panel.async_added_to_hass() + await panel.async_update() + + panel._gateway_handler.send_status_request.assert_not_awaited() + assert panel.alarm_state is None + + +@pytest.mark.parametrize( + ("model", "flag", "polled"), + [("MH202", False, False), ("F461", True, True), ("F461", None, False), ("MH202", None, True)], +) +async def test_ownd_profile_flag_decides_before_the_model_table(hass: HomeAssistant, model, flag, polled) -> None: + """A `supports_alarm_status_request` flag on the OWNd profile wins; `None` (unverified) falls back to the table.""" + gateway = _gateway(model) + gateway.profile = MagicMock() + gateway.profile.supports_alarm_status_request = flag + panel = _panel(hass, gateway) + panel.async_get_last_state = AsyncMock(return_value=None) + + await panel.async_added_to_hass() + + assert panel._gateway_handler.send_status_request.await_count == (1 if polled else 0) diff --git a/tests/test_component_alarm.py b/tests/test_component_alarm.py index 042d4df3b..02d6f4dd2 100644 --- a/tests/test_component_alarm.py +++ b/tests/test_component_alarm.py @@ -119,7 +119,8 @@ def fake_add_entities(entities): assert isinstance(msg_empty_activation, OWNAlarmEvent) async_dispatcher_send(hass, f"myhome_message_{mac}", msg_empty_activation) assert len(added_entities) == 3 - assert central_alarm.alarm_state == (STATE_ARMED_AWAY if msg_empty_activation.is_armed_away else STATE_DISARMED) + # Activation is neither armed nor disarmed: a fresh panel stays unknown (#177) + assert central_alarm.alarm_state == (STATE_ARMED_AWAY if msg_empty_activation.is_armed_away else None) assert central_alarm.extra_state_attributes["raw_state"] == "activation" assert central_alarm.extra_state_attributes["state_code"] == 1 assert zone1_alarm.extra_state_attributes["raw_state"] == "activation" @@ -416,10 +417,11 @@ def alarm_zone1(self, hass, mock_gateway): def test_alarm_attributes(self, alarm_central, alarm_zone1): # Read-only: the central unit rejects SCS arm/disarm (#564) assert alarm_central.supported_features == AlarmControlPanelEntityFeature(0) - assert alarm_central.alarm_state == STATE_DISARMED - assert alarm_central.state == STATE_DISARMED + # Unknown until the bus or the restored state says otherwise (#177) + assert alarm_central.alarm_state is None + assert alarm_central.state is None assert alarm_central.extra_state_attributes["where"] == "0" - assert alarm_central.extra_state_attributes["raw_state"] == "disarmed" + assert alarm_central.extra_state_attributes["raw_state"] is None assert alarm_zone1._display_name == "Zone 1 Burglar Alarm" async def test_async_lifecycle_and_update(self, alarm_central, alarm_zone1): From ffd629095dd447947da947eb208109d79df70943 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:41:51 +0300 Subject: [PATCH 2/6] fix(alarm): poll *#5*0## unless the OWNd profile says the gateway refuses it The model table ALARM_STATUS_REFUSED_MODELS turned emulator rows into a model-capability fact, and the physical traces committed in tests/fixtures disprove it: the #311 F454 (firmware 2.0) answers `*#5*#1##` with `*5*11*#1##` and reports `*5*1*##`, `*5*5*##`, `*5*7*##`, `*5*9*##`; the #629 F454 (1.0.34) and #619 F453AV (3.0.14) sweeps answer `*#5*0##` with `*5*0*##` / `*5*1*##`, `*5*9*##` and the zone frames. The own-firmware-oracle `nack` / `silent` rows (F450, F453AV, F454, F459, F460, F461, MyHomeServer1) are factory-blank emulations (tools/expected_divergences.yaml lists the F454 case under silent_bus_vs_populated_trace), and the live F461 2.0.11 and MyHomeServer1 3.87.13 NACKs (#799) were taken on plants without a central unit: they prove those plants NACKed, not that every plant with that gateway cannot answer. Silence is never converted into refusal. The table is removed. Only an explicit `supports_alarm_status_request = False` on the OWNd profile skips the poll; `None` or no flag polls. The flag belongs in OWNd (#381 pattern). A per-plant cache of a live NACK is not implemented here: the command session does not report a NACK back to the entity (the future resolves to the write time), so it would need gateway-session plumbing first. Tests: every formerly listed model with no flag is polled, explicit False skips, True / None poll; the three physical F454 / F453AV fixtures prove the request is answered and that `*5*9*##` confirms the restored state; the two live NACK traces prove nothing reaches the panel and the restored state is left alone. docs/configuration/alarm.md no longer contradicts its own point 1, and the release note describes the policy. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 2 +- .../myhome/alarm_control_panel.py | 28 ++--- docs/configuration/alarm.md | 2 +- tests/test_alarm_restore_unknown.py | 118 ++++++++++++++++-- 4 files changed, 122 insertions(+), 28 deletions(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 13d85da98..8f878b8d8 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -20,7 +20,7 @@ See the cover docs for the details and the known limits. - **`myhome.send_message` and `myhome.sync_time` reject unknown keys.** A misspelt key such as `gw_mac` or a missing `message` raises a validation error instead of sending the frame to the default gateway; both services get text selectors in the visual editor (#189). - **Climate zones show the setpoint in force (dimension 12).** When the thermostat regulates to another setpoint than the commanded one (dimension 14; 23.5 in force while 21.0 commanded on an F461), the entity and `local_target_temperature` now show the one in force instead of the commanded value plus the knob offset. Known limit: once a zone has reported dimension 12, a dimension 14 that is not the echo of a Home Assistant write does not move the slider until the next dimension 12 report or poll (see Known Limitations). -- **The burglar alarm panel starts `unknown` instead of `disarmed`.** It restores its last state after a restart (attribute `restored: true` until the central unit confirms it) and no longer polls `*#5*0##` on gateways whose firmware NACKs it (F450, F453AV, F454, F459, F460, F461, MyHomeServer1) (#177). +- **The burglar alarm panel starts `unknown` instead of `disarmed`.** It restores its last state after a restart (attribute `restored: true` until the central unit confirms it) and skips the `*#5*0##` poll only when the OWNd gateway profile states that the gateway refuses it; the gateway model alone never decides, because the physical F454 and F453AV answer the request that their factory-blank emulations NACK (#311, #619, #629). - **`myhome.sync_time` refuses time zones with a half-hour offset** (e.g. `Asia/Kolkata`, UTC+5:30) with a translated error. The WHO 13 time-zone field carries whole hours only; b15 (on OWNd 2.0.0b10) set the gateway clock with a truncated offset without saying so (#764). - **`advanced: true` on a standard F411 shutter now logs a startup warning.** Those actuators have no position reporting, so the position stayed at 50 % and calibration was refused; the error now says to remove `advanced: true` (#761, #762). diff --git a/custom_components/myhome/alarm_control_panel.py b/custom_components/myhome/alarm_control_panel.py index 07c7fa0e2..5dd1f4121 100644 --- a/custom_components/myhome/alarm_control_panel.py +++ b/custom_components/myhome/alarm_control_panel.py @@ -41,12 +41,6 @@ STATE_TRIGGERED = AlarmControlPanelState.TRIGGERED CENTRAL_UNIT_KEY = "0" -# Gateways that NACK the WHO 5 status request ``*#5*0##`` (own-firmware-oracle -# ``results///oracle/full/trace-corpus.tsv``: F450 020010, F453AV 030014, -# F454 020051, F459 020105, F460 020012, F461 020011, MyHomeServer1 028206 answer -# ``nack`` / ``silent``). Fallback for OWNd profiles that carry no -# ``supports_alarm_status_request`` flag yet; the flag belongs in OWNd (#381 pattern). -ALARM_STATUS_REFUSED_MODELS = frozenset({"F450", "F453AV", "F454", "F459", "F460", "F461", "MYHOMESERVER1"}) async def async_setup_entry( @@ -192,20 +186,22 @@ async def async_restore_last_state(self, last_state: State) -> None: @property def _status_request_refused(self) -> bool: - """Whether this gateway NACKs ``*#5*0##``: the OWNd profile flag first, then the model table. - - ``None`` on the profile means unverified and falls through to the table; a gateway - in neither is polled as before. + """Whether the OWNd profile says the gateway refuses the WHO 5 status request. + + Only an explicit ``supports_alarm_status_request = False`` on the profile skips + the poll. ``None`` (unverified) and a missing flag poll: the own-firmware-oracle + ``nack`` / ``silent`` rows for ``*#5*0##`` (F450, F453AV, F454, F459, F460, F461, + MyHomeServer1) come from factory-blank emulations and the live F461 / MyHomeServer1 + NACKs came from plants without a central unit, while the physical F454 (#311, + #629) and F453AV (#619) traces show the request answered. Neither says what a + plant with a central unit does, so a model is never treated as refusing. The + flag belongs in OWNd (#381 pattern). """ profile = getattr(self._gateway_handler, "profile", None) - flag = getattr(profile, "supports_alarm_status_request", None) - if isinstance(flag, bool): - return not flag - model = getattr(self._gateway_handler, "model", None) - return isinstance(model, str) and model.strip().upper().replace(" ", "") in ALARM_STATUS_REFUSED_MODELS + return getattr(profile, "supports_alarm_status_request", None) is False async def async_update(self) -> None: - """Request status from the gateway, unless the gateway refuses the request.""" + """Request status from the gateway, unless its OWNd profile says it refuses the request.""" if self._status_request_refused: LOGGER.debug( "%s %s: %s refuses *#5*0##; waiting for alarm frames on the bus", diff --git a/docs/configuration/alarm.md b/docs/configuration/alarm.md index 5b5fd1afd..b33efb90d 100644 --- a/docs/configuration/alarm.md +++ b/docs/configuration/alarm.md @@ -44,7 +44,7 @@ The panel follows what the central unit reports on the bus: * **`armed_home`** is never reported: home and away arming look the same on the bus. * Zones (`*5*11*#n##` active, `*5*18*#n##` not active) never change the panel's state. * A new panel is **unknown** until the central unit reports a state. After a restart it shows its last known state with the attribute `restored: true` until the next frame confirms it. -* F450, F453AV, F454, F459, F460, F461 and MyHomeServer1 refuse the status request `*#5*0##`, so the panel is not polled there and waits for the central unit's own frames. +* The panel polls `*#5*0##` once after it is added, on every gateway, unless the OWNd gateway profile states that the gateway refuses the request. Whether the request is answered cannot be inferred from the gateway model alone: a physical F454 ([#311](https://github.com/OpenWebNet-HA/MyHOME/issues/311), [#629](https://github.com/OpenWebNet-HA/MyHOME/issues/629)) and F453AV ([#619](https://github.com/OpenWebNet-HA/MyHOME/issues/619)) answer it with the system-level frames of point 1 above, although the factory-blank emulations of both in own-firmware-oracle answer NACK, and a live F461 2.0.11 and MyHomeServer1 3.87.13 without a central unit answer NACK too ([#799](https://github.com/OpenWebNet-HA/MyHOME/pull/799)). A refused request costs one request / NACK exchange on the command session and leaves the panel's state as it was. --- diff --git a/tests/test_alarm_restore_unknown.py b/tests/test_alarm_restore_unknown.py index 2ad7dd93e..d61cdc93d 100644 --- a/tests/test_alarm_restore_unknown.py +++ b/tests/test_alarm_restore_unknown.py @@ -1,4 +1,6 @@ -"""The alarm panel is unknown until the bus says otherwise, and restores its last state (#177).""" +"""The alarm panel is unknown until the bus says otherwise, and restores its last state.""" +import json +from pathlib import Path from unittest.mock import AsyncMock, MagicMock import pytest @@ -8,6 +10,12 @@ from custom_components.myhome.alarm_control_panel import MyHOMEAlarmControlPanel +FIXTURES = Path(__file__).resolve().parent / "fixtures" +# Emulated `nack` / `silent` rows for `*#5*0##` in own-firmware-oracle trace-corpus.tsv +# (F450 020010, F453AV 030014, F454 020051, F459 020105, F460 020012, F461 020011, +# MyHomeServer1 028206): factory-blank emulations, not a model capability. +ORACLE_NACK_MODELS = ["F450", "F453AV", "F454", "F459", "F460", "F461", "MyHomeServer1"] + def _gateway(model: str) -> MagicMock: gw = MagicMock() @@ -41,6 +49,28 @@ def _panel(hass: HomeAssistant, gateway: MagicMock) -> MyHOMEAlarmControlPanel: return panel +def _frames(path: Path) -> list[tuple[str, str]]: + """The (direction, raw) frames of a diagnostics dump, a bus-monitor capture or a live-run trace.""" + data = json.loads(path.read_text(encoding="utf-8")) + if "rows" in data: + return [(r["dir"], r["frame"]) for r in data["rows"] if r.get("dir") in ("tx", "rx")] + frames = data["frames"] if "frames" in data else data["data"]["bus_monitor"]["recent_frames"] + return [(f["direction"], f["raw"]) for f in frames] + + +def _replay_central_unit(panel: MyHOMEAlarmControlPanel, frames: list[tuple[str, str]]) -> int: + """Feed the received WHO 5 frames the central unit routes to the panel (empty WHERE or 0, like route_keys).""" + fed = 0 + for direction, raw in frames: + if direction != "rx" or not raw.startswith("*5*"): + continue + event = OWNEvent.parse(raw) + if event.where in ("", "0"): + panel.handle_event(event) + fed += 1 + return fed + + async def test_new_panel_is_unknown_not_disarmed(hass: HomeAssistant) -> None: """With no bus frame and nothing to restore the panel does not claim `disarmed`.""" panel = _panel(hass, _gateway("MH202")) @@ -90,31 +120,99 @@ async def test_unavailable_last_state_is_not_restored(hass: HomeAssistant) -> No assert "restored" not in panel.extra_state_attributes -@pytest.mark.parametrize("model", ["F454", "F459", "F460", "F461", "MyHomeServer1", "F450", "F453AV"]) -async def test_no_status_poll_where_the_gateway_refuses_it(hass: HomeAssistant, model: str) -> None: - """`*#5*0##` is NACKed by these gateways (own-firmware-oracle): no poll, state stays restored.""" +@pytest.mark.parametrize("model", ORACLE_NACK_MODELS) +async def test_oracle_nack_rows_do_not_gate_the_poll(hass: HomeAssistant, model: str) -> None: + """An emulated `nack` row is not a capability fact (the physical F454 disproves it): with no profile flag, poll.""" panel = _panel(hass, _gateway(model)) panel.async_get_last_state = AsyncMock(return_value=None) await panel.async_added_to_hass() - await panel.async_update() - panel._gateway_handler.send_status_request.assert_not_awaited() + panel._gateway_handler.send_status_request.assert_awaited_once() + assert str(panel._gateway_handler.send_status_request.await_args.args[0]) == "*#5*0##" assert panel.alarm_state is None @pytest.mark.parametrize( ("model", "flag", "polled"), - [("MH202", False, False), ("F461", True, True), ("F461", None, False), ("MH202", None, True)], + [("MH202", False, False), ("F461", False, False), ("F461", True, True), ("F461", None, True), ("MH202", None, True)], ) -async def test_ownd_profile_flag_decides_before_the_model_table(hass: HomeAssistant, model, flag, polled) -> None: - """A `supports_alarm_status_request` flag on the OWNd profile wins; `None` (unverified) falls back to the table.""" +async def test_only_an_explicit_profile_flag_skips_the_poll(hass: HomeAssistant, model, flag, polled) -> None: + """`supports_alarm_status_request` False on the OWNd profile skips; True or None (unverified) polls.""" gateway = _gateway(model) gateway.profile = MagicMock() gateway.profile.supports_alarm_status_request = flag panel = _panel(hass, gateway) panel.async_get_last_state = AsyncMock(return_value=None) + await panel.async_added_to_hass() + await panel.async_update() + + assert panel._gateway_handler.send_status_request.await_count == (2 if polled else 0) + + +@pytest.mark.parametrize( + ("fixture", "model", "request_frame", "first_reply"), + [ + # #311: physical F454 2.0 with a burglar alarm; *#5*#1## is answered on the bus + ("plants/issue_311_f454/diagnostic_summary.json", "F454", "*#5*#1##", "*5*11*#1##"), + # #629: physical F454 1.0.34 sweep; *#5*0## is answered with the central unit's frames + ("traces/issue_629/myhome_sweep_F454_all_2026-10-05T10-51-55.json", "F454", "*#5*0##", "*5*0*##"), + # #619: physical F453AV 3.0.14 sweep; *#5*0## is answered with the central unit's frames + ("traces/issue_619/myhome_sweep_F453AV_all_2026-10-04T19-45-11.json", "F453AV", "*#5*0##", "*5*1*##"), + ], +) +async def test_physical_f454_and_f453av_answer_the_status_request( + hass: HomeAssistant, fixture: str, model: str, request_frame: str, first_reply: str +) -> None: + """The emulated F454 / F453AV rows say `nack`; the physical plants answer, and their frames update the panel.""" + frames = _frames(FIXTURES / fixture) + tx_index = frames.index(("tx", request_frame)) + replies = [raw for direction, raw in frames[tx_index + 1 :] if direction == "rx" and raw.startswith("*5*")] + assert replies[0] == first_reply, f"{model} trace: {request_frame} is answered on the bus" + + panel = _panel(hass, _gateway(model)) + panel.async_get_last_state = AsyncMock( + return_value=State("alarm_control_panel.alarm_0", AlarmControlPanelState.ARMED_AWAY, {"raw_state": "engage"}) + ) + await panel.async_added_to_hass() + + # No profile flag: the F454 / F453AV is polled, and the central unit answers on these plants + panel._gateway_handler.send_status_request.assert_awaited_once() + assert str(panel._gateway_handler.send_status_request.await_args.args[0]) == "*#5*0##" + assert panel.extra_state_attributes["restored"] is True + + assert _replay_central_unit(panel, frames) > 0 + + # *5*9*## (disengage) confirmed the restored state: the panel is live again + assert panel.alarm_state == AlarmControlPanelState.DISARMED + assert "restored" not in panel.extra_state_attributes + + +@pytest.mark.parametrize( + ("fixture", "model"), + [ + # #799: live F461 2.0.11, no central unit: *#5*0## and *#5*#1## NACKed on the command session + ("traces/f461_live_2026-10-23/myhome_trace_F461_live_2026-10-23.json", "F461"), + # #799: live MyHomeServer1 3.87.13, no WHO 5 plant: *#5*0## NACKed on the command session + ("traces/myhomeserver1_live_2026-10-24/myhome_trace_MyHomeServer1_live_2026-10-24.json", "MyHomeServer1"), + ], +) +async def test_live_nack_leaves_the_restored_state_alone(hass: HomeAssistant, fixture: str, model: str) -> None: + """A NACK on `*#5*0##` is the gateway's own refusal on that plant: nothing reaches the panel, no false state.""" + frames = _frames(FIXTURES / fixture) + tx_index = frames.index(("tx", "*#5*0##")) + assert frames[tx_index + 1] == ("rx", "*#*0##"), f"{model} trace: *#5*0## is NACKed" + + panel = _panel(hass, _gateway(model)) + panel.async_get_last_state = AsyncMock( + return_value=State("alarm_control_panel.alarm_0", AlarmControlPanelState.ARMED_AWAY, {"raw_state": "engage"}) + ) await panel.async_added_to_hass() - assert panel._gateway_handler.send_status_request.await_count == (1 if polled else 0) + # The plant decides, not the model: the request is sent, the gateway NACKs it + panel._gateway_handler.send_status_request.assert_awaited_once() + assert _replay_central_unit(panel, frames) == 0 + assert panel.alarm_state == AlarmControlPanelState.ARMED_AWAY + assert panel.extra_state_attributes["restored"] is True + assert panel.extra_state_attributes["raw_state"] == "engage" From 83854fc97953f5def3a209d9df1fad35e139abc3 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:43:53 +0300 Subject: [PATCH 3/6] fix(alarm): decide the *#5*0## skip on the gateway that carries the request `send_status_request()` routes through `_delegated_target(cmd)` and `_failover_target(cmd)`, but the skip looked at the entity's own gateway. On a shared bus such as a MyHomeServer1 primary with an MH202 secondary carrying WHO 5 (#564 / #576), the panel registered on the primary would have skipped a poll the secondary answers; in warm standby the disconnected primary decided instead of the standby that sends. Resolve the target the same way `send_status_request` does (delegated secondary, then standby, then the gateway itself, cf. MyHOMEExposeManager._effective_profile) and read that target's profile. The DEBUG line names the frame actually skipped (`*#5*#1##` for a zone entry, not a hard-coded `*#5*0##`) and the target's canonical `profile.model_name` (`MHS1` -> `MyHomeServer1`, `573992` -> `F453AV`), falling back to the configured name only for a Generic profile. Tests: delegation in both directions, failover in both directions, an explicit True polls whatever the configured alias, SKU, case or spacing, and the log label for a short alias, a SKU and a Generic profile. Co-Authored-By: Claude Fable 5.1 --- .../myhome/alarm_control_panel.py | 49 ++++++-- tests/test_alarm_restore_unknown.py | 116 +++++++++++++++++- 2 files changed, 155 insertions(+), 10 deletions(-) diff --git a/custom_components/myhome/alarm_control_panel.py b/custom_components/myhome/alarm_control_panel.py index 5dd1f4121..bcedf8151 100644 --- a/custom_components/myhome/alarm_control_panel.py +++ b/custom_components/myhome/alarm_control_panel.py @@ -19,7 +19,9 @@ from OWNd.message import ( OWNAlarmCommand, OWNAlarmEvent, + OWNCommand, ) +from OWNd.profiles import GenericGatewayProfile from .const import ( CONF_DEVICE_MODEL, @@ -184,9 +186,25 @@ async def async_restore_last_state(self, last_state: State) -> None: if "state_code" in last_state.attributes: attrs["state_code"] = last_state.attributes["state_code"] - @property - def _status_request_refused(self) -> bool: - """Whether the OWNd profile says the gateway refuses the WHO 5 status request. + def _status_request_target(self, cmd: OWNCommand) -> MyHOMEGatewayHandler: + """The gateway that ``send_status_request(cmd)`` would carry ``cmd`` through. + + Mirrors ``MyHOMEGatewayHandler.send_status_request``: the connected secondary + that owns the delegated WHO, else the warm standby while the primary is + down, else the entity's own gateway. The request is then sent through that + gateway directly, so the route is resolved once. + """ + gateway = self._gateway_handler + for finder in ("_delegated_target", "_failover_target"): + resolve = getattr(gateway, finder, None) + target = resolve(cmd) if callable(resolve) else None + if isinstance(target, MyHOMEGatewayHandler): + return target + return gateway + + @staticmethod + def _status_request_refused(target: Any) -> bool: + """Whether the OWNd profile of ``target`` says it refuses the WHO 5 status request. Only an explicit ``supports_alarm_status_request = False`` on the profile skips the poll. ``None`` (unverified) and a missing flag poll: the own-firmware-oracle @@ -197,20 +215,33 @@ def _status_request_refused(self) -> bool: plant with a central unit does, so a model is never treated as refusing. The flag belongs in OWNd (#381 pattern). """ - profile = getattr(self._gateway_handler, "profile", None) + profile = getattr(target, "profile", None) return getattr(profile, "supports_alarm_status_request", None) is False + @staticmethod + def _gateway_label(target: Any) -> str: + """The canonical model name of ``target`` for logs: the OWNd profile's, else the configured one.""" + profile = getattr(target, "profile", None) + if profile is not None and not isinstance(profile, GenericGatewayProfile): + model_name = getattr(profile, "model_name", None) + if isinstance(model_name, str) and model_name: + return model_name + return str(getattr(target, "model", "")).strip() + async def async_update(self) -> None: - """Request status from the gateway, unless its OWNd profile says it refuses the request.""" - if self._status_request_refused: + """Request status, unless the OWNd profile of the gateway carrying the request says it refuses it.""" + cmd = OWNAlarmCommand.status(self._where) + target = self._status_request_target(cmd) + if self._status_request_refused(target): LOGGER.debug( - "%s %s: %s refuses *#5*0##; waiting for alarm frames on the bus", + "%s %s: %s refuses %s; waiting for alarm frames on the bus", self._gateway_handler.log_id, self._display_name, - self._gateway_handler.model, + self._gateway_label(target), + cmd, ) return - await self._gateway_handler.send_status_request(OWNAlarmCommand.status(self._where)) + await target.send_status_request(cmd) async def async_alarm_disarm(self, code: str | None = None) -> None: # pylint: disable=unused-argument """Disarm has no feature flag in core, so refuse it here instead of sending a frame the panel rejects.""" diff --git a/tests/test_alarm_restore_unknown.py b/tests/test_alarm_restore_unknown.py index d61cdc93d..ecc96f479 100644 --- a/tests/test_alarm_restore_unknown.py +++ b/tests/test_alarm_restore_unknown.py @@ -7,8 +7,10 @@ from homeassistant.components.alarm_control_panel import AlarmControlPanelState from homeassistant.core import HomeAssistant, State from OWNd.message import OWNEvent +from OWNd.profiles import GenericGatewayProfile, get_gateway_profile from custom_components.myhome.alarm_control_panel import MyHOMEAlarmControlPanel +from custom_components.myhome.gateway import MyHOMEGatewayHandler FIXTURES = Path(__file__).resolve().parent / "fixtures" # Emulated `nack` / `silent` rows for `*#5*0##` in own-firmware-oracle trace-corpus.tsv @@ -18,7 +20,7 @@ def _gateway(model: str) -> MagicMock: - gw = MagicMock() + gw = MagicMock(spec=MyHOMEGatewayHandler) gw.mac = "00:03:50:00:55:55" gw.unique_id = gw.mac gw.log_id = "[Test Alarm Gateway]" @@ -29,6 +31,18 @@ def _gateway(model: str) -> MagicMock: gw.initial_discovery_pending = False gw.send = AsyncMock() gw.send_status_request = AsyncMock() + # A single gateway: nothing is delegated or failed over + gw._delegated_target = MagicMock(return_value=None) + gw._failover_target = MagicMock(return_value=None) + return gw + + +def _refusing(model: str) -> MagicMock: + """A gateway whose OWNd profile states that it refuses the WHO 5 status request.""" + gw = _gateway(model) + gw.profile = MagicMock() + gw.profile.model_name = model + gw.profile.supports_alarm_status_request = False return gw @@ -216,3 +230,103 @@ async def test_live_nack_leaves_the_restored_state_alone(hass: HomeAssistant, fi assert panel.alarm_state == AlarmControlPanelState.ARMED_AWAY assert panel.extra_state_attributes["restored"] is True assert panel.extra_state_attributes["raw_state"] == "engage" + + +@pytest.mark.parametrize( + ("primary", "secondary", "polled"), + [ + # MyHomeServer1 primary whose profile refuses, MH202 secondary carrying WHO 5: the secondary is asked + (_refusing("MyHomeServer1"), _gateway("MH202"), True), + # MH202 primary, a secondary carrying WHO 5 whose profile refuses: nothing is sent + (_gateway("MH202"), _refusing("F461"), False), + ], +) +async def test_shared_bus_delegation_decides_on_the_secondary(hass: HomeAssistant, primary, secondary, polled) -> None: + """The gateway send_status_request would route to decides, not the one the entity was registered on.""" + primary._delegated_target = MagicMock(return_value=secondary) + panel = _panel(hass, primary) + panel.async_get_last_state = AsyncMock(return_value=None) + + await panel.async_added_to_hass() + + assert str(primary._delegated_target.call_args.args[0]) == "*#5*0##" + # The route is resolved once and the request goes through the secondary + primary.send_status_request.assert_not_awaited() + assert secondary.send_status_request.await_count == (1 if polled else 0) + + +@pytest.mark.parametrize( + ("primary", "standby", "polled"), + [(_refusing("F461"), _gateway("MH202"), True), (_gateway("MH202"), _refusing("F461"), False)], +) +async def test_failover_target_decides_while_the_primary_is_down(hass: HomeAssistant, primary, standby, polled) -> None: + """The warm standby that would carry the request decides, not the disconnected primary.""" + primary._failover_target = MagicMock(return_value=standby) + panel = _panel(hass, primary) + panel.async_get_last_state = AsyncMock(return_value=None) + + await panel.async_added_to_hass() + + primary.send_status_request.assert_not_awaited() + assert standby.send_status_request.await_count == (1 if polled else 0) + # The primary's own routing (and its failover bookkeeping) ran exactly once + primary._failover_target.assert_called_once() + + +async def test_a_mock_or_foreign_route_result_falls_back_to_the_gateway(hass: HomeAssistant) -> None: + """Only a real gateway handler is accepted as the route target; anything else means `no route`.""" + gateway = _gateway("MH202") + gateway._delegated_target = MagicMock(return_value=object()) + panel = _panel(hass, gateway) + panel.async_get_last_state = AsyncMock(return_value=None) + + await panel.async_added_to_hass() + + gateway.send_status_request.assert_awaited_once() + + +@pytest.mark.parametrize("configured_name", ["MHS1", "573992", " my-home-server-1 ", "f461"]) +async def test_explicit_true_polls_whatever_the_configured_name(hass: HomeAssistant, configured_name: str) -> None: + """An explicit True on the profile polls; the configured alias, SKU, case or spacing plays no part.""" + gateway = _gateway(configured_name) + gateway.profile = MagicMock() + gateway.profile.supports_alarm_status_request = True + panel = _panel(hass, gateway) + panel.async_get_last_state = AsyncMock(return_value=None) + + await panel.async_added_to_hass() + + gateway.send_status_request.assert_awaited_once() + + +@pytest.mark.parametrize( + ("configured_name", "label"), + [ + ("MHS1", "MyHomeServer1"), # short alias: the OWNd profile's canonical model_name + ("573992", "F453AV"), # SKU alias: the OWNd profile's canonical model_name + (" Unknown-GW ", "Unknown-GW"), # Generic profile: the configured name, trimmed + ], +) +async def test_refusal_log_names_the_canonical_model_and_the_frame( + hass: HomeAssistant, caplog, configured_name: str, label: str +) -> None: + """The DEBUG line carries the frame actually skipped and the canonical name of the gateway carrying it.""" + resolved = get_gateway_profile(configured_name) + assert isinstance(resolved, GenericGatewayProfile) == (label == "Unknown-GW") + primary = _gateway("MH202") + standby = _gateway(configured_name) + # The frozen OWNd profiles carry no flag yet: a profile of the resolved class that states one + standby.profile = MagicMock(spec=type(resolved)) + standby.profile.model_name = resolved.model_name + standby.profile.supports_alarm_status_request = False + primary._failover_target = MagicMock(return_value=standby) + panel = _panel(hass, primary) + panel._where = "#1" + panel.async_get_last_state = AsyncMock(return_value=None) + + with caplog.at_level("DEBUG", logger="custom_components.myhome"): + await panel.async_added_to_hass() + + primary.send_status_request.assert_not_awaited() + assert f"{label} refuses *#5*#1##" in caplog.text + assert "*#5*0##" not in caplog.text From fd8f71c43f7b38d336bd251145d5411c856fb70c Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:45:08 +0300 Subject: [PATCH 4/6] fix(alarm): restore only the states the panel emits and keep them until a verdict `AlarmControlPanelState(last_state.state)` accepted every core value, so a stale `armed_home` (removed in #609) or a transient `arming` / `pending` from .storage/core.restore_state would have been restored and left the documented template panel unavailable. Restore only `disarmed`, `armed_away` and `triggered`; anything else starts unknown. While `restored` is set, frames that name no state (`*5*1*0##` activation, `*5*11*#n##` / `*5*18*#n##` zones, `*5*5*##` battery, `*5*7*##` mains) no longer overwrite `raw_state` / `state_code`, which would have persisted a mismatched `armed_away` + `activation` pair across a second restart. A verdict frame overwrites both and clears the flag; once live, telemetry updates them as before. Tests: each restorable state, six stale or transient states, five non-verdict frames followed by a verdict. The doc lists the frames that confirm a restored state. Co-Authored-By: Claude Fable 5.1 --- .../myhome/alarm_control_panel.py | 22 ++++-- docs/configuration/alarm.md | 2 +- tests/test_alarm_restore_unknown.py | 68 +++++++++++++++++++ 3 files changed, 86 insertions(+), 6 deletions(-) diff --git a/custom_components/myhome/alarm_control_panel.py b/custom_components/myhome/alarm_control_panel.py index bcedf8151..e454b4dbf 100644 --- a/custom_components/myhome/alarm_control_panel.py +++ b/custom_components/myhome/alarm_control_panel.py @@ -43,6 +43,9 @@ STATE_TRIGGERED = AlarmControlPanelState.TRIGGERED CENTRAL_UNIT_KEY = "0" +# The only states this panel emits; anything else in .storage/core.restore_state is stale +# (`armed_home` was removed in #609) or transient and is not restored. +RESTORABLE_STATES = frozenset({STATE_DISARMED, STATE_ARMED_AWAY, STATE_TRIGGERED}) async def async_setup_entry( @@ -174,11 +177,17 @@ def alarm_state(self) -> AlarmControlPanelState | None: return self._attr_alarm_state async def async_restore_last_state(self, last_state: State) -> None: - """Restore the last known state, flagged as restored until a bus frame confirms it.""" + """Restore the last known state, flagged as restored until a bus frame confirms it. + + Only ``disarmed``, ``armed_away`` and ``triggered`` are restored: a stale + ``armed_home`` would leave the documented template panel unavailable. + """ try: state = AlarmControlPanelState(last_state.state) except ValueError: return + if state not in RESTORABLE_STATES: + return self._attr_alarm_state = state attrs = self._attr_extra_state_attributes attrs["restored"] = True @@ -267,13 +276,16 @@ def handle_event(self, message: OWNAlarmEvent) -> None: live_state = STATE_ARMED_AWAY elif message.is_disarmed: live_state = STATE_DISARMED + attrs = self._attr_extra_state_attributes if live_state is not None: self._attr_alarm_state = live_state # Only a frame that sets the state confirms it; activation or zone frames do not. - self._attr_extra_state_attributes.pop("restored", None) - - self._attr_extra_state_attributes["raw_state"] = message.state_name - self._attr_extra_state_attributes["state_code"] = message.state_code + attrs.pop("restored", None) + if live_state is not None or "restored" not in attrs: + # While the state is only restored, telemetry that names no state (activation, + # zones, battery, mains) must not replace the restored raw_state / state_code. + attrs["raw_state"] = message.state_name + attrs["state_code"] = message.state_code if self.hass is not None: self.async_schedule_update_ha_state() diff --git a/docs/configuration/alarm.md b/docs/configuration/alarm.md index b33efb90d..894ed90e5 100644 --- a/docs/configuration/alarm.md +++ b/docs/configuration/alarm.md @@ -43,7 +43,7 @@ The panel follows what the central unit reports on the bus: * `*5*1*##` (*activation*) is **not** an armed state. The central unit sends it while disarming (`*5*2*0##` → `*5*1*0##` → `*5*9*0##`) and in the status of a disarmed system, as well as while arming (`*5*1*0##` → `*5*8*0##`). * **`armed_home`** is never reported: home and away arming look the same on the bus. * Zones (`*5*11*#n##` active, `*5*18*#n##` not active) never change the panel's state. -* A new panel is **unknown** until the central unit reports a state. After a restart it shows its last known state with the attribute `restored: true` until the next frame confirms it. +* A new panel is **unknown** until the central unit reports a state. After a restart it shows its last `disarmed`, `armed_away` or `triggered` state with the attribute `restored: true` until the next frame that names a state (`*5*8*`, `*5*9*`, `*5*2*`, `*5*0*` or an alarm) confirms it; activation, zone, battery and mains frames leave the restored `raw_state` and `state_code` as they were. * The panel polls `*#5*0##` once after it is added, on every gateway, unless the OWNd gateway profile states that the gateway refuses the request. Whether the request is answered cannot be inferred from the gateway model alone: a physical F454 ([#311](https://github.com/OpenWebNet-HA/MyHOME/issues/311), [#629](https://github.com/OpenWebNet-HA/MyHOME/issues/629)) and F453AV ([#619](https://github.com/OpenWebNet-HA/MyHOME/issues/619)) answer it with the system-level frames of point 1 above, although the factory-blank emulations of both in own-firmware-oracle answer NACK, and a live F461 2.0.11 and MyHomeServer1 3.87.13 without a central unit answer NACK too ([#799](https://github.com/OpenWebNet-HA/MyHOME/pull/799)). A refused request costs one request / NACK exchange on the command session and leaves the panel's state as it was. --- diff --git a/tests/test_alarm_restore_unknown.py b/tests/test_alarm_restore_unknown.py index ecc96f479..5650f8b17 100644 --- a/tests/test_alarm_restore_unknown.py +++ b/tests/test_alarm_restore_unknown.py @@ -330,3 +330,71 @@ async def test_refusal_log_names_the_canonical_model_and_the_frame( primary.send_status_request.assert_not_awaited() assert f"{label} refuses *#5*#1##" in caplog.text assert "*#5*0##" not in caplog.text + + +@pytest.mark.parametrize( + "state", [AlarmControlPanelState.DISARMED, AlarmControlPanelState.ARMED_AWAY, AlarmControlPanelState.TRIGGERED] +) +async def test_each_state_the_panel_emits_is_restored(hass: HomeAssistant, state: AlarmControlPanelState) -> None: + panel = _panel(hass, _gateway("MH202")) + panel.async_get_last_state = AsyncMock(return_value=State("alarm_control_panel.alarm_0", state, {"raw_state": "x"})) + + await panel.async_added_to_hass() + + assert panel.alarm_state == state + assert panel.extra_state_attributes["restored"] is True + + +@pytest.mark.parametrize("stale", ["armed_home", "arming", "pending", "armed_night", "unknown", "unavailable"]) +async def test_states_the_panel_never_emits_are_not_restored(hass: HomeAssistant, stale: str) -> None: + """A stale `armed_home` (removed in #609) or a transient state must not wedge the template alarm recipe.""" + panel = _panel(hass, _gateway("MH202")) + panel.async_get_last_state = AsyncMock( + return_value=State("alarm_control_panel.alarm_0", stale, {"raw_state": "engage", "state_code": 8}) + ) + + await panel.async_added_to_hass() + + assert panel.alarm_state is None + assert "restored" not in panel.extra_state_attributes + assert panel.extra_state_attributes["raw_state"] is None + assert "state_code" not in panel.extra_state_attributes + + +@pytest.mark.parametrize( + "frame", + [ + "*5*1*0##", # activation + "*5*11*#1##", # active zone (routed to the central unit on an F454, #311) + "*5*18*#2##", # not-active zone + "*5*5*##", # battery OK, empty WHERE + "*5*7*##", # mains, empty WHERE + ], +) +async def test_non_verdict_frames_keep_the_restored_raw_state(hass: HomeAssistant, frame: str) -> None: + """While `restored` is set, telemetry that names no state leaves raw_state / state_code as restored.""" + panel = _panel(hass, _gateway("MH202")) + panel.async_get_last_state = AsyncMock( + return_value=State( + "alarm_control_panel.alarm_0", AlarmControlPanelState.ARMED_AWAY, {"raw_state": "engage", "state_code": 8} + ) + ) + await panel.async_added_to_hass() + + panel.handle_event(OWNEvent.parse(frame)) + + assert panel.alarm_state == AlarmControlPanelState.ARMED_AWAY + assert panel.extra_state_attributes["restored"] is True + assert panel.extra_state_attributes["raw_state"] == "engage" + assert panel.extra_state_attributes["state_code"] == 8 + + # A verdict overwrites both and clears the flag + panel.handle_event(OWNEvent.parse("*5*9*0##")) + assert panel.alarm_state == AlarmControlPanelState.DISARMED + assert "restored" not in panel.extra_state_attributes + assert panel.extra_state_attributes["raw_state"] == "disengage" + assert panel.extra_state_attributes["state_code"] == 9 + + # Live again: telemetry updates the raw attributes as before + panel.handle_event(OWNEvent.parse(frame)) + assert panel.extra_state_attributes["raw_state"] == OWNEvent.parse(frame).state_name From 9e55fb6296d49f10d188b24394e707366a3a7128 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:45:26 +0300 Subject: [PATCH 5/6] docs(alarm): say when the template panel is available after a restart The `availability` comment said the template panel is unavailable until the MyHOME panel has reported. With RestoreEntity that holds only on a blank first start: after a restart `alarm_0` is available at once on its restored state (`restored: true`) until the central unit confirms it. Say so, and give the `and not state_attr(..., 'restored')` clause for users who want the template panel unavailable until the bus has confirmed the state. Co-Authored-By: Claude Fable 5.1 --- docs/configuration/alarm.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/configuration/alarm.md b/docs/configuration/alarm.md index 894ed90e5..71b380e6f 100644 --- a/docs/configuration/alarm.md +++ b/docs/configuration/alarm.md @@ -64,8 +64,13 @@ template: - alarm_control_panel: - name: Home alarm unique_id: home_alarm - # Unavailable until the MyHOME panel has reported. Falling back to - # "disarmed" instead would show an armed alarm as disarmed after a restart. + # Unavailable while the MyHOME panel is unknown (blank first start, or a + # restart with no state to restore). After a restart the MyHOME panel is + # available at once on its restored state (attribute restored: true) until + # the central unit confirms it. Falling back to "disarmed" instead would + # show an armed alarm as disarmed. To keep this panel unavailable until + # the bus has confirmed the state, append: + # and not state_attr('alarm_control_panel.alarm_0', 'restored') availability: "{{ states('alarm_control_panel.alarm_0') in ['disarmed', 'armed_away', 'triggered'] }}" state: "{{ states('alarm_control_panel.alarm_0') }}" code_format: number From 3b6b1bc9aad4a2343f1f2b28cf713b12da8143a6 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:45:26 +0300 Subject: [PATCH 6/6] chore(alarm): drop the misattributed #177 references #177 is a pre-v2 feature request about adding an F4201 central unit on a MyHomeServer1, before WHO 5 existed; it names no F454 and contains no sentence about the state after a restart. No open or closed issue describes the disarmed-after-restart behaviour; the motivation is the live F461 observation of the panel's default state. Remove the citation from the code comment and the test comments (the release note and the commit messages no longer carry it either). Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/alarm_control_panel.py | 2 +- tests/test_component_alarm.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/custom_components/myhome/alarm_control_panel.py b/custom_components/myhome/alarm_control_panel.py index e454b4dbf..4e4a5794c 100644 --- a/custom_components/myhome/alarm_control_panel.py +++ b/custom_components/myhome/alarm_control_panel.py @@ -164,7 +164,7 @@ def __init__( self._attr_supported_features = AlarmControlPanelEntityFeature(0) # The central unit takes no code over the bus. self._attr_code_arm_required = False - # Unknown until a bus frame or the restored state says otherwise (#177). + # Unknown until a bus frame or the restored state says otherwise. self._attr_alarm_state: AlarmControlPanelState | None = None self._attr_extra_state_attributes = { "where": self._where, diff --git a/tests/test_component_alarm.py b/tests/test_component_alarm.py index 02d6f4dd2..d55812aa8 100644 --- a/tests/test_component_alarm.py +++ b/tests/test_component_alarm.py @@ -119,7 +119,7 @@ def fake_add_entities(entities): assert isinstance(msg_empty_activation, OWNAlarmEvent) async_dispatcher_send(hass, f"myhome_message_{mac}", msg_empty_activation) assert len(added_entities) == 3 - # Activation is neither armed nor disarmed: a fresh panel stays unknown (#177) + # Activation is neither armed nor disarmed: a fresh panel stays unknown assert central_alarm.alarm_state == (STATE_ARMED_AWAY if msg_empty_activation.is_armed_away else None) assert central_alarm.extra_state_attributes["raw_state"] == "activation" assert central_alarm.extra_state_attributes["state_code"] == 1 @@ -417,7 +417,7 @@ def alarm_zone1(self, hass, mock_gateway): def test_alarm_attributes(self, alarm_central, alarm_zone1): # Read-only: the central unit rejects SCS arm/disarm (#564) assert alarm_central.supported_features == AlarmControlPanelEntityFeature(0) - # Unknown until the bus or the restored state says otherwise (#177) + # Unknown until the bus or the restored state says otherwise assert alarm_central.alarm_state is None assert alarm_central.state is None assert alarm_central.extra_state_attributes["where"] == "0"