Skip to content
Merged
1 change: 1 addition & 0 deletions .github/release-notes/2.0.0b16.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
36 changes: 31 additions & 5 deletions custom_components/myhome/gateway_events.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment thread
GreenGrassBlueOcean marked this conversation as resolved.
elif message.is_general:
self.hass.bus.async_fire(
"myhome_general_light_event",
{"message": str(message), "event": event},
Expand Down Expand Up @@ -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)
):
Comment thread
GreenGrassBlueOcean marked this conversation as resolved.
if message.is_opening and not message.is_closing:
event = "open"
elif message.is_closing and not message.is_opening:
Expand Down
14 changes: 10 additions & 4 deletions custom_components/myhome/gateway_resync.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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())
Expand Down Expand Up @@ -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
Expand Down
4 changes: 3 additions & 1 deletion custom_components/myhome/light.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions custom_components/myhome/light_group.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
33 changes: 33 additions & 0 deletions docs/configuration/covers.md
Original file line number Diff line number Diff line change
Expand Up @@ -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#<interface>`) | `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:
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration/lights.md
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
31 changes: 31 additions & 0 deletions tests/test_component_light.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from OWNd.message import (
OWNEvent,
OWNLightingEvent,
OWNMessage,
)

from custom_components.myhome.const import (
Expand Down Expand Up @@ -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
49 changes: 48 additions & 1 deletion tests/test_device_trigger.py
Original file line number Diff line number Diff line change
@@ -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 (
Expand All @@ -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 (
Expand Down Expand Up @@ -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()
Loading
Loading