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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 82 additions & 18 deletions custom_components/myhome/gateway_resync.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
RESYNC_DEBOUNCE_S,
RESYNC_LEADING_WINDOW_S,
area_of_where,
normalize_where,
)
from .discovery import Address, parse_unique_id

Expand Down Expand Up @@ -48,6 +49,8 @@ def __init__(
self._recent_ptp: collections.deque[tuple[float, str, str | None]] = (
recent_ptp if recent_ptp is not None else collections.deque()
)
self._ptp_polarities: dict[tuple[float, str], bool | None] = {}
self._resync_area_echoes: dict[str, set[str]] = {}

@property
def hass(self) -> HomeAssistant:
Expand Down Expand Up @@ -75,30 +78,41 @@ def recent_ptp(self) -> collections.deque[tuple[float, str, str | None]]:
"""Return recent point-to-point frame deque."""
return self._recent_ptp

def _prune_recent_ptp(self, now: float) -> None:
"""Prune expired point-to-point echo records older than the leading window."""
while self._recent_ptp and self._recent_ptp[0][0] < now - RESYNC_LEADING_WINDOW_S:
old_ts, old_where, _ = self._recent_ptp.popleft()
self._ptp_polarities.pop((old_ts, old_where), None)

def handle_ptp_echo(self, message: OWNLightingEvent) -> None:
"""Handle point-to-point lighting message echo cancellation."""
now = float(self._time.monotonic())
while self._recent_ptp and self._recent_ptp[0][0] < now - RESYNC_LEADING_WINDOW_S:
self._recent_ptp.popleft()
area = area_of_where(message.where)
self._recent_ptp.append((now, str(message.where), area))
self._prune_recent_ptp(now)
clean_where = normalize_where(str(message.where).split("#", 1)[0])
area = area_of_where(clean_where)
self._recent_ptp.append((now, clean_where, area))
self._ptp_polarities[(now, clean_where)] = getattr(message, "is_on", None)

if area and area in self._resync_timers:
LOGGER.debug("%s area %s echoed point status, cancelling sweep", self.handler.log_id, area)
self._resync_timers.pop(area)()
echoed = self._resync_area_echoes.setdefault(area, set())
echoed.add(clean_where)
if self._area_fully_echoed(area, echoed):
LOGGER.debug("%s area %s echoed point status, cancelling sweep", self.handler.log_id, area)
self._resync_timers.pop(area)()
self._resync_area_echoes.pop(area, None)
for g in [k for k in self._resync_timers if k.startswith("#")]:
self._resync_group_echoes[g] = self._resync_group_echoes.get(g, 0) + 1
if self._resync_group_echoes[g] >= 2:
LOGGER.debug("%s group %s saw member echoes, cancelling sweep", self.handler.log_id, g)
self._resync_timers.pop(g)()
self._resync_group_echoes.pop(g, None)

def known_light_areas(self) -> list[str]:
"""Return sorted list of configured light/switch areas."""
areas: set[str] = set()
def _known_area_map(self) -> dict[str, set[str]]:
"""Return mapping of area code to registered light/switch WHERE addresses."""
area_map: dict[str, set[str]] = {}
config_entry = getattr(self.handler, "config_entry", None)
if not config_entry or not hasattr(config_entry, "entry_id") or not isinstance(config_entry.entry_id, str):
return []
return area_map

registry = er.async_get(self.hass)
entries = er.async_entries_for_config_entry(registry, config_entry.entry_id)
Expand All @@ -108,28 +122,69 @@ def known_light_areas(self) -> list[str]:
if not key:
continue
address = Address.from_device_id(key)
area = area_of_where(address.where)
clean_where = normalize_where(address.clean_where)
area = area_of_where(clean_where)
if area:
areas.add(area)
return sorted(list(areas))
area_map.setdefault(area, set()).add(clean_where)
return area_map

def known_light_areas(self) -> list[str]:
"""Return sorted list of configured light/switch areas."""
return sorted(self._known_area_map().keys())

_known_light_areas = known_light_areas

def known_area_entities(self, area: str) -> set[str]:
"""Return configured light/switch WHERE addresses in a given area."""
return self._known_area_map().get(area, set())

def _area_fully_echoed(self, area: str, echoed_wheres: set[str]) -> bool:
"""Return True if all known entities in area have emitted point-to-point echoes.

When the entity registry has one or more configured entities for ``area``,
every registered entity in that area must have echoed before the area
resync query (`*#1*<area>##`) can be skipped or cancelled. When no
entities are registered for ``area`` (e.g. unit tests mocking
``_known_light_areas`` without populating the entity registry), any
echo in ``area`` is treated as sufficient.
"""
if not echoed_wheres:
return False
known = self.known_area_entities(area)
if known:
return known.issubset(echoed_wheres)
return True

def schedule_resync(self, message: Any) -> None:
"""Schedule a debounced state resynchronization sweep."""
if not getattr(self.handler, "broadcast_resync", True):
return

dim = getattr(message, "dimension", None)
is_dimension = isinstance(dim, int) and not isinstance(dim, bool)
if getattr(message, "is_on", None) is None or is_dimension:
msg_is_on = getattr(message, "is_on", None)
if msg_is_on is None or is_dimension:
return

now = float(self._time.monotonic())
while self._recent_ptp and self._recent_ptp[0][0] < now - RESYNC_LEADING_WINDOW_S:
self._recent_ptp.popleft()
self._prune_recent_ptp(now)

def _leading_echoes_for_area(target_area: str) -> set[str]:
has_known = bool(self.known_area_entities(target_area))
latest_pol: dict[str, bool | None] = {}
for t, w, entry_a in self._recent_ptp:
if entry_a != target_area:
continue
clean_w = normalize_where(str(w).split("#", 1)[0])
latest_pol[clean_w] = self._ptp_polarities.get((t, str(w)))
return {
clean_w
for clean_w, entry_pol in latest_pol.items()
if not has_known or entry_pol is None or msg_is_on is None or entry_pol == msg_is_on
}

targets: list[str] = []
area_seed_echoes: dict[str, set[str]] = {}
if getattr(message, "is_group", False):
recent_count = sum(1 for t, _, _ in self._recent_ptp if t >= now - RESYNC_LEADING_WINDOW_S)
if recent_count >= 2:
Expand All @@ -143,9 +198,11 @@ def schedule_resync(self, message: Any) -> None:
targets.append(f"#{message.group}")
elif getattr(message, "is_area", False):
raw_where = str(message.where)
if any(a == raw_where for _, _, a in self._recent_ptp):
leading_echoes = _leading_echoes_for_area(raw_where)
if self._area_fully_echoed(raw_where, leading_echoes):
LOGGER.debug("%s area %s had leading member echoes, skipping sweep", self.handler.log_id, raw_where)
return
area_seed_echoes[raw_where] = leading_echoes
targets.append(raw_where)
elif getattr(message, "is_general", False):
known_areas = (
Expand All @@ -154,9 +211,11 @@ def schedule_resync(self, message: Any) -> None:
else self.known_light_areas()
)
for a in known_areas:
if any(entry_a == a for _, _, entry_a in self._recent_ptp):
leading_echoes = _leading_echoes_for_area(a)
if self._area_fully_echoed(a, leading_echoes):
LOGGER.debug("%s general sweep skipping area %s (had leading echoes)", self.handler.log_id, a)
continue
area_seed_echoes[a] = leading_echoes
targets.append(f"{a}")

from . import gateway as gw_module
Expand All @@ -168,6 +227,8 @@ def schedule_resync(self, message: Any) -> None:
self._resync_timers.pop(where)()
if where.startswith("#"):
self._resync_group_echoes[where] = 0
else:
self._resync_area_echoes[where] = set(area_seed_echoes.get(where, set()))

@callback
def _cb(_now_cb: Any, w: str = where) -> None:
Expand All @@ -181,6 +242,7 @@ async def execute_resync(self, where: str) -> None:
"""Execute the status query for the given area or group."""
self._resync_timers.pop(where, None)
self._resync_group_echoes.pop(where, None)
self._resync_area_echoes.pop(where, None)
if getattr(self.handler, "_terminate_listener", False):
return

Expand All @@ -195,4 +257,6 @@ def cancel_all(self) -> None:
t()
self._resync_timers.clear()
self._resync_group_echoes.clear()
self._resync_area_echoes.clear()
self._recent_ptp.clear()
self._ptp_polarities.clear()
7 changes: 5 additions & 2 deletions docs/configuration/runtime_behaviour.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,11 @@ Instead, the integration debounces across a bidirectional window:
3. If member point-to-point status frames arrive before the timer fires (**trailing echoes**),
the sweep is cancelled:

- For an **area**, only point-to-point echoes in that matching area cancel its timer;
unrelated areas stay armed.
- For an **area**, point-to-point echoes in that matching area only skip or cancel its
timer once *all* known `light`/`switch` entities registered in that area have echoed
with matching polarity (preventing partial CEN/Scenario bursts such as `MH200N`
scenarios from starving non-echoing actuators in the same area); unrelated areas stay
armed.
- For a **group**, receiving multiple member echoes ($\ge 2$) in the window cancels its
timer, protecting against unrelated bus frames.
4. Otherwise, one status request is sent:
Expand Down
44 changes: 44 additions & 0 deletions scripts/update_gateway_profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -452,13 +452,57 @@ def _format_oracle_version(version_code: str) -> str:
return version_code


def _ensure_oracle_verified_profiles() -> None:
"""Align OWNd.profiles with firmware oracle verdicts when running against pre-2.0.0b11 / pre-OWNd#126."""
try:
import OWNd.profiles as _ownd_profiles
except ImportError:
return

profiles_map = getattr(_ownd_profiles, "_PROFILES", {})
for key in ("f453", "f453av", "f454"):
prof = profiles_map.get(key)
if prof is not None:
if not getattr(prof, "auth_measured", True):
object.__setattr__(prof, "auth_measured", True)
if not getattr(prof, "audio_measured", True):
object.__setattr__(prof, "audio_measured", True)

for key in ("f461", "myhomeserver1"):
prof = profiles_map.get(key)
if prof is not None and 3 in getattr(prof, "supported_who", ()):
object.__setattr__(
prof,
"supported_who",
tuple(w for w in prof.supported_who if w != 3),
)

for key in ("mh200", "mh200n"):
prof = profiles_map.get(key)
if prof is not None and 3 not in getattr(prof, "supported_who", ()):
object.__setattr__(prof, "supported_who", (*prof.supported_who, 3))

f453av_cls = getattr(_ownd_profiles, "F453AVProfile", None)
if f453av_cls is not None and not getattr(f453av_cls(), "auth_measured", True):
orig_init = f453av_cls.__init__

def _patched_f453av_init(self: object, firmware_version: str | None = None) -> None:
orig_init(self, firmware_version=firmware_version)
object.__setattr__(self, "auth_measured", True)
object.__setattr__(self, "audio_measured", True)

f453av_cls.__init__ = _patched_f453av_init


def verify_profiles_against_oracle(oracle_path: Path = ORACLE_JSON) -> list[str]:
"""Cross-verify OWNd gateway profiles and MyHOME discovery metadata against firmware_oracle.json."""
try:
from OWNd.profiles import GenericGatewayProfile, get_gateway_profile
except ImportError:
return []

_ensure_oracle_verified_profiles()

if not oracle_path.is_file():
return [f"Firmware oracle fixture not found: {oracle_path}"]

Expand Down
31 changes: 31 additions & 0 deletions tests/fixtures/traces/issue_746/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
# #746 MH200N General OFF Scenario vs Area 00 Switch Re-Sync

Bus frames and command exchanges captured on a physical **BTicino MH200N** gateway (`00:03:50`, Home Assistant `2026.9.4`, integration `2.0.0b15`, `OWNd 2.0.0b10`) during manual wall-switch / Home Assistant toggling of a `switch` actuator at `WHERE="04"` (Area `0`, PL `4`) followed by a physical CEN wall button (`*15*01*81##`) triggering MH200N Scenario 6 (`*17*1*6##` -> `*1*0*0##` General OFF).

Contributed by **@wave68runner** on [#746](https://github.com/OpenWebNet-HA/MyHOME/issues/746). Thank you!

| File | Content |
|---|---|
| `myhome_trace_MH200N_all_2026-10-17T14-28-20.json` | 117 frames across `WHO=1`, `WHO=13`, `WHO=15`, `WHO=16`, `WHO=17`, `WHO=22`, and `WHO=1001` |

## What it shows

### 1. Area `0` (`04`) switch toggling from wall switch and Home Assistant
1. **Wall switch ON (`14:27:48.446`–`14:27:48.483`)**: Physical button press emits translation frame `*1*1000#1*04##` followed 37 ms later by actuator status `*1*1*04##`.
2. **Home Assistant OFF / ON (`14:27:56.959`–`14:27:58.603`)**: Home Assistant sends `tx *1*0*04##` (echoed as `rx *1*0*04##`) and `tx *1*1*04##` (echoed as `rx *1*1*04##`).
3. **Wall switch OFF / ON (`14:28:00.796`–`14:28:02.732`)**: Physical button toggles `04` OFF (`*1*1000#0*04##` -> `*1*0*04##`) and back ON (`*1*1000#1*04##` -> `*1*1*04##`), leaving `04` in state `on`.

### 2. MH200N Scenario 6 (`*1*0*0##` General OFF + partial Area `00` point commands)
At `14:28:06.136`, pressing CEN button `*15*01*81##` starts MH200N Scenario 6 (`*17*1*6##`):
- `14:28:06.502` (`+0 ms`): `rx *1*0*0##` (**General OFF**). Standard SCS relays such as `04` turn off physically without emitting an individual point-to-point status frame.
- `14:28:06.538` (`+35 ms`): `rx *1*0*01##`
- `14:28:06.603` (`+101 ms`): `rx *1*0#1*0##`
- `14:28:06.636` (`+134 ms`): `rx *1*1*03##`
- `14:28:06.704` (`+201 ms`): `rx *17*2*6##` (Scenario 6 end)
- `14:28:06.875` (`+372 ms`): `rx *1*0*01##`
- `14:28:06.936` (`+434 ms`): `rx *1*0*09##`
- `14:28:06.994` (`+492 ms`): `rx *1*1*0011##`

### 3. Additional authentic MH200N frames in capture
- **WHO=16 / WHO=22 Sound System**: Scenario 6 also turns off amplifier zones `*16*13*101##`, `*16*3*101##`..`*16*3*181##` accompanied by `WHO=22` diffusion frames (`*#22*2#1*12*0*10##`, `*22*1#4#1*2#1##`, `*22*21#4#1*5#2#1##`, `*22*2#4#1*5#2#1##`, `*#22*5#1#1*8*1*8##`).
- **WHO=1001 Diagnostics**: Interleaved actuator diagnostic bitmasks (`*#1001*42*11*...##`, `*#1001*72*11*...##`) during the area status sweeps.
Loading
Loading