diff --git a/.github/release-notes/2.0.0b16.md b/.github/release-notes/2.0.0b16.md index 6632155f..f85a5276 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 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 10916c05..2d8a74bf 100644 --- a/custom_components/myhome/gateway_events.py +++ b/custom_components/myhome/gateway_events.py @@ -294,18 +294,27 @@ 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) is_not_dimension = not (isinstance(dim, int) and not isinstance(dim, bool)) - if message.is_on is not None and is_not_dimension: + # 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 or is_dim_step) 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 +343,24 @@ 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 command frame that states the motion is an open/close/stop + # 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 ( + 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: event = "open" elif message.is_closing and not message.is_opening: diff --git a/custom_components/myhome/gateway_resync.py b/custom_components/myhome/gateway_resync.py index 0788aebb..09ad1673 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/custom_components/myhome/light.py b/custom_components/myhome/light.py index 2c32e751..e110d0b8 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 d6efd6f5..674ceaba 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/docs/configuration/covers.md b/docs/configuration/covers.md index 62aedcc0..6e634544 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: + +* **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 +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 426e7bce..0fe77f96 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; `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" diff --git a/tests/test_component_light.py b/tests/test_component_light.py index 09843869..f6f7c30d 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_device_trigger.py b/tests/test_device_trigger.py index d5883b6d..72b450a9 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,45 @@ 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 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 + + 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"}) + + # 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() + + # 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 11f17a4e..7e6ba904 100644 --- a/tests/test_gateway.py +++ b/tests/test_gateway.py @@ -519,6 +519,142 @@ 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 or unknown frame is not "stop". + + Real frames only: WHO 1 relative dim steps (plain, parameterised and general), + 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 + `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 + 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*#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) + 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 + OWNMessage.parse("*2*1*3##"), # area 3 open + ] + 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}) + 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 + + 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_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. 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} + + +@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: diff --git a/tests/test_issue_368_groups.py b/tests/test_issue_368_groups.py index d77f9d90..4ca06007 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 6cadfa51..9810eed7 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,111 @@ 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() + + +@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()