From 755586d096b1c413f17610ee3f575f4726b8195c Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 15:34:54 +0300 Subject: [PATCH 1/9] fix(events): fire scope events only for frames that carry a state (#367) A WHAT 30/31 dim step to a group, area or general address parses with `is_on True`, so `myhome_{group,area,general}_light_event` fired as "on" although the frame only says the light changed one step. A WHO 2 dimension frame to a scope address (`*#2*#5*11*50##`, `*#2*3*12*1##`, `*#2*0*13*1##`) parses with `is_opening` / `is_closing` None, so the automation event fired as "stop" with no state behind it. Dim steps now fire no light event (the member re-sync still runs) and the WHO 2 branch fires only when the frame states a motion. On a physical F461 2.0.11, `*2*0*0##` (general stop) fires exactly one `myhome_general_automation_event` with `event: stop`, as before. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 1 + custom_components/myhome/gateway_events.py | 15 +++++++-- docs/configuration/lights.md | 2 +- tests/test_gateway.py | 38 ++++++++++++++++++++++ 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 6632155f8..f204432c0 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -22,6 +22,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). - **`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). +- **Group, area and general bus events only for frames that carry a state.** A dim step (`*1*30*#1##` / `*1*31*#1##`) no longer fires `myhome_group_light_event` / `myhome_area_light_event` as `on`, and a WHO 2 dimension frame to a group, area or general address (`*#2*#5*11*50##`) no longer fires an automation event as `stop`; on/off, open/close/stop frames fire as before (#367). - **`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). #### Other changes diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index 10916c05d..fb5e4160b 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -303,9 +303,14 @@ async def process_message(self, message: Any) -> None: dim = getattr(message, "dimension", None) is_not_dimension = not (isinstance(dim, int) and not isinstance(dim, bool)) + # A dim step (WHAT 30/31) says the light changed, not that it is on (#367). + is_dim_step = getattr(message, "what", None) in (30, 31) + if message.is_on is not None and is_not_dimension: event = "on" if message.is_on else "off" - if message.is_general: + if is_dim_step: + pass # no on/off event; the members are still resynced below + elif message.is_general: self.hass.bus.async_fire( "myhome_general_light_event", {"message": str(message), "event": event}, @@ -334,7 +339,13 @@ async def process_message(self, message: Any) -> None: or getattr(message, "is_group", False) ): self.handler._schedule_resync(message) - elif isinstance(message, OWNAutomationEvent) and self._is_active_for_who(2): + elif ( + isinstance(message, OWNAutomationEvent) + and self._is_active_for_who(2) + # Only a frame that states the motion is an open/close/stop event: a + # dimension frame with no known state is none of them (#367). + and (message.is_opening is not None or message.is_closing is not None) + ): if message.is_opening and not message.is_closing: event = "open" elif message.is_closing and not message.is_opening: diff --git a/docs/configuration/lights.md b/docs/configuration/lights.md index 426e7bceb..5e2d6e188 100644 --- a/docs/configuration/lights.md +++ b/docs/configuration/lights.md @@ -95,7 +95,7 @@ groups: --- ### Synchronizing Physical Wall Switches (SCS Group Buttons) -If you have physical BTicino wall switches configured to trigger an SCS group (e.g. `WHERE = #1`), the integration automatically dispatches a bus event named `myhome_group_light_event` whenever group frames are intercepted on the SCS bus. You can keep your Home Assistant Light Group perfectly synchronized with a simple automation: +If you have physical BTicino wall switches configured to trigger an SCS group (e.g. `WHERE = #1`), the integration automatically dispatches a bus event named `myhome_group_light_event` whenever a group frame with a state (`on` / `off`) is intercepted on the SCS bus. A dim step to the group (`*1*30*#1##` / `*1*31*#1##`) fires no event, as it does not say whether the group is on; the same holds for the area and general events and for the WHO 2 automation events (`myhome_group_automation_event`, `myhome_area_automation_event`, `myhome_general_automation_event`), which fire for `open` / `close` / `stop` frames only. You can keep your Home Assistant Light Group perfectly synchronized with a simple automation: ```yaml alias: "Sync MyHOME Group 1 Wall Switch" diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 11f17a4e6..5ccf835b4 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -519,6 +519,44 @@ async def test_listening_loop_lighting_unknown_is_on_skips_event(gateway_handler assert "myhome_group_light_event" not in fired_events +@pytest.mark.asyncio +async def test_scope_events_fire_only_for_state_frames(gateway_handler): + """#367: a dim step is not "on" and a WHO 2 dimension frame without a state is not "stop".""" + frames = [ + "*1*30*#5##", # group 5 dim up one step + "*1*31*3##", # area 3 dim down one step + "*#2*#5*11*50##", # WHO 2 dimension 11 to group 5 + "*#2*3*12*1##", # WHO 2 dimension 12 to area 3 + "*#2*0*13*1##", # WHO 2 dimension 13, general + "*1*1*#6##", # group 6 on: a real state, still fired + "*1*0*#6##", # group 6 off + "*2*0*#5##", # group 5 stop: a real state + "*2*1*3##", # area 3 open + ] + with patch("custom_components.myhome.gateway.OWNEventSession") as mock_session_class: + mock_session = MagicMock() + mock_session.connect = AsyncMock(return_value={"Success": True}) + mock_session.get_next = AsyncMock(side_effect=[OWNMessage.parse(f) for f in frames] + [asyncio.CancelledError()]) + mock_session_class.return_value = mock_session + gateway_handler.send_status_request = AsyncMock() + try: + await gateway_handler.listening_loop() + except asyncio.CancelledError: + pass + + fired = [ + (call.args[0], call.args[1].get("message"), call.args[1].get("event")) + for call in gateway_handler.hass.bus.async_fire.call_args_list + if call.args[0].endswith(("_light_event", "_automation_event")) + ] + assert fired == [ + ("myhome_group_light_event", "*1*1*#6##", "on"), + ("myhome_group_light_event", "*1*0*#6##", "off"), + ("myhome_group_automation_event", "*2*0*#5##", "stop"), + ("myhome_area_automation_event", "*2*1*3##", "open"), + ] + + @pytest.mark.asyncio async def test_listening_loop_automation(gateway_handler): with patch("custom_components.myhome.gateway.OWNEventSession") as mock_session_class: From 6469570d03d51f6ddef4f14c36c9f9ce46df108a Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:16:42 +0300 Subject: [PATCH 2/9] fix(resync): sweep a WHAT 30/31 dim step with an unknown polarity (#367) OWNd 2.0.0b11 parses `*1*31*#5##` with `is_on True`, so the member re-sync recorded `expected_on = True` for the group. When the step dims the members down to 0 % their `*1*0*##` echoes were discarded as the wrong polarity, the sweep could not be cancelled and `*#1*#G##` / `*#1*A##` went out although every member had spoken. A dim step commands no polarity: `_resync_expected_on[target]` is now `None` for WHAT 30/31 and both ON and OFF member echoes count towards cancelling the sweep. Resync eligibility no longer hangs on `is_on is not None` in the dispatcher or in `LightingResyncManager.schedule_resync`, so an OWNd that reports `is_on None` for a dim step keeps the member re-sync running instead of silently disabling it. Tests: `*1*31*#5##` / `*1*31*3##` schedule the `#5` and `3` sweeps with no expected polarity; OFF and mixed member echoes after a dim step cancel them; a WHAT 31 frame with `is_on None` still schedules. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/gateway_events.py | 4 +- custom_components/myhome/gateway_resync.py | 14 +++- tests/test_gateway.py | 4 + tests/test_issue_368_resync.py | 87 +++++++++++++++++++++- 4 files changed, 103 insertions(+), 6 deletions(-) diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index fb5e4160b..f17267483 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -304,9 +304,11 @@ async def process_message(self, message: Any) -> None: is_not_dimension = not (isinstance(dim, int) and not isinstance(dim, bool)) # A dim step (WHAT 30/31) says the light changed, not that it is on (#367). + # It fires no on/off event but still resyncs its members, whether OWNd + # reports its `is_on` as True (2.0.0b11) or None. is_dim_step = getattr(message, "what", None) in (30, 31) - if message.is_on is not None and is_not_dimension: + if (message.is_on is not None or is_dim_step) and is_not_dimension: event = "on" if message.is_on else "off" if is_dim_step: pass # no on/off event; the members are still resynced below diff --git a/custom_components/myhome/gateway_resync.py b/custom_components/myhome/gateway_resync.py index 0788aebb2..09ad1673d 100644 --- a/custom_components/myhome/gateway_resync.py +++ b/custom_components/myhome/gateway_resync.py @@ -53,7 +53,9 @@ def __init__( self._ptp_polarities: dict[tuple[float, str], bool | None] = {} self._resync_area_echoes: dict[str, set[str]] = {} self._group_members: dict[str, set[str]] = {} - self._resync_expected_on: dict[str, bool] = {} + # Polarity the scope frame commands, or None when it says none (a WHAT 30/31 + # dim step): then both ON and OFF member echoes count towards cancelling. + self._resync_expected_on: dict[str, bool | None] = {} self._resync_echoed_members: dict[str, set[str]] = {} @property @@ -248,8 +250,12 @@ def schedule_resync(self, message: Any) -> None: dim = getattr(message, "dimension", None) is_dimension = isinstance(dim, int) and not isinstance(dim, bool) - msg_is_on = getattr(message, "is_on", None) - if msg_is_on is None or is_dimension: + # A WHAT 30/31 dim step moves the members without saying whether they end up + # on or off (#367): it is swept with an unknown polarity, whatever `is_on` + # OWNd gives it (True in 2.0.0b11, None once it stops calling a step "on"). + is_dim_step = getattr(message, "what", None) in (30, 31) + msg_is_on: bool | None = None if is_dim_step else getattr(message, "is_on", None) + if (msg_is_on is None and not is_dim_step) or is_dimension: return now = float(self._time.monotonic()) @@ -329,7 +335,7 @@ def _leading_echoes_for_area(target_area: str) -> set[str]: for where in targets: if where in self._resync_timers: self._resync_timers.pop(where)() - self._resync_expected_on[where] = bool(msg_is_on) + self._resync_expected_on[where] = msg_is_on if isinstance(msg_is_on, bool) else None if where.startswith("#"): seeded = set(group_seed_echoes.get(where, set())) self._resync_echoed_members[where] = seeded diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 5ccf835b4..c9b270989 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -555,6 +555,10 @@ async def test_scope_events_fire_only_for_state_frames(gateway_handler): ("myhome_group_automation_event", "*2*0*#5##", "stop"), ("myhome_area_automation_event", "*2*1*3##", "open"), ] + # The dim steps still schedule the member re-sync of group 5 and area 3, with no + # expected polarity: a step says nothing about where the members end up. + assert sorted(gateway_handler._resync_timers) == ["#5", "#6", "3"] + assert gateway_handler._resync_manager._resync_expected_on == {"#5": None, "3": None, "#6": False} @pytest.mark.asyncio diff --git a/tests/test_issue_368_resync.py b/tests/test_issue_368_resync.py index 6cadfa513..13215bf05 100644 --- a/tests/test_issue_368_resync.py +++ b/tests/test_issue_368_resync.py @@ -7,7 +7,7 @@ from homeassistant.const import CONF_HOST, CONF_MAC, CONF_PASSWORD, CONF_PORT from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er -from OWNd.message import OWNMessage +from OWNd.message import OWNLightingEvent, OWNMessage from pytest_homeassistant_custom_component.common import ( MockConfigEntry, async_fire_time_changed, @@ -980,3 +980,88 @@ async def _send(cmd: Any) -> None: (round(12.461 + RESYNC_DEBOUNCE_S, 3), "*#1*00##"), ] + + +@pytest.mark.asyncio +async def test_dim_step_sweeps_members_with_unknown_polarity( + hass: HomeAssistant, handler: MyHOMEGatewayHandler +) -> None: + """A WHAT 30/31 dim step resyncs its members but commands no polarity (#367). + + OWNd 2.0.0b11 parses `*1*31*#5##` with `is_on True`; recording that as the + expected polarity made the members' `*1*0*##` echoes (dimmed down to 0 %) + look like the wrong polarity, so the sweep ran although every member had spoken. + """ + mgr = handler._resync_manager + + # Group dim-down: both OFF echoes cancel the sweep. + await handler._process_message(OWNMessage.parse("*1*31*#5##")) + assert "#5" in handler._resync_timers + assert mgr._resync_expected_on["#5"] is None + await handler._process_message(OWNMessage.parse("*1*0*51##")) + assert "#5" in handler._resync_timers + await handler._process_message(OWNMessage.parse("*1*0*52##")) + assert "#5" not in handler._resync_timers + await _advance(hass) + handler.send_status_request.assert_not_called() + + # Group dim-up: one ON and one OFF echo count alike, the polarity is unknown. + mgr.cancel_all() # forget the leading echoes of the previous phase + await handler._process_message(OWNMessage.parse("*1*30*#6##")) + assert mgr._resync_expected_on["#6"] is None + await handler._process_message(OWNMessage.parse("*1*1*11##")) + await handler._process_message(OWNMessage.parse("*1*0*12##")) + assert "#6" not in handler._resync_timers + await _advance(hass) + handler.send_status_request.assert_not_called() + + # Area dim-down: an OFF echo from the area cancels its sweep. + mgr.cancel_all() + await handler._process_message(OWNMessage.parse("*1*31*3##")) + assert "3" in handler._resync_timers + assert mgr._resync_expected_on["3"] is None + await handler._process_message(OWNMessage.parse("*1*0*31##")) + assert "3" not in handler._resync_timers + await _advance(hass) + handler.send_status_request.assert_not_called() + + # Silence after a dim step still sweeps, with the group's own status request. + mgr.cancel_all() + await handler._process_message(OWNMessage.parse("*1*31*#7##")) + await _advance(hass) + handler.send_status_request.assert_called_once() + assert str(handler.send_status_request.call_args[0][0]) == "*#1*#7##" + assert "#7" not in mgr._resync_expected_on + + +@pytest.mark.asyncio +async def test_dim_step_resync_does_not_depend_on_is_on( + hass: HomeAssistant, handler: MyHOMEGatewayHandler +) -> None: + """Member re-sync on a dim step must survive OWNd reporting `is_on None` for WHAT 30/31.""" + msg = MagicMock(spec=OWNLightingEvent) + msg.who = 1 + msg.what = 31 + msg.where = "#5" + msg.interface = None + msg.is_translation = False + msg.is_general = False + msg.is_area = False + msg.is_group = True + msg.group = "5" + msg.is_on = None + msg.dimension = None + msg.brightness = None + msg.unknown_state = None + msg.human_readable_log = "Light #5 is dimmed down one level." + msg.__str__ = lambda self: "*1*31*#5##" # type: ignore[method-assign] + + await handler._process_message(msg) + assert "#5" in handler._resync_timers + assert handler._resync_manager._resync_expected_on["#5"] is None + + # The manager alone accepts it too, while a motion or dimension frame is still refused. + handler._resync_manager.cancel_all() + handler._resync_manager.schedule_resync(msg) + assert "#5" in handler._resync_timers + handler._resync_manager.cancel_all() From dfc103445c28cc256df15565b8dff4aa3e940be9 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:18:59 +0300 Subject: [PATCH 3/9] fix(light): a WHAT 30/31 dim step does not switch an entity on (#367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OWNd 2.0.0b11 reports a dim step as `is_on True`, so an assumed-state `MyHOMELightGroup` flipped to on at `*1*31*#5##`, a point-to-point `MyHOMELight` did the same at `*1*31*12##`, and the dispatcher counted the point-to-point step as an ON member echo of a resync sweep. A step is a relative command: from 0 % it may well leave the light off. `MyHOMELightGroup.handle_event`, `MyHOMELight.handle_event` and the member-echo path in the dispatcher now skip `what in (30, 31)` until OWNd stops calling a step "on"; the entity keeps its last known state and a step neither seeds nor cancels a sweep. Tests: an assumed-state group and a dimmer stay unknown / off / on across `*1*30*…##`, `*1*31*…##` and `*1*30#2#100*…##`; point-to-point dim steps around `*1*1*#6##` leave its sweep armed until the members' own on/off echoes arrive. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/gateway_events.py | 6 +++-- custom_components/myhome/light.py | 4 ++- custom_components/myhome/light_group.py | 10 +++++-- tests/test_component_light.py | 31 ++++++++++++++++++++++ tests/test_issue_368_groups.py | 24 +++++++++++++++++ tests/test_issue_368_resync.py | 23 ++++++++++++++++ 6 files changed, 93 insertions(+), 5 deletions(-) diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index f17267483..39e47042d 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -294,10 +294,12 @@ async def process_message(self, message: Any) -> None: and not getattr(message, "is_general", False) and message.is_on is not None and message.dimension is None + and getattr(message, "what", None) not in (30, 31) ): # Only an actuator's own on/off status is a member echo: motion - # frames and illuminance / PIR dimension pushes must not cancel - # or count towards a resync sweep. + # frames, illuminance / PIR dimension pushes and WHAT 30/31 dim + # steps (relative commands, "on" only to OWNd 2.0.0b11) must not + # cancel or count towards a resync sweep (#367). self.handler._resync_manager.handle_ptp_echo(message) dim = getattr(message, "dimension", None) diff --git a/custom_components/myhome/light.py b/custom_components/myhome/light.py index 2c32e7519..e110d0b82 100644 --- a/custom_components/myhome/light.py +++ b/custom_components/myhome/light.py @@ -1135,7 +1135,9 @@ def handle_event(self, message: OWNLightingEvent) -> None: self._gateway_handler.log_id, self._full_where, ) - elif message.is_on is not None: + elif message.is_on is not None and getattr(message, "what", None) not in (30, 31): + # A WHAT 30/31 dim step is a relative command, not a state: OWNd 2.0.0b11 + # still reports it as `is_on True`, and a step from 0 % may leave the light off (#367). self._attr_is_on = message.is_on self._is_on_restored = False # An "off" frame is complete for a dimmer too: there is no brightness to ask for. diff --git a/custom_components/myhome/light_group.py b/custom_components/myhome/light_group.py index d6efd6f56..674ceaba8 100644 --- a/custom_components/myhome/light_group.py +++ b/custom_components/myhome/light_group.py @@ -191,8 +191,14 @@ def handle_event(self, msg: Any) -> None: if msg_iface != self._interface: return - # Parse status from the frame (assumed mode only updates attributes) - if isinstance(msg, OWNLightingEvent) and getattr(msg, "dimension", None) is None: + # Parse status from the frame (assumed mode only updates attributes). A WHAT + # 30/31 dim step moves the group one level but says nothing about whether it + # is on, whatever `is_on` OWNd gives it (#367). + if ( + isinstance(msg, OWNLightingEvent) + and getattr(msg, "dimension", None) is None + and getattr(msg, "what", None) not in (30, 31) + ): if msg.is_on is True: self._attr_is_on = True elif msg.is_on is False: diff --git a/tests/test_component_light.py b/tests/test_component_light.py index 098438690..f6f7c30d0 100644 --- a/tests/test_component_light.py +++ b/tests/test_component_light.py @@ -21,6 +21,7 @@ from OWNd.message import ( OWNEvent, OWNLightingEvent, + OWNMessage, ) from custom_components.myhome.const import ( @@ -2361,3 +2362,33 @@ async def test_dimmer_with_preset_frame_is_still_polled(hass): async def test_dimmer_reported_off_is_not_polled(hass): """An off dimmer has no brightness left to ask for, so the sweep's reply is enough (#612).""" assert await _poll_count_after(hass, "*1*0*11##", dimmable=True) == 0 + + +@pytest.mark.parametrize("step", ["*1*30*12##", "*1*31*12##", "*1*30#2#100*12##"]) +def test_dim_step_does_not_flip_point_to_point_light(hass, step): + """A WHAT 30/31 dim step to a light is a relative command, not an "on" state (#367).""" + gateway = MagicMock() + gateway.send = AsyncMock() + gateway.send_status_request = AsyncMock() + light = MyHOMELight( + hass=hass, name="L", entity_name="L", icon=None, icon_on=None, + device_id="12", who="1", where="12", interface=None, dimmable=True, + manufacturer="B", model="M", gateway=gateway, + ) + light.hass = hass + light.async_schedule_update_ha_state = MagicMock() + + assert light.is_on is None + light.handle_event(OWNMessage.parse(step)) + assert light.is_on is None + assert "unknown_state" not in light.extra_state_attributes + + light.handle_event(OWNMessage.parse("*1*0*12##")) + assert light.is_on is False + light.handle_event(OWNMessage.parse(step)) + assert light.is_on is False # a step from 0 % may leave the dimmer off + + light.handle_event(OWNMessage.parse("*1*1*12##")) + assert light.is_on is True + light.handle_event(OWNMessage.parse(step)) + assert light.is_on is True diff --git a/tests/test_issue_368_groups.py b/tests/test_issue_368_groups.py index d77f9d909..4ca06007e 100644 --- a/tests/test_issue_368_groups.py +++ b/tests/test_issue_368_groups.py @@ -707,3 +707,27 @@ async def test_turn_on_hs_brightness_zero_turns_off(hass: HomeAssistant): assert [str(c.args[0]) for c in gateway.send.call_args_list] == ["*1*0*#6##"] assert group.is_on is False + + +async def test_dim_step_does_not_flip_assumed_group(hass: HomeAssistant): + """A WHAT 30/31 dim step to the group is not an "on" for an assumed-state group (#367).""" + gateway = _gateway() + group = _group(hass, gateway) + await group.async_added_to_hass() + + _fire(group, "*1*31*#6##") # dim down: OWNd 2.0.0b11 says is_on True + assert group.is_on is None + _fire(group, "*1*30*#6##") # dim up + assert group.is_on is None + _fire(group, "*1*30#2#100*#6##") # parameterised step + assert group.is_on is None + + _fire(group, "*1*0*#6##") + assert group.is_on is False + _fire(group, "*1*31*#6##") + assert group.is_on is False # a step from off does not switch the group on + + _fire(group, "*1*1*#6##") + assert group.is_on is True + _fire(group, "*1*31*#6##") + assert group.is_on is True # nor does a dim-down switch it off diff --git a/tests/test_issue_368_resync.py b/tests/test_issue_368_resync.py index 13215bf05..9810eed78 100644 --- a/tests/test_issue_368_resync.py +++ b/tests/test_issue_368_resync.py @@ -1065,3 +1065,26 @@ async def test_dim_step_resync_does_not_depend_on_is_on( handler._resync_manager.schedule_resync(msg) assert "#5" in handler._resync_timers handler._resync_manager.cancel_all() + + +@pytest.mark.asyncio +async def test_point_to_point_dim_steps_are_not_member_echoes( + hass: HomeAssistant, handler: MyHOMEGatewayHandler +) -> None: + """`*1*30*##` / `*1*31*##` are commands, not an actuator's status: they neither seed nor cancel a sweep (#367).""" + # Leading dim steps to the members do not skip the group sweep. + await handler._process_message(OWNMessage.parse("*1*31*11##")) + await handler._process_message(OWNMessage.parse("*1*31*12##")) + await handler._process_message(OWNMessage.parse("*1*1*#6##")) + assert "#6" in handler._resync_timers + assert handler._resync_manager.resync_group_echoes["#6"] == 0 + + # Trailing dim steps do not cancel it either; the members' own on/off echoes do. + await handler._process_message(OWNMessage.parse("*1*30*11##")) + await handler._process_message(OWNMessage.parse("*1*30*12##")) + assert "#6" in handler._resync_timers + await handler._process_message(OWNMessage.parse("*1*1*11##")) + await handler._process_message(OWNMessage.parse("*1*1*12##")) + assert "#6" not in handler._resync_timers + await _advance(hass) + handler.send_status_request.assert_not_called() From 0e9ad84c92cd62514385a9e4a971192c66b99888 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:21:01 +0300 Subject: [PATCH 4/9] fix(events): a WHO 2 dimension frame on a scope address fires no stop (#367) The WHO 2 branch had no `is_not_dimension` guard like WHO 1, so a stationary DIMENSION 10 status readback on a scope address (`*#2*#5*10*10*50*0*0##`, `*#2*0*10*10*50*0*0##`, shutterStatus 10 at 50 %) parsed with `is_opening False, is_closing False` and fired `myhome_{group,area,general}_automation_event` with `event: stop`; on WHERE 0 that also ran the gateway's `centralized_shutter_stop` device trigger when nothing had stopped. A dimension frame is never an open/close/stop event; a WHAT outside the table (`is_opening` / `is_closing` None) states nothing either. Command frames `*2*0*0##`, `*2*10#001#1*0##` (advanced stop) fire as before. Tests: the two DIMENSION 10 scope readbacks fire nothing through the dispatcher, and the `centralized_shutter_stop` trigger attached to a real handler fires for `*2*0*0##` and `*2*10#001#1*0##` only. Co-Authored-By: Claude Fable 5.1 --- custom_components/myhome/gateway_events.py | 12 +++++-- tests/test_device_trigger.py | 41 +++++++++++++++++++++- tests/test_gateway.py | 2 ++ 3 files changed, 52 insertions(+), 3 deletions(-) diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index 39e47042d..3a6c6d210 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -346,8 +346,16 @@ async def process_message(self, message: Any) -> None: elif ( isinstance(message, OWNAutomationEvent) and self._is_active_for_who(2) - # Only a frame that states the motion is an open/close/stop event: a - # dimension frame with no known state is none of them (#367). + # Only a command frame that states the motion is an open/close/stop + # event (#367). As for WHO 1, a dimension frame is never one: a + # DIMENSION 10 status readback on a scope address + # (`*#2*#5*10*10*50*0*0##`) is stationary, not a stop, and must not + # fire the gateway's `centralized_shutter_stop` trigger. A WHAT outside + # the table leaves `is_opening` / `is_closing` None and states nothing. + and not ( + isinstance(getattr(message, "dimension", None), int) + and not isinstance(getattr(message, "dimension", None), bool) + ) and (message.is_opening is not None or message.is_closing is not None) ): if message.is_opening and not message.is_closing: diff --git a/tests/test_device_trigger.py b/tests/test_device_trigger.py index d5883b6d5..9a9db740f 100644 --- a/tests/test_device_trigger.py +++ b/tests/test_device_trigger.py @@ -1,5 +1,5 @@ """Tests for MyHOME device triggers.""" -from unittest.mock import AsyncMock, MagicMock +from unittest.mock import AsyncMock, MagicMock, patch import pytest from homeassistant.components.device_automation import ( @@ -9,12 +9,17 @@ from homeassistant.const import ( CONF_DEVICE_ID, CONF_DOMAIN, + CONF_HOST, + CONF_MAC, + CONF_PASSWORD, CONF_PLATFORM, + CONF_PORT, CONF_TYPE, ) from homeassistant.core import Context, HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component +from OWNd.message import OWNMessage from pytest_homeassistant_custom_component.common import MockConfigEntry from custom_components.myhome.const import ( @@ -1235,3 +1240,37 @@ async def test_family_filter_combines_with_gateway_mac_mismatch(hass: HomeAssist await hass.async_block_till_done() action.assert_called_once() unsub() + + +@pytest.mark.asyncio +async def test_centralized_shutter_stop_ignores_dimension_10_status(hass: HomeAssistant): + """A stationary WHO 2 DIMENSION 10 readback on WHERE 0 is not a general stop (#367).""" + from custom_components.myhome.const import CONF_BROADCAST_RESYNC + from custom_components.myhome.gateway import MyHOMEGatewayHandler + + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_HOST: "192.0.2.5", CONF_PORT: 20000, CONF_PASSWORD: "open", CONF_MAC: "00:03:50:00:03:67"}, + options={CONF_BROADCAST_RESYNC: False}, + ) + entry.add_to_hass(hass) + with patch("custom_components.myhome.gateway.OWNGateway"): + handler = MyHOMEGatewayHandler(hass, entry, generate_events=False, broadcast_resync=False) + handler.gateway.serial = "00:03:50:00:03:67" + + action_stop = AsyncMock() + unsub = await async_attach_trigger(hass, {CONF_TYPE: CONF_CENTRALIZED_SHUTTER_STOP}, action_stop, {"trigger_name": "stop"}) + + # Stationary status readbacks: shutterStatus 10 at 50 %, on the general and a group address. + await handler._process_message(OWNMessage.parse("*#2*0*10*10*50*0*0##")) + await handler._process_message(OWNMessage.parse("*#2*#5*10*10*50*0*0##")) + await hass.async_block_till_done() + action_stop.assert_not_called() + + # The general stop and the advanced general stop (issue_445 trace) still trigger. + await handler._process_message(OWNMessage.parse("*2*0*0##")) + await handler._process_message(OWNMessage.parse("*2*10#001#1*0##")) + await hass.async_block_till_done() + assert action_stop.await_count == 2 + assert [c.args[0]["trigger"]["event"]["message"] for c in action_stop.await_args_list] == ["*2*0*0##", "*2*10#001#1*0##"] + unsub() diff --git a/tests/test_gateway.py b/tests/test_gateway.py index c9b270989..1476a7143 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -528,6 +528,8 @@ async def test_scope_events_fire_only_for_state_frames(gateway_handler): "*#2*#5*11*50##", # WHO 2 dimension 11 to group 5 "*#2*3*12*1##", # WHO 2 dimension 12 to area 3 "*#2*0*13*1##", # WHO 2 dimension 13, general + "*#2*#5*10*10*50*0*0##", # WHO 2 DIMENSION 10 status, group 5 stationary at 50 %: not a stop + "*#2*0*10*10*50*0*0##", # WHO 2 DIMENSION 10 status, general, stationary: not a stop "*1*1*#6##", # group 6 on: a real state, still fired "*1*0*#6##", # group 6 off "*2*0*#5##", # group 5 stop: a real state From a49413864391c46674d9a7ee1569ce67233e67ed Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:22:39 +0300 Subject: [PATCH 5/9] test(events): drive the scope-event test with documented WHO 2 frames (#367) The test fed `*#2*3*12*1##` and `*#2*0*13*1##` (WHO 2 has no DIMENSION 12 or 13) and `*#2*#5*11*50##` (read-reply syntax on the write-only DIMENSION 11). It now uses the frames a plant produces: DIMENSION 10 status readbacks on a group and the general address, one malformed DIMENSION 10 frame OWNd hands through unparsed, the advanced scope commands captured in `tests/fixtures/traces/issue_445/` (`*2*10#001#1*0##`, `*2*11#100#001#1*0##`, `*2*12#100#001#1*0##`) and `issue_645/` (`*2*0*100##`), and the parameterised and general WHO 1 dim steps `*1*30#2#100*#5##` / `*1*30*0##`. The exact fired list is pinned: the advanced commands fire general stop / open / close, the area stop and the on/off and open/stop frames fire as before, and nothing fires for the dim steps, the dimension readbacks and the unparsed frame. On the base branch the same test fails with six extra events. Co-Authored-By: Claude Fable 5.1 --- tests/test_gateway.py | 45 +++++++++++++++++++++++++++++-------------- 1 file changed, 31 insertions(+), 14 deletions(-) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 1476a7143..0d3d2c7e2 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -521,24 +521,36 @@ async def test_listening_loop_lighting_unknown_is_on_skips_event(gateway_handler @pytest.mark.asyncio async def test_scope_events_fire_only_for_state_frames(gateway_handler): - """#367: a dim step is not "on" and a WHO 2 dimension frame without a state is not "stop".""" + """#367: a dim step is not "on" and a WHO 2 dimension or unknown frame is not "stop". + + Real frames only: WHO 1 relative dim steps (plain, parameterised and general), + WHO 2 DIMENSION 10 status readbacks (the only readable WHO 2 dimension), one + frame OWNd cannot parse (handed through as the raw string), and the advanced + scope commands captured in `tests/fixtures/traces/issue_445/` (WHAT 10/11/12 on + WHERE 0) and `issue_645/` (`*2*0*100##`). + """ frames = [ - "*1*30*#5##", # group 5 dim up one step - "*1*31*3##", # area 3 dim down one step - "*#2*#5*11*50##", # WHO 2 dimension 11 to group 5 - "*#2*3*12*1##", # WHO 2 dimension 12 to area 3 - "*#2*0*13*1##", # WHO 2 dimension 13, general - "*#2*#5*10*10*50*0*0##", # WHO 2 DIMENSION 10 status, group 5 stationary at 50 %: not a stop - "*#2*0*10*10*50*0*0##", # WHO 2 DIMENSION 10 status, general, stationary: not a stop - "*1*1*#6##", # group 6 on: a real state, still fired - "*1*0*#6##", # group 6 off - "*2*0*#5##", # group 5 stop: a real state - "*2*1*3##", # area 3 open + OWNMessage.parse("*1*30*#5##"), # group 5 dim up one step + OWNMessage.parse("*1*31*3##"), # area 3 dim down one step + OWNMessage.parse("*1*30#2#100*#5##"), # group 5 dim up, 2 levels at speed 100 + OWNMessage.parse("*1*30*0##"), # general dim up + OWNMessage.parse("*#2*#5*10*10*50*0*0##"), # DIMENSION 10 status, group 5 stationary at 50 % + OWNMessage.parse("*#2*0*10*10*50*0*0##"), # DIMENSION 10 status, general, stationary + "*#2*0*10*xx*50*0*0##", # malformed DIMENSION 10 status: not an OWNMessage at all + OWNMessage.parse("*2*10#001#1*0##"), # advanced general stop (issue_445) + OWNMessage.parse("*2*11#100#001#1*0##"), # advanced general up (issue_445) + OWNMessage.parse("*2*12#100#001#1*0##"), # advanced general down (issue_445) + OWNMessage.parse("*2*0*100##"), # area 10 stop (issue_645) + OWNMessage.parse("*1*1*#6##"), # group 6 on: a real state, still fired + OWNMessage.parse("*1*0*#6##"), # group 6 off + OWNMessage.parse("*2*0*#5##"), # group 5 stop: a real state + OWNMessage.parse("*2*1*3##"), # area 3 open ] + assert OWNMessage.parse(frames[6]) is None with patch("custom_components.myhome.gateway.OWNEventSession") as mock_session_class: mock_session = MagicMock() mock_session.connect = AsyncMock(return_value={"Success": True}) - mock_session.get_next = AsyncMock(side_effect=[OWNMessage.parse(f) for f in frames] + [asyncio.CancelledError()]) + mock_session.get_next = AsyncMock(side_effect=[*frames, asyncio.CancelledError()]) mock_session_class.return_value = mock_session gateway_handler.send_status_request = AsyncMock() try: @@ -552,13 +564,18 @@ async def test_scope_events_fire_only_for_state_frames(gateway_handler): if call.args[0].endswith(("_light_event", "_automation_event")) ] assert fired == [ + ("myhome_general_automation_event", "*2*10#001#1*0##", "stop"), + ("myhome_general_automation_event", "*2*11#100#001#1*0##", "open"), + ("myhome_general_automation_event", "*2*12#100#001#1*0##", "close"), + ("myhome_area_automation_event", "*2*0*100##", "stop"), ("myhome_group_light_event", "*1*1*#6##", "on"), ("myhome_group_light_event", "*1*0*#6##", "off"), ("myhome_group_automation_event", "*2*0*#5##", "stop"), ("myhome_area_automation_event", "*2*1*3##", "open"), ] # The dim steps still schedule the member re-sync of group 5 and area 3, with no - # expected polarity: a step says nothing about where the members end up. + # expected polarity: a step says nothing about where the members end up. The + # general step sweeps the known light areas, none in this bare handler. assert sorted(gateway_handler._resync_timers) == ["#5", "#6", "3"] assert gateway_handler._resync_manager._resync_expected_on == {"#5": None, "3": None, "#6": False} From 828468b1f270d3d5f152e156a0607bc7ede40f49 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sat, 24 Oct 2026 23:23:45 +0300 Subject: [PATCH 6/9] docs(covers): document the WHO 2 scope bus events (#367) The three `myhome_{general,area,group}_automation_event` events were described only inside one long sentence of the lighting page, where a cover user does not look. The cover docs now have a "Bus Events for Scope Commands" section: addresses and payload, the command frames that fire (`WHAT 0/1/2` and the advanced `WHAT 10/11/12` from the issue_445 captures), the frames that do not (DIMENSION 10 status readbacks, dimension writes, a WHAT outside the table), the link to the gateway's centralized shutter triggers and an example automation. The lighting paragraph keeps its own three events and links across. Release note: names `myhome_general_light_event` with the other two, mentions the entity and re-sync fixes, and uses a valid DIMENSION 10 frame (`*#2*#5*10*10*50*0*0##`) as the WHO 2 example. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 2 +- docs/configuration/covers.md | 33 +++++++++++++++++++++++++++++++ docs/configuration/lights.md | 2 +- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index f204432c0..2d7fa41ac 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -22,7 +22,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). - **`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). -- **Group, area and general bus events only for frames that carry a state.** A dim step (`*1*30*#1##` / `*1*31*#1##`) no longer fires `myhome_group_light_event` / `myhome_area_light_event` as `on`, and a WHO 2 dimension frame to a group, area or general address (`*#2*#5*11*50##`) no longer fires an automation event as `stop`; on/off, open/close/stop frames fire as before (#367). +- **Group, area and general bus events only for frames that carry a state.** A relative dim step (`*1*30*#1##` / `*1*31*#1##`, `*1*30#2#100*#1##`) no longer fires `myhome_group_light_event` / `myhome_area_light_event` / `myhome_general_light_event` as `on`, no longer switches an assumed-state light group or a point-to-point light on, and its member re-sync accepts both on and off echoes; a WHO 2 DIMENSION 10 status readback on a group, area or general address (`*#2*#5*10*10*50*0*0##`) no longer fires `myhome_group_automation_event` / `myhome_area_automation_event` / `myhome_general_automation_event` as `stop` (nor the gateway's *Centralized shutter stop* trigger). On/off and open/close/stop command frames, including the advanced `WHAT 10` / `11` / `12`, fire as before; the WHO 2 scope events are now documented in the cover docs (#367). - **`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). #### Other changes diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index 62aedcc06..f6311060e 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -286,6 +286,39 @@ The MyHOME integration implements a dual-layer architecture adhering to both Hom 3. **Keypad Broadcast Reflection**: A general or area command from a physical wall switch or scenario unit also moves the individual covers in Home Assistant. A group or area cover whose shutters are not known yet ends its run after its own `travel_time`, so it never stays stuck on *opening* ([#433](https://github.com/OpenWebNet-HA/MyHOME/issues/433)). +### 📡 Bus Events for Scope Commands (`myhome_*_automation_event`) + +A WHO 2 command heard on a **general**, **area** or **group** address — from a keypad, a scenario unit or Home Assistant itself — is mirrored on the Home Assistant event bus, so an automation can react to the physical button without owning a cover entity: + +| Address | Event | Extra payload | +| :--- | :--- | :--- | +| General `0` (also `0#4#`) | `myhome_general_automation_event` | — | +| Area `1`…`9`, `00`, `100` | `myhome_area_automation_event` | `area` | +| Group `#1`…`#255` | `myhome_group_automation_event` | `group` | + +Every payload carries `message` (the raw frame), `event` (`open`, `close` or `stop`), `where`, `gateway_mac` and `entry_id`. The general event also drives the gateway device triggers *Centralized shutter open / close / stop*. + +Only a **command frame that states a motion** fires an event: the standard `WHAT 0` / `1` / `2` (`*2*0*0##`, `*2*1*3##`, `*2*2*#5##`) and the advanced `WHAT 10` / `11` / `12` with their parameters (`*2*10#001#1*0##` stop, `*2*11#100#001#1*0##` up, `*2*12#100#001#1*0##` down, as captured from a MyHomeServer1 + LN4660M2 keypad). Nothing fires for: + +* a **DIMENSION 10 status readback** on a scope address (`*#2*0*10*10*50*0*0##`, `*#2*#5*10*10*50*0*0##`): the shutter is stationary at 50 %, nothing was stopped; +* a dimension write such as the Dimension 11 level (`*#2*#5*#11#001*50##`, see [Protocol Note: Dimension 11 is Shutter Level](#protocol-note-dimension-11-is-shutter-level)); +* a `WHAT` outside the WHO 2 table, which states no motion. + +```yaml +alias: "Pause the media player on a general shutter stop" +trigger: + - platform: event + event_type: myhome_general_automation_event + event_data: + event: stop +action: + - service: media_player.media_pause + target: + entity_id: media_player.living_room +``` + +The lighting counterparts (`myhome_general_light_event`, `myhome_area_light_event`, `myhome_group_light_event`) are described under [Synchronizing Physical Wall Switches](lights.md#synchronizing-physical-wall-switches-scs-group-buttons). + ### 💡 Automation Best Practices: The Home Assistant & BTicino Way To ensure maximum reliability and preserve bus health: diff --git a/docs/configuration/lights.md b/docs/configuration/lights.md index 5e2d6e188..0fe77f96b 100644 --- a/docs/configuration/lights.md +++ b/docs/configuration/lights.md @@ -95,7 +95,7 @@ groups: --- ### Synchronizing Physical Wall Switches (SCS Group Buttons) -If you have physical BTicino wall switches configured to trigger an SCS group (e.g. `WHERE = #1`), the integration automatically dispatches a bus event named `myhome_group_light_event` whenever a group frame with a state (`on` / `off`) is intercepted on the SCS bus. A dim step to the group (`*1*30*#1##` / `*1*31*#1##`) fires no event, as it does not say whether the group is on; the same holds for the area and general events and for the WHO 2 automation events (`myhome_group_automation_event`, `myhome_area_automation_event`, `myhome_general_automation_event`), which fire for `open` / `close` / `stop` frames only. You can keep your Home Assistant Light Group perfectly synchronized with a simple automation: +If you have physical BTicino wall switches configured to trigger an SCS group (e.g. `WHERE = #1`), the integration automatically dispatches a bus event named `myhome_group_light_event` whenever a group frame with a state (`on` / `off`) is intercepted on the SCS bus; `myhome_area_light_event` and `myhome_general_light_event` do the same for an area and the general address. A relative dim step to the scope (`*1*30*#1##` / `*1*31*#1##`, also `*1*30#2#100*#1##`) fires no event, as it does not say whether the lights are on; it only triggers the member re-sync. The WHO 2 counterparts for shutters are described under [Bus Events for Scope Commands](covers.md#bus-events-for-scope-commands-myhome__automation_event). You can keep your Home Assistant Light Group perfectly synchronized with a simple automation: ```yaml alias: "Sync MyHOME Group 1 Wall Switch" From 9ebde27fe3d9373de9197b698b44d1d3b390d6ba Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 13:02:53 +0200 Subject: [PATCH 7/9] test(events): cite the firmware oracle for the area 10 stop frame (#367) The `issue_645/` burst trace reconstructed ten redacted point-to-point status requests as WHERE 91..100, so its `*#2*100##` -> `*2*0*100##` pair is a status reply on a reconstructed address, not a captured area 10 stop. The scope-event test now cites the MyHomeServer1 028206 firmware oracle replay of `down *2*0*100##` (`bus:$04B30A120A`, `own:*2*0*100##`) for that frame instead; the frame and the pinned `myhome_area_automation_event` `stop` are unchanged. Co-Authored-By: Claude Fable 5.1 --- tests/test_gateway.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 0d3d2c7e2..d93c2fd19 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -527,7 +527,9 @@ async def test_scope_events_fire_only_for_state_frames(gateway_handler): WHO 2 DIMENSION 10 status readbacks (the only readable WHO 2 dimension), one frame OWNd cannot parse (handed through as the raw string), and the advanced scope commands captured in `tests/fixtures/traces/issue_445/` (WHAT 10/11/12 on - WHERE 0) and `issue_645/` (`*2*0*100##`). + WHERE 0), and the area 10 stop `*2*0*100##` as the MyHomeServer1 028206 + firmware oracle replays it (`down *2*0*100##` -> `bus:$04B30A120A` and + `own:*2*0*100##` on the event session; no community capture carries it). """ frames = [ OWNMessage.parse("*1*30*#5##"), # group 5 dim up one step @@ -540,7 +542,7 @@ async def test_scope_events_fire_only_for_state_frames(gateway_handler): OWNMessage.parse("*2*10#001#1*0##"), # advanced general stop (issue_445) OWNMessage.parse("*2*11#100#001#1*0##"), # advanced general up (issue_445) OWNMessage.parse("*2*12#100#001#1*0##"), # advanced general down (issue_445) - OWNMessage.parse("*2*0*100##"), # area 10 stop (issue_645) + OWNMessage.parse("*2*0*100##"), # area 10 stop (MyHomeServer1 028206 oracle replay) OWNMessage.parse("*1*1*#6##"), # group 6 on: a real state, still fired OWNMessage.parse("*1*0*#6##"), # group 6 off OWNMessage.parse("*2*0*#5##"), # group 5 stop: a real state From 9d6547ea0c466064cb422e2470e7a1a4b6f1bfc8 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 13:04:19 +0200 Subject: [PATCH 8/9] test(events): replay the live F461 general dim step capture (#367) Live F461 2.0.11 capture of 2026-10-25 (plain OWNd master as sender, event session recorded): `*1*30*0##` (general dim up, no dimmer on the plant) was acknowledged in 69 ms, echoed on the event session and followed by every point re-reporting its own state twice over about two seconds (`*1*0*06##`, `*1*0*07##`, `*1*0*09##`, `*2*0*05##`); `*1*31*#1##` (group 1 dim down) was acknowledged in 62 ms, echoed, and followed by `*1*0*06##` only. The new test replays that sequence through `listening_loop` and pins that no scope light or automation event fires, that every frame reaches the point-to-point dispatch once in bus order, that both dim steps reach the member re-sync, and that the leading point reports satisfy the group 1 sweep before it is armed. On the base branch it fails with two extra events (`myhome_general_light_event` and `myhome_group_light_event` as `on`). The capture is on file and will be ingested by a trace PR. Co-Authored-By: Claude Fable 5.1 --- tests/test_gateway.py | 61 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/tests/test_gateway.py b/tests/test_gateway.py index d93c2fd19..2c3e6ae1e 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -582,6 +582,67 @@ async def test_scope_events_fire_only_for_state_frames(gateway_handler): assert gateway_handler._resync_manager._resync_expected_on == {"#5": None, "3": None, "#6": False} +@pytest.mark.asyncio +async def test_general_dim_step_point_reports_fire_no_scope_events(gateway_handler): + """Replay the live F461 2.0.11 capture of a general dim step (#367, 2026-10-25). + + `*1*30*0##` (general dim up; the plant has no dimmer) was acknowledged in 69 ms + and echoed on the event session, after which every point re-reported its own + state twice over about two seconds: `*1*0*06##`, `*1*0*07##`, `*1*0*09##` and + `*2*0*05##`. `*1*31*#1##` (group 1 dim down) was acknowledged in 62 ms, echoed, + and followed by `*1*0*06##` only. A dim step is not an "on" and a point report + is a point event: none of these frames is a scope event. Both dim steps still + reach the member re-sync: the general step sweeps the known light areas (none + in this bare handler) and the group step finds that lights 06, 07 and 09 have + just spoken, so no sweep is pending afterwards. Capture on file, to be ingested + by a trace PR. + """ + raw = [ + "*1*30*0##", # general dim up, echoed on the event session + "*2*0*05##", # every point re-reports its state: cover 05 stopped + "*1*0*06##", # light 06 off + "*1*0*07##", # light 07 off + "*1*0*09##", # light 09 off + "*1*0*06##", # second round of the same reports + "*2*0*05##", + "*1*0*07##", + "*1*0*09##", + "*1*31*#1##", # group 1 dim down, echoed on the event session + "*1*0*06##", # the only report that followed it + ] + frames = [OWNMessage.parse(f) for f in raw] + assert all(frame is not None for frame in frames) + gateway_handler._schedule_resync = MagicMock(wraps=gateway_handler._schedule_resync) + with ( + patch("custom_components.myhome.gateway.OWNEventSession") as mock_session_class, + patch("custom_components.myhome.gateway.async_dispatcher_send") as send, + ): + mock_session = MagicMock() + mock_session.connect = AsyncMock(return_value={"Success": True}) + mock_session.get_next = AsyncMock(side_effect=[*frames, asyncio.CancelledError()]) + mock_session_class.return_value = mock_session + gateway_handler.send_status_request = AsyncMock() + try: + await gateway_handler.listening_loop() + except asyncio.CancelledError: + pass + + scope_events = [ + call.args[0] + for call in gateway_handler.hass.bus.async_fire.call_args_list + if call.args[0].endswith(("_light_event", "_automation_event")) + ] + assert scope_events == [] + # Every frame reached the point-to-point dispatch exactly once, in bus order. + assert [str(call.args[2]) for call in send.call_args_list if call.args[1] == f"myhome_message_{gateway_handler.mac}"] == raw + assert [str(call.args[0]) for call in gateway_handler._schedule_resync.call_args_list] == ["*1*30*0##", "*1*31*#1##"] + # The point reports that preceded the group step count as its leading member + # echoes (two are enough for a group of unknown membership), so the `#1` sweep + # is satisfied before it is armed; the general step has no known area to sweep. + assert gateway_handler._resync_timers == {} + assert gateway_handler._resync_manager._resync_expected_on == {} + + @pytest.mark.asyncio async def test_listening_loop_automation(gateway_handler): with patch("custom_components.myhome.gateway.OWNEventSession") as mock_session_class: From ef3417ded615b4f557ca9cc30f5dca28dde54b25 Mon Sep 17 00:00:00 2001 From: gdluck Date: Sun, 25 Oct 2026 13:05:33 +0200 Subject: [PATCH 9/9] docs(covers): frame the WHO 2 scope dimension guard as a parser invariant (#367) A physical shutter reports DIMENSION 10 on its own point-to-point address, never on a group, area or general WHERE (issue_445 captures: one `*#2**10*...##` per actuator after a scope command). The `is_not_dimension` guard on the WHO 2 scope events is therefore a defensive parser invariant symmetrical with WHO 1, not the handling of a routine readback: the dispatcher comment, the cover docs and the b16 release note now say so, and no longer describe `*#2*#5*10*...##` as "the shutter is stationary at 50 %". The test frames use the 3-digit priority an F461 / MyHomeServer1 encodes (`*#2*#5*10*10*50*001*0##`, `*#2*0*10*10*50*001*0##`), and the real event-session echoes of a scope DIMENSION 11 level write (`*#2*#5*#11#001#1*50##`, `*#2*0*#11#001#1*50##`) join `test_scope_events_fire_only_for_state_frames` and the centralized stop trigger test, so both OWNd dimension paths are pinned: plain OWNd master parses the DIMENSION 11 echoes as `OWNAutomationCommand` (dimension 11, never reaching the WHO 2 event branch) and the scope DIMENSION 10 frames as `OWNAutomationEvent` (dimension 10, `is_opening` / `is_closing` False), which the guard keeps away from `stop`. Co-Authored-By: Claude Fable 5.1 --- .github/release-notes/2.0.0b16.md | 2 +- custom_components/myhome/gateway_events.py | 9 +++++--- docs/configuration/covers.md | 4 ++-- tests/test_device_trigger.py | 16 +++++++++++---- tests/test_gateway.py | 24 ++++++++++++++++------ 5 files changed, 39 insertions(+), 16 deletions(-) diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 2d7fa41ac..f85a52761 100644 --- a/.github/release-notes/2.0.0b16.md +++ b/.github/release-notes/2.0.0b16.md @@ -22,7 +22,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). - **`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). -- **Group, area and general bus events only for frames that carry a state.** A relative dim step (`*1*30*#1##` / `*1*31*#1##`, `*1*30#2#100*#1##`) no longer fires `myhome_group_light_event` / `myhome_area_light_event` / `myhome_general_light_event` as `on`, no longer switches an assumed-state light group or a point-to-point light on, and its member re-sync accepts both on and off echoes; a WHO 2 DIMENSION 10 status readback on a group, area or general address (`*#2*#5*10*10*50*0*0##`) no longer fires `myhome_group_automation_event` / `myhome_area_automation_event` / `myhome_general_automation_event` as `stop` (nor the gateway's *Centralized shutter stop* trigger). On/off and open/close/stop command frames, including the advanced `WHAT 10` / `11` / `12`, fire as before; the WHO 2 scope events are now documented in the cover docs (#367). +- **Group, area and general bus events only for frames that carry a state.** A relative dim step (`*1*30*#1##` / `*1*31*#1##`, `*1*30#2#100*#1##`) no longer fires `myhome_group_light_event` / `myhome_area_light_event` / `myhome_general_light_event` as `on`, no longer switches an assumed-state light group or a point-to-point light on, and its member re-sync accepts both on and off echoes; a WHO 2 `DIMENSION 10` frame on a group, area or general address (`*#2*#5*10*10*50*001*0##`) no longer fires `myhome_group_automation_event` / `myhome_area_automation_event` / `myhome_general_automation_event` as `stop` (nor the gateway's *Centralized shutter stop* trigger). This is a defensive guard matching WHO 1, not a readback a shutter sends: a physical shutter reports `DIMENSION 10` on its own point-to-point address, and the Dimension 11 level echo a gateway really puts on the event session (`*#2*#5*#11#001#1*50##`) never fired an event. On/off and open/close/stop command frames, including the advanced `WHAT 10` / `11` / `12`, fire as before; the WHO 2 scope events are now documented in the cover docs (#367). - **`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). #### Other changes diff --git a/custom_components/myhome/gateway_events.py b/custom_components/myhome/gateway_events.py index 3a6c6d210..2d8a74bf3 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -347,9 +347,12 @@ async def process_message(self, message: Any) -> None: isinstance(message, OWNAutomationEvent) and self._is_active_for_who(2) # Only a command frame that states the motion is an open/close/stop - # event (#367). As for WHO 1, a dimension frame is never one: a - # DIMENSION 10 status readback on a scope address - # (`*#2*#5*10*10*50*0*0##`) is stationary, not a stop, and must not + # event (#367). As for WHO 1, a dimension frame is never one. This is + # a defensive parser invariant, not a readback actuators send: a real + # shutter reports DIMENSION 10 on its own point-to-point address, never + # on a group, area or general WHERE. OWNd still parses a grammatically + # valid scope DIMENSION 10 frame (`*#2*#5*10*10*50*001*0##`) with + # `is_opening` / `is_closing` False, which must not become a stop nor # fire the gateway's `centralized_shutter_stop` trigger. A WHAT outside # the table leaves `is_opening` / `is_closing` None and states nothing. and not ( diff --git a/docs/configuration/covers.md b/docs/configuration/covers.md index f6311060e..6e6345441 100644 --- a/docs/configuration/covers.md +++ b/docs/configuration/covers.md @@ -300,8 +300,8 @@ Every payload carries `message` (the raw frame), `event` (`open`, `close` or `st Only a **command frame that states a motion** fires an event: the standard `WHAT 0` / `1` / `2` (`*2*0*0##`, `*2*1*3##`, `*2*2*#5##`) and the advanced `WHAT 10` / `11` / `12` with their parameters (`*2*10#001#1*0##` stop, `*2*11#100#001#1*0##` up, `*2*12#100#001#1*0##` down, as captured from a MyHomeServer1 + LN4660M2 keypad). Nothing fires for: -* a **DIMENSION 10 status readback** on a scope address (`*#2*0*10*10*50*0*0##`, `*#2*#5*10*10*50*0*0##`): the shutter is stationary at 50 %, nothing was stopped; -* a dimension write such as the Dimension 11 level (`*#2*#5*#11#001*50##`, see [Protocol Note: Dimension 11 is Shutter Level](#protocol-note-dimension-11-is-shutter-level)); +* **any WHO 2 dimension frame**, as for the WHO 1 light events: a Dimension 11 level write and its event-session echo (`*#2*#5*#11#001*50##`, echoed by an F461 / MyHomeServer1 as `*#2*#5*#11#001#1*50##`; see [Protocol Note: Dimension 11 is Shutter Level](#protocol-note-dimension-11-is-shutter-level)) sets a level, it does not state a motion; +* a **`DIMENSION 10` status frame on a scope address** (`*#2*#5*10*10*50*001*0##`, `*#2*0*10*10*50*001*0##`), guarded defensively: a physical shutter reports its `DIMENSION 10` status on its own point-to-point address, never on a group, area or general one, so such a frame is parsed but never read as a stop; * a `WHAT` outside the WHO 2 table, which states no motion. ```yaml diff --git a/tests/test_device_trigger.py b/tests/test_device_trigger.py index 9a9db740f..72b450a99 100644 --- a/tests/test_device_trigger.py +++ b/tests/test_device_trigger.py @@ -1244,7 +1244,13 @@ async def test_family_filter_combines_with_gateway_mac_mismatch(hass: HomeAssist @pytest.mark.asyncio async def test_centralized_shutter_stop_ignores_dimension_10_status(hass: HomeAssistant): - """A stationary WHO 2 DIMENSION 10 readback on WHERE 0 is not a general stop (#367).""" + """A WHO 2 dimension frame on a scope address is not a general stop (#367). + + A physical shutter reports DIMENSION 10 on its own point-to-point address, so a + scope DIMENSION 10 frame is guarded defensively (3-digit priority as an F461 / + MyHomeServer1 encodes it); the DIMENSION 11 level echo is what such a gateway + really puts on the event session for a scope level write. + """ from custom_components.myhome.const import CONF_BROADCAST_RESYNC from custom_components.myhome.gateway import MyHOMEGatewayHandler @@ -1261,9 +1267,11 @@ async def test_centralized_shutter_stop_ignores_dimension_10_status(hass: HomeAs action_stop = AsyncMock() unsub = await async_attach_trigger(hass, {CONF_TYPE: CONF_CENTRALIZED_SHUTTER_STOP}, action_stop, {"trigger_name": "stop"}) - # Stationary status readbacks: shutterStatus 10 at 50 %, on the general and a group address. - await handler._process_message(OWNMessage.parse("*#2*0*10*10*50*0*0##")) - await handler._process_message(OWNMessage.parse("*#2*#5*10*10*50*0*0##")) + # Scope DIMENSION 10 frames (defensive guard) and the real DIMENSION 11 level echoes. + await handler._process_message(OWNMessage.parse("*#2*0*10*10*50*001*0##")) + await handler._process_message(OWNMessage.parse("*#2*#5*10*10*50*001*0##")) + await handler._process_message(OWNMessage.parse("*#2*0*#11#001#1*50##")) + await handler._process_message(OWNMessage.parse("*#2*#5*#11#001#1*50##")) await hass.async_block_till_done() action_stop.assert_not_called() diff --git a/tests/test_gateway.py b/tests/test_gateway.py index 2c3e6ae1e..7e6ba904f 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -524,8 +524,14 @@ async def test_scope_events_fire_only_for_state_frames(gateway_handler): """#367: a dim step is not "on" and a WHO 2 dimension or unknown frame is not "stop". Real frames only: WHO 1 relative dim steps (plain, parameterised and general), - WHO 2 DIMENSION 10 status readbacks (the only readable WHO 2 dimension), one - frame OWNd cannot parse (handed through as the raw string), and the advanced + the DIMENSION 11 level echoes an F461 / MyHomeServer1 puts on the event session + for a scope level write (OWNd parses them as `OWNAutomationCommand`), WHO 2 + DIMENSION 10 frames on a scope address in the gateway's 3-digit priority + format (a physical shutter reports DIMENSION 10 on its own point-to-point + address only, so this is the defensive guard; OWNd parses them as + `OWNAutomationEvent` with `is_opening` / `is_closing` False), one frame OWNd + cannot parse (handed through as the raw string), one WHAT outside the WHO 2 + table (`*2*7*0##`, `is_opening` / `is_closing` None), and the advanced scope commands captured in `tests/fixtures/traces/issue_445/` (WHAT 10/11/12 on WHERE 0), and the area 10 stop `*2*0*100##` as the MyHomeServer1 028206 firmware oracle replays it (`down *2*0*100##` -> `bus:$04B30A120A` and @@ -536,9 +542,12 @@ async def test_scope_events_fire_only_for_state_frames(gateway_handler): OWNMessage.parse("*1*31*3##"), # area 3 dim down one step OWNMessage.parse("*1*30#2#100*#5##"), # group 5 dim up, 2 levels at speed 100 OWNMessage.parse("*1*30*0##"), # general dim up - OWNMessage.parse("*#2*#5*10*10*50*0*0##"), # DIMENSION 10 status, group 5 stationary at 50 % - OWNMessage.parse("*#2*0*10*10*50*0*0##"), # DIMENSION 10 status, general, stationary - "*#2*0*10*xx*50*0*0##", # malformed DIMENSION 10 status: not an OWNMessage at all + OWNMessage.parse("*#2*#5*#11#001#1*50##"), # DIMENSION 11 level echo, group 5 (OWNAutomationCommand) + OWNMessage.parse("*#2*0*#11#001#1*50##"), # DIMENSION 11 level echo, general (OWNAutomationCommand) + OWNMessage.parse("*#2*#5*10*10*50*001*0##"), # DIMENSION 10 on group 5: defensive guard (OWNAutomationEvent) + OWNMessage.parse("*#2*0*10*10*50*001*0##"), # DIMENSION 10 on general: defensive guard (OWNAutomationEvent) + "*#2*0*10*xx*50*001*0##", # malformed DIMENSION 10 frame: not an OWNMessage at all + OWNMessage.parse("*2*7*0##"), # WHAT outside the WHO 2 table: is_opening / is_closing None OWNMessage.parse("*2*10#001#1*0##"), # advanced general stop (issue_445) OWNMessage.parse("*2*11#100#001#1*0##"), # advanced general up (issue_445) OWNMessage.parse("*2*12#100#001#1*0##"), # advanced general down (issue_445) @@ -548,7 +557,10 @@ async def test_scope_events_fire_only_for_state_frames(gateway_handler): OWNMessage.parse("*2*0*#5##"), # group 5 stop: a real state OWNMessage.parse("*2*1*3##"), # area 3 open ] - assert OWNMessage.parse(frames[6]) is None + assert OWNMessage.parse(frames[8]) is None + assert [type(f).__name__ for f in frames[4:8]] == ["OWNAutomationCommand"] * 2 + ["OWNAutomationEvent"] * 2 + assert [f.dimension for f in frames[4:8]] == [11, 11, 10, 10] + assert (frames[9].is_opening, frames[9].is_closing) == (None, None) with patch("custom_components.myhome.gateway.OWNEventSession") as mock_session_class: mock_session = MagicMock() mock_session.connect = AsyncMock(return_value={"Success": True})