diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 196af1a4..8f878b8d 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 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 a137f955..4e4a5794 100644 --- a/custom_components/myhome/alarm_control_panel.py +++ b/custom_components/myhome/alarm_control_panel.py @@ -12,14 +12,16 @@ 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 from OWNd.message import ( OWNAlarmCommand, OWNAlarmEvent, + OWNCommand, ) +from OWNd.profiles import GenericGatewayProfile from .const import ( CONF_DEVICE_MODEL, @@ -41,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( @@ -159,10 +164,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. + self._attr_alarm_state: AlarmControlPanelState | None = None self._attr_extra_state_attributes = { "where": self._where, - "raw_state": "disarmed", + "raw_state": None, } @property @@ -170,14 +176,81 @@ 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. + + 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 + attrs["raw_state"] = last_state.attributes.get("raw_state") + if "state_code" in last_state.attributes: + attrs["state_code"] = last_state.attributes["state_code"] + + 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 + ``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(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.""" - await self._gateway_handler.send_status_request(OWNAlarmCommand.status(self._where)) + """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 %s; waiting for alarm frames on the bus", + self._gateway_handler.log_id, + self._display_name, + self._gateway_label(target), + cmd, + ) + return + 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.""" @@ -196,15 +269,23 @@ 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 - - self._attr_extra_state_attributes["raw_state"] = message.state_name - self._attr_extra_state_attributes["state_code"] = message.state_code + 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. + 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 2d977370..71b380e6 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 `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. --- @@ -62,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 diff --git a/tests/test_alarm_restore_unknown.py b/tests/test_alarm_restore_unknown.py new file mode 100644 index 00000000..5650f8b1 --- /dev/null +++ b/tests/test_alarm_restore_unknown.py @@ -0,0 +1,400 @@ +"""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 +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 +# (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(spec=MyHOMEGatewayHandler) + 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() + # 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 + + +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 + + +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")) + 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", 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() + + 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", False, False), ("F461", True, True), ("F461", None, True), ("MH202", None, True)], +) +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() + + # 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" + + +@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 + + +@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 diff --git a/tests/test_component_alarm.py b/tests/test_component_alarm.py index 042d4df3..d55812aa 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 + 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 + 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):