Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/release-notes/2.0.0b16.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down
111 changes: 96 additions & 15 deletions custom_components/myhome/alarm_control_panel.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down Expand Up @@ -159,25 +164,93 @@ 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
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
Comment on lines +179 to +191

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AlarmControlPanelState(last_state.state) accepts every HA core enum value (including armed_home, which MyHOME removed in #609, as well as arming/pending). Restoring a stale armed_home from .storage/core.restore_state would leave the documented template panel (states(...) in ['disarmed', 'armed_away', 'triggered']) stuck unavailable.

Please restrict restoration to the states this entity actually emits ({STATE_DISARMED, STATE_ARMED_AWAY, STATE_TRIGGERED}).

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."""
Expand All @@ -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()
11 changes: 9 additions & 2 deletions docs/configuration/alarm.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@ The panel follows what the central unit reports on the bus:
* `*5*1*<where>##` (*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.

---

Expand All @@ -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
Expand Down
Loading
Loading