From a63d64db43851e45f9435e8b3ebae74f781cb02c Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 10 Oct 2026 10:51:08 +0200 Subject: [PATCH 1/3] fix(media_player): centralise power-state decision engine, document sound system state machine, and add intercom ducking trace replay (#682) --- README.md | 2 +- custom_components/myhome/media_player.py | 64 ++- .../myhome/media_player_decoder.py | 37 +- .../myhome/media_player_power.py | 176 ++++++++ docs/configuration/media_player.md | 62 +++ docs/trace-availability.md | 2 +- .../mh202_sound_intercom_ducking/README.md | 20 + ...me_trace_MH202_sound_intercom_ducking.json | 411 ++++++++++++++++++ tests/golden/corpus.json | 98 ++++- tests/golden/frames/who16_sound.yaml | 50 +++ .../golden/frames/who22_sound_diffusion.yaml | 26 ++ tests/test_audio_group_live_replay.py | 132 ++++++ tests/test_intercom_ducking_trace_replay.py | 212 +++++++++ tests/test_media_player_power.py | 280 ++++++++++++ 14 files changed, 1509 insertions(+), 63 deletions(-) create mode 100644 custom_components/myhome/media_player_power.py create mode 100644 tests/fixtures/traces/mh202_sound_intercom_ducking/README.md create mode 100644 tests/fixtures/traces/mh202_sound_intercom_ducking/myhome_trace_MH202_sound_intercom_ducking.json create mode 100644 tests/test_intercom_ducking_trace_replay.py create mode 100644 tests/test_media_player_power.py diff --git a/README.md b/README.md index 1e347ecaa..61b2e429f 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ We now maintain a comprehensive, community-curated **[GitHub Wiki](https://githu | **MH200** | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | ✅ | ✅ | | | | ✅ | ✅ | | | **MH200N** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | **MH201** | ✅ | ✅ | ✅ | ✅ | | | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | | | | -| **MH202** | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | ✅ | | ✅ | | ✅ | | +| **MH202** | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | ✅ | ✅ | | ✅ | | | **MyHomeServer1** | | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | ✅ | ✅ | | diff --git a/custom_components/myhome/media_player.py b/custom_components/myhome/media_player.py index 2d83e3505..a26dad68f 100644 --- a/custom_components/myhome/media_player.py +++ b/custom_components/myhome/media_player.py @@ -120,6 +120,11 @@ build_pool, sync_multiple_audio_gateways, ) +from .media_player_power import ( + WAKE_ECHO_WINDOW, + PowerTransition, + determine_power_transition, +) from .media_player_routing import parse_routing_address, route_pseudo_zones, zone_environment from .sound_source import MyHOMESoundSource, source_address @@ -128,7 +133,7 @@ # The amplifier wake sequence starts with an OFF frame, and the gateway reports # that frame back on the event session like any other bus traffic. An OFF that # arrives this soon after a wake is our own and must not tear the zone down. -_WAKE_ECHO_WINDOW = 3.0 # seconds +_WAKE_ECHO_WINDOW = WAKE_ECHO_WINDOW # seconds (centralised in media_player_power) _RESTORE_CONFIRM_WINDOW = 120.0 # seconds a restored zone has to show up on the bus @@ -946,24 +951,30 @@ def handle_event(self, message: OWNSoundEvent) -> None: source_num, CONF_SOURCE_SLOTS, ) - elif message.is_on: - self._cancel_pending_off() # confirmed on: nothing left to time out - self._parked = False - self._attr_state = MediaPlayerState.ON - if not self._status_seen: - self._mark_status_seen() - self._restore_claim() - self._check_stray_at_startup() - trigger_auto_join = True - elif message.is_off: - if self._is_wake_echo(): + else: + transition = determine_power_transition( + message=message, + current_state=self._attr_state, + is_parked=self._parked, + is_wake_echo=self._is_wake_echo(), + ) + if transition == PowerTransition.WAKE_ON: + self._cancel_pending_off() # confirmed on: nothing left to time out + self._parked = False + self._attr_state = MediaPlayerState.ON + if not self._status_seen: + self._mark_status_seen() + self._restore_claim() + self._check_stray_at_startup() + trigger_auto_join = True + elif transition == PowerTransition.WAKE_ECHO_OFF: # Our own wake sequence's OFF: the ON follows it. LOGGER.debug("%s: ignoring the OFF echo of the wake sequence", self.entity_id) - elif self._parked: + elif transition == PowerTransition.PARKED_CONFIRM_OFF: # The anti-hiss OFF we sent ourselves: the room stays in its group. self._mark_status_seen() self._attr_state = MediaPlayerState.OFF - else: + elif transition == PowerTransition.REAL_OFF: self._mark_status_seen() # A real OFF (wall switch or otherwise) makes any pending # group-leave OFF redundant; _async_handle_turn_off below @@ -973,31 +984,6 @@ def handle_event(self, message: OWNSoundEvent) -> None: if not self._turning_off: self.hass.async_create_task(self._async_handle_turn_off(from_bus=True)) - what = getattr(message, "what", getattr(message, "_what", None)) - is_volume_up = False - if what is not None: - try: - is_volume_up = 1001 <= int(what) <= 1015 - except (ValueError, TypeError): - pass - - # Physical volume-up rocker commands (*16*1001*WHERE## .. *16*1015*WHERE##) - # wake an unpowered amplifier and auto-join the active streaming group (#579). - # Dimension 1 volume status reports (*#16*WHERE*1*##, WHO 22 mirror - # *#22*3#A#P*1*##) must NEVER wake an amplifier (#669), as amplifiers - # retain configured volume while OFF and hardware audio ducking during intercom - # video streams broadcasts volume status frames across all zones. - if not message.is_off and is_volume_up: - if self._attr_state != MediaPlayerState.ON or self._parked: - self._cancel_pending_off() - self._parked = False - self._attr_state = MediaPlayerState.ON - if not self._status_seen: - self._mark_status_seen() - self._restore_claim() - self._check_stray_at_startup() - trigger_auto_join = True - if message.volume is not None: self._attr_volume_level = message.volume / 31.0 # Volume 0 is not a mute: only async_mute_volume() mutes. Music diff --git a/custom_components/myhome/media_player_decoder.py b/custom_components/myhome/media_player_decoder.py index a4bb846b4..415e9b069 100644 --- a/custom_components/myhome/media_player_decoder.py +++ b/custom_components/myhome/media_player_decoder.py @@ -11,21 +11,18 @@ from .const import LOGGER from .decoder_pool import DecoderPool from .media_player_pool import STREAM_INCOMPATIBLE_PLATFORMS +from .media_player_power import ( + AUTO_OFF_IDLE_DELAY, + AUTO_OFF_PAUSED_DELAY, + DECODER_PLAYING_STATES, + calculate_auto_off_delay, +) from .media_player_source import ZoneSourceLayer -# Anti-hiss auto-off: how long a room stays on after the decoder it hears -# stops (idle, standby or off) or pauses. -_AUTO_OFF_IDLE_DELAY = 3.0 # seconds -_AUTO_OFF_PAUSED_DELAY = 60.0 # seconds - -# Decoder states that mean music is coming out, or about to: a track change -# or a Spotify Connect handshake passes through "buffering". -_DECODER_PLAYING_STATES = frozenset({ - MediaPlayerState.PLAYING, - MediaPlayerState.BUFFERING, - "playing", - "buffering", -}) +# Anti-hiss auto-off delays and states (centralised in media_player_power) +_AUTO_OFF_IDLE_DELAY = AUTO_OFF_IDLE_DELAY +_AUTO_OFF_PAUSED_DELAY = AUTO_OFF_PAUSED_DELAY +_DECODER_PLAYING_STATES = DECODER_PLAYING_STATES class ZoneDecoderLayer(ZoneSourceLayer): @@ -328,14 +325,12 @@ def _apply_decoder_state(self, new_state_val: str, decoder_id: str | None = None should not have taken every room down with it. ``unavailable`` and ``unknown`` say nothing about playback and are ignored. """ - if new_state_val in _DECODER_PLAYING_STATES: - self._cancel_auto_off() - elif new_state_val in (MediaPlayerState.OFF, "off"): - self._arm_auto_off(_AUTO_OFF_IDLE_DELAY, decoder_id) - elif new_state_val in (MediaPlayerState.IDLE, "idle", "standby"): - self._arm_auto_off(_AUTO_OFF_IDLE_DELAY, decoder_id) - elif new_state_val in (MediaPlayerState.PAUSED, "paused"): - self._arm_auto_off(_AUTO_OFF_PAUSED_DELAY, decoder_id) + delay = calculate_auto_off_delay(new_state_val) + if delay is None: + if new_state_val in _DECODER_PLAYING_STATES: + self._cancel_auto_off() + else: + self._arm_auto_off(delay, decoder_id) @callback def _cancel_auto_off(self) -> None: diff --git a/custom_components/myhome/media_player_power.py b/custom_components/myhome/media_player_power.py new file mode 100644 index 000000000..28572d66b --- /dev/null +++ b/custom_components/myhome/media_player_power.py @@ -0,0 +1,176 @@ +"""Power-state decision engine and frame classification for MyHOME media players. + +The WHO 16 sound system features multiple interacting power-state pathways: +- Explicit ON / OFF OpenWebNet frames +- Physical wall control volume-up rocker commands (*16*1001*WHERE## .. *16*1015*WHERE##) +- Dimension 1 volume status reports (*#16*WHERE*1*##, *#22*3#A#P*1*##) +- Anti-hiss auto-off timers for active, unowned (stray), and grouped zones +- Temporary parking of multi-room groups during pauses or track transitions +- Wake-echo suppression to prevent self-induced shutdown loops + +This module centralises all power-state classifications, transitions, and delay +calculations into pure, deterministic functions to prevent power state regressions. +""" + +from __future__ import annotations + +from enum import Enum +from typing import Any + +from homeassistant.components.media_player.const import MediaPlayerState + +# Anti-hiss auto-off delays (seconds). +# When the decoder an amplifier hears stops playing or goes idle, the amplifier's +# analog power stage is switched off to eliminate background line hiss. +AUTO_OFF_IDLE_DELAY = 3.0 # seconds after idle, standby or off +AUTO_OFF_PAUSED_DELAY = 60.0 # seconds after pause + +# A departing member of a group has a short grace period before being turned off, +# allowing Music Assistant's sequential handover to reclaim it without audio dropouts. +GROUP_LEAVE_GRACE = 5.0 # seconds + +# Window (seconds) during which an incoming OFF frame is presumed to be the gateway's +# echo of our own OFF -> ON wake sequence rather than an external power-down. +WAKE_ECHO_WINDOW = 3.0 # seconds + +# Decoder states that indicate audio playback is active or imminent. +DECODER_PLAYING_STATES = frozenset({ + MediaPlayerState.PLAYING, + MediaPlayerState.BUFFERING, + "playing", + "buffering", +}) + +_PLAYING_STATE_STRINGS = frozenset({"playing", "buffering"}) + + +class PowerTransition(Enum): + """Categorisation of power-state effect for an incoming OpenWebNet event.""" + + NO_CHANGE = "no_change" + WAKE_ON = "wake_on" + WAKE_ECHO_OFF = "wake_echo_off" + PARKED_CONFIRM_OFF = "parked_confirm_off" + REAL_OFF = "real_off" + + +def is_volume_rocker_up(message: Any) -> bool: + """Return True if message is a physical volume-up rocker command. + + Physical wall switches (such as H4651/2, L4651/2) emit OpenWebNet frames + *16*1001*WHERE## to *16*1015*WHERE## (step +1 to +15). + In BTicino systems, tapping volume-up on a wall control powers on an unpowered + amplifier (#579). + """ + if getattr(message, "is_off", False): + return False + what = getattr(message, "what", getattr(message, "_what", None)) + if what is None: + return False + try: + val = int(what) + return 1001 <= val <= 1015 + except (ValueError, TypeError): + return False + + +def is_volume_rocker_down(message: Any) -> bool: + """Return True if message is a physical volume-down rocker command. + + Frames *16*1101*WHERE## to *16*1115*WHERE## (step -1 to -15). + Volume-down steps adjust the volume level but MUST NOT power on an amplifier. + """ + what = getattr(message, "what", getattr(message, "_what", None)) + if what is None: + return False + try: + val = int(what) + return 1101 <= val <= 1115 + except (ValueError, TypeError): + return False + + +def is_dimension_1_volume_report(message: Any) -> bool: + """Return True if message is a Dimension 1 volume status report. + + Dimension 1 frames (*#16*WHERE*1*##, *#22*3#A#P*1*##) report the + current hardware volume level (0..31). + BTicino amplifiers retain their configured volume in hardware registers even + when turned OFF. During video intercom door entry calls, the matrix broadcasts + hardware audio ducking and volume restoration frames for all zones in the house. + These reports MUST NEVER change the amplifier power state (#669). + """ + dimension = getattr(message, "dimension", getattr(message, "_dimension", None)) + if dimension == 1: + return True + if getattr(message, "volume", None) is not None and not is_volume_rocker_up(message): + return True + return False + + +def determine_power_transition( + message: Any, + current_state: MediaPlayerState | None, + is_parked: bool, + is_wake_echo: bool, +) -> PowerTransition: + """Determine the power state transition for an incoming bus message. + + Rules: + 1. Source device events (*16*3*10S##, *16*13*10S##) relate to external source + interfaces and never modify room amplifier power state. + 2. Explicit OFF frames (*16*13*WHERE## / *16*10*WHERE##): + - If within the wake echo window: WAKE_ECHO_OFF (ignored). + - If the zone is currently parked: PARKED_CONFIRM_OFF (physical confirmation, + preserves group membership). + - Otherwise: REAL_OFF (powers off, initiates coordinated teardown or handover). + 3. Explicit ON frames (*16*3*WHERE## / *16*0*WHERE##): + - WAKE_ON (sets state ON, cancels pending off, triggers auto-join). + 4. Physical volume-up rocker commands (*16*1001*WHERE## .. *16*1015*WHERE##): + - WAKE_ON (wakes an unpowered or parked amplifier, triggers auto-join). + 5. All other frames — including Dimension 1 volume status reports, matrix routing + frames (*16*3*1ES##), Dimension 5 status responses, and volume-down commands: + - NO_CHANGE (must NEVER turn an off amplifier on). + """ + # 1. Source device events never affect amplifier zone power + if getattr(message, "is_source_event", False): + return PowerTransition.NO_CHANGE + + # 2. Explicit OFF frames + if getattr(message, "is_off", False): + if is_wake_echo: + return PowerTransition.WAKE_ECHO_OFF + if is_parked: + return PowerTransition.PARKED_CONFIRM_OFF + return PowerTransition.REAL_OFF + + # 3. Explicit ON frames + if getattr(message, "is_on", False): + return PowerTransition.WAKE_ON + + # 4. Physical volume-up rocker command wakes amplifier + if is_volume_rocker_up(message): + return PowerTransition.WAKE_ON + + # 5. Non-power frames (volume reports, routing, status queries) + return PowerTransition.NO_CHANGE + + +def calculate_auto_off_delay(new_state_val: str | MediaPlayerState | None) -> float | None: + """Calculate the anti-hiss auto-off delay for a decoder state change. + + Returns: + float delay in seconds if the timer should be armed, or None if the + timer should be cancelled (audio playing/buffering) or ignored (unknown/unavailable). + """ + if new_state_val is None: + return None + + state_str = str(new_state_val).lower() + if state_str in _PLAYING_STATE_STRINGS: + return None + if state_str in ("off", "idle", "standby"): + return AUTO_OFF_IDLE_DELAY + if state_str == "paused": + return AUTO_OFF_PAUSED_DELAY + return None diff --git a/docs/configuration/media_player.md b/docs/configuration/media_player.md index fc0c6ff02..fcd296d78 100644 --- a/docs/configuration/media_player.md +++ b/docs/configuration/media_player.md @@ -577,6 +577,68 @@ and the decoder), so a report needs two files and one sentence: Attach these to the issue instead of pasting log excerpts; two files are enough. +## ⚡ Amplifier Power State Machine & Bus Interaction + +To ensure predictable behavior across diverse physical hardware (F441/F441M matrices, H4562/F502 amplifiers, L4561N source interfaces, and wall switches), the MyHOME integration centralizes all power-state decision logic into a dedicated state engine (`media_player_power.py`). + +### Power States + +The amplifier entity transitions between five distinct states: + +| State | Entity Status | Hardware Stage | Description | +| :--- | :--- | :--- | :--- | +| `OFF` | `off` | Powered Off | Amplifier output stage is unpowered (`*16*13*##`). Zero bus audio is demodulated. Standby power is minimal. | +| `WAKE_PENDING` | `off` / `on` | Powering Up | Integration initiates an OFF → ON wake sequence (`*16*13*##` followed by `*16*3*##`). A 3-second echo suppression window (`WAKE_ECHO_WINDOW = 3.0s`) ignores the initial OFF echo. | +| `ON` | `playing` / `on` | Active | Amplifier is powered ON (`*16*3*##`). Demodulating stereo audio and driving room speakers. | +| `PENDING_OFF` | `paused` / `idle` | Active (Timer) | Decoder has stopped or paused. A safety timer runs (3.0s for idle/off/standby, 60.0s for pause, or 5.0s group leave grace). If audio resumes before timer expiry, the timer cancels and playback continues without interruption. | +| `PARKED` | `paused` / `idle` | Powered Off | Anti-hiss auto-off has powered off the hardware amplifier (`*16*13*##`) to eliminate analog bus hiss during extended pauses. In Home Assistant, the entity reports `paused`/`idle` and preserves group topology. Resumes immediately upon playback. | + +### State Transition Diagram + +```mermaid +stateDiagram-v2 + [*] --> OFF + OFF --> WAKE_PENDING: play_media / turn_on + WAKE_PENDING --> ON: *16*3*## confirmed + OFF --> ON: Physical Vol-Up Rocker (*16*1001..1015*##) + OFF --> ON: External Bus ON (*16*3*##) + ON --> PENDING_OFF: Decoder Idle/Off (3s) or Paused (60s) + ON --> PENDING_OFF: Member Unjoined (5s grace period) + PENDING_OFF --> ON: Decoder resumes playing (Timer Cancelled) + PENDING_OFF --> PARKED: Auto-Off Timer Expires (Group Preserved) + PENDING_OFF --> OFF: Grace Period Expires (Standalone / Unjoined) + PARKED --> ON: media_play / play_media / turn_on + PARKED --> OFF: turn_off / Physical Wall Switch OFF (*16*13*##) + ON --> OFF: turn_off / Physical Wall Switch OFF (*16*13*##) +``` + +### Bus Frame Decision Engine + +Every OpenWebNet frame received on the SCS bus is evaluated against the pure decision function `determine_power_transition(...)`. The engine maps frame types to transitions: + +| Frame Pattern | Description | Power Transition | Handling & Rationale | +| :--- | :--- | :--- | :--- | +| `*16*3*##`
`*16*0*##` | Amplifier ON (stereo / mono) | `PowerTransition.WAKE_ON` | Powers the amplifier ON; cancels any pending auto-off or grace timers. | +| `*16*1001*##`
to `*16*1015*##` | Wall switch Volume-UP rocker (+1 to +15) | `PowerTransition.WAKE_ON` | Physical volume-up rocker intentionally wakes and turns ON the amplifier ([#579](https://github.com/OpenWebNet-HA/MyHOME/issues/579)). | +| `*16*1101*##`
to `*16*1115*##` | Wall switch Volume-DOWN rocker (−1 to −15) | `PowerTransition.NO_CHANGE` | Steps volume down only. Never powers on an inactive amplifier. | +| `*#16**1*##`
`*#22*3#A#P*1*##` | Dimension 1 volume status report (0–31) | `PowerTransition.NO_CHANGE` | **CRITICAL**: Updates `volume_level` only. Never wakes or turns on an OFF amplifier ([#669](https://github.com/OpenWebNet-HA/MyHOME/issues/669), [#682](https://github.com/OpenWebNet-HA/MyHOME/issues/682)). | +| `*#16**#1*##` | Dimension #1 volume set command | `PowerTransition.NO_CHANGE` | Writes target volume level. Does not alter amplifier power state. | +| `*16*3*1ES##` | Matrix environment routing frame | `PowerTransition.NO_CHANGE` | Routes environment `E` to source `S`. Handled by routing logic; does not change power state. | +| `*16*3*10S##` | Source device activation frame | `PowerTransition.NO_CHANGE` | Toggles source hardware `S`. Does not alter room amplifier power. | +| `*16*13*##`
`*16*10*##` | Amplifier OFF (stereo / mono) | `PowerTransition.WAKE_ECHO_OFF`
`PowerTransition.PARKED_CONFIRM_OFF`
`PowerTransition.REAL_OFF` | • If within 3.0s of wake: ignored as self-echo.
• If amplifier is parked: acknowledged as auto-off echo.
• Otherwise: real OFF from wall switch or gateway; powers off entity and disbands group. | + +### Video Door Entry (Intercom Ducking) & Sound Diffusion Interaction + +In installations equipped with both BTicino Sound Diffusion (F441/F441M, WHO = 16 / WHO = 22) and 2-Wire Video Door Entry / Intercom systems (e.g. Classe 100/300 internal units, 344612, entrance panels): + +1. **Hardware Intercom Ducking**: When an incoming door call or intercom conversation begins, the video door entry system or audio matrix hardware performs automatic **hardware audio ducking** or line muting so that door chimes and speech remain clearly audible over background music. +2. **Dimension 1 Status Broadcasts**: To synchronize volume levels during ducking, the gateway or matrix broadcasts OpenWebNet Dimension 1 volume status reports (`*#16**1*##` and WHO 22 mirrors `*#22*3#A#P*1*##`) across the SCS bus. During the call, volume is lowered (e.g., to 0 or a low ducked level). When the intercom call terminates, the hardware broadcasts restoration Dimension 1 reports restoring previous volume levels (e.g., back to 10 or 15). +3. **Prevention of False Wake-Ups ([#669](https://github.com/OpenWebNet-HA/MyHOME/issues/669))**: Unpowered room amplifiers retain their hardware volume registers in non-volatile memory and receive these broadcast ducking/restoration frames even while switched OFF. Prior to [#682](https://github.com/OpenWebNet-HA/MyHOME/issues/682), loosely coupled status checks could interpret volume updates as an indication that the amplifier was active, causing unpowered amplifiers throughout the house to falsely power ON whenever the doorbell rang. +4. **Guaranteed Decoupling**: With `determine_power_transition(...)`, Dimension 1 status reports are strictly classified as `PowerTransition.NO_CHANGE`. Inactive amplifiers stay OFF throughout the intercom call, active amplifiers duck and restore seamlessly, and volume state remains perfectly synchronized. +5. **Golden Trace Verification**: Validated against an authentic 30-frame MH202 bus capture (`myhome_trace_MH202_sound_intercom_ducking.json`) in `tests/test_intercom_ducking_trace_replay.py`. + +--- + ## 📜 OpenWebNet WHO = 16 Reference Frames `` is an amplifier (`01`–`99`), an environment (`#0`–`#9`) or `0` for diff --git a/docs/trace-availability.md b/docs/trace-availability.md index 49c5dc457..9d89065cf 100644 --- a/docs/trace-availability.md +++ b/docs/trace-availability.md @@ -13,7 +13,7 @@ This matrix documents the real-world physical gateways for which we have capture | **MH200** | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | ✅ | ✅ | | | | ✅ | ✅ | | | **MH200N** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | **MH201** | ✅ | ✅ | ✅ | ✅ | | | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | | | | -| **MH202** | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | ✅ | | ✅ | | ✅ | | +| **MH202** | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | ✅ | ✅ | | ✅ | | | **MyHomeServer1** | | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | ✅ | ✅ | | diff --git a/tests/fixtures/traces/mh202_sound_intercom_ducking/README.md b/tests/fixtures/traces/mh202_sound_intercom_ducking/README.md new file mode 100644 index 000000000..8f8059faf --- /dev/null +++ b/tests/fixtures/traces/mh202_sound_intercom_ducking/README.md @@ -0,0 +1,20 @@ +# MH202 + F441M Intercom Video Stream Audio Ducking & Volume Restoration Trace + +Authentic passive bus trace captured on a physical plant with an MH202 gateway, F441M audio matrix, and video intercom entrance panel (Issue #669): +- **Gateway**: BTicino MH202 (Firmware 1.0) +- **Audio Matrix**: Legrand / BTicino F441M (WHO=16 / WHO=22) +- **Amplifiers in Home**: 31, 41, 51, 61, 71, 81 (all powered OFF when trace starts) +- **Video Intercom Event**: Video stream starts from entrance panel, ducking all audio; video stream stops (`*6*9**##`), restoring configured volumes. + +### Validated Protocol Behaviors + +1. **Hardware Audio Ducking**: + - Starting a video intercom stream triggers automatic audio ducking on the SCS bus. + - The matrix broadcasts Dimension 1 volume reduction frames across all amplifiers: + - `*#16*41*1*1##`, `*#16*31*1*1##`, `*#16*61*1*1##`, `*#16*51*1*1##`, `*#16*71*1*1##`, `*#16*81*1*1##` + - Interleaved with WHO 22 mirror status reports: `*#22*3#4#1*1*1##`, `*#22*3#3#1*1*1##`, `*#22*3#6#1*1*1##`, `*#22*3#5#1*1*1##`, `*#22*3#7#1*1*1##`, `*#22*3#8#1*1*1##` + - Amplifiers that are currently `MediaPlayerState.OFF` must retain their `OFF` state and **never** wake or auto-join streaming sessions (#669). + +2. **Camera Off & Volume Restoration**: + - When the camera stream ends (`*6*9**##`), the matrix restores individual amplifier volume levels to their pre-intercom configurations (e.g. 6/31, 7/31, 1/31, 0/31). + - Powered-off amplifiers update their stored volume level (`_attr_volume_level`) but remain strictly `MediaPlayerState.OFF`. diff --git a/tests/fixtures/traces/mh202_sound_intercom_ducking/myhome_trace_MH202_sound_intercom_ducking.json b/tests/fixtures/traces/mh202_sound_intercom_ducking/myhome_trace_MH202_sound_intercom_ducking.json new file mode 100644 index 000000000..42775cc06 --- /dev/null +++ b/tests/fixtures/traces/mh202_sound_intercom_ducking/myhome_trace_MH202_sound_intercom_ducking.json @@ -0,0 +1,411 @@ +{ + "capture": { + "kind": "trace", + "started_at": "2026-10-09T17:29:26.082Z", + "filters": { + "who": null, + "where": null, + "direction": null + }, + "window": { + "first": "2026-10-09T17:29:26.082Z", + "last": "2026-10-09T17:29:50.895Z", + "frames": 30, + "buffer_frames": 30, + "buffer_depth": 500, + "truncated": false + } + }, + "environment": { + "home_assistant_version": "2026.10.0", + "integration_version": "2.0.0b15", + "ownd_version": "2.0.0b10", + "exported_at": "2026-10-09T17:30:00.022Z" + }, + "gateway": { + "model": "MH202", + "manufacturer": "BTicino S.p.A.", + "firmware": "1.0", + "mac_prefix": "00:03:50", + "connection_type": "tcp", + "queue_pacing": 0.1, + "is_connected": true, + "identification": { + "model": "MH202", + "source": "ssdp", + "configured_model": "MH202", + "ssdp_model": "MH202", + "profile": "MH202Profile", + "conflict": null + } + }, + "telemetry": { + "total_rx": 30, + "total_tx": 0, + "captured_in_buffer": 30, + "buffer_depth": 500, + "queue_depth": 0 + }, + "frames": [ + { + "timestamp": 1791566966.082, + "iso_time": "2026-10-09T17:29:26.082Z", + "direction": "rx", + "raw": "*#18*76#0*113*0##", + "who": "18", + "what": null, + "where": "76", + "dimension": "113", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.184, + "iso_time": "2026-10-09T17:29:30.184Z", + "direction": "rx", + "raw": "*#22*3#4#1*1*1##", + "who": "22", + "what": null, + "where": "3", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.207, + "iso_time": "2026-10-09T17:29:30.207Z", + "direction": "rx", + "raw": "*#13**22*17*29*32*000*05*09*10*2026##", + "who": "13", + "what": null, + "where": "", + "dimension": "22", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.214, + "iso_time": "2026-10-09T17:29:30.214Z", + "direction": "rx", + "raw": "*#16*41*1*1##", + "who": "16", + "what": null, + "where": "41", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.344, + "iso_time": "2026-10-09T17:29:30.344Z", + "direction": "rx", + "raw": "*#22*3#3#1*1*1##", + "who": "22", + "what": null, + "where": "3", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.355, + "iso_time": "2026-10-09T17:29:30.355Z", + "direction": "rx", + "raw": "*#16*31*1*1##", + "who": "16", + "what": null, + "where": "31", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.417, + "iso_time": "2026-10-09T17:29:30.417Z", + "direction": "rx", + "raw": "*#16*61*1*1##", + "who": "16", + "what": null, + "where": "61", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.42, + "iso_time": "2026-10-09T17:29:30.420Z", + "direction": "rx", + "raw": "*#22*3#6#1*1*1##", + "who": "22", + "what": null, + "where": "3", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.454, + "iso_time": "2026-10-09T17:29:30.454Z", + "direction": "rx", + "raw": "*#22*3#5#1*1*1##", + "who": "22", + "what": null, + "where": "3", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.465, + "iso_time": "2026-10-09T17:29:30.465Z", + "direction": "rx", + "raw": "*#16*51*1*1##", + "who": "16", + "what": null, + "where": "51", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.5, + "iso_time": "2026-10-09T17:29:30.500Z", + "direction": "rx", + "raw": "*#16*71*1*1##", + "who": "16", + "what": null, + "where": "71", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.502, + "iso_time": "2026-10-09T17:29:30.502Z", + "direction": "rx", + "raw": "*#22*3#7#1*1*1##", + "who": "22", + "what": null, + "where": "3", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.537, + "iso_time": "2026-10-09T17:29:30.537Z", + "direction": "rx", + "raw": "*#22*3#8#1*1*1##", + "who": "22", + "what": null, + "where": "3", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566970.544, + "iso_time": "2026-10-09T17:29:30.544Z", + "direction": "rx", + "raw": "*#16*81*1*1##", + "who": "16", + "what": null, + "where": "81", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566975.714, + "iso_time": "2026-10-09T17:29:35.714Z", + "direction": "rx", + "raw": "*#18*77#0*113*161##", + "who": "18", + "what": null, + "where": "77", + "dimension": "113", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566980.884, + "iso_time": "2026-10-09T17:29:40.884Z", + "direction": "rx", + "raw": "*#18*73#0*113*0##", + "who": "18", + "what": null, + "where": "73", + "dimension": "113", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566981.482, + "iso_time": "2026-10-09T17:29:41.482Z", + "direction": "rx", + "raw": "*#18*78#0*113*0##", + "who": "18", + "what": null, + "where": "78", + "dimension": "113", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566981.546, + "iso_time": "2026-10-09T17:29:41.546Z", + "direction": "rx", + "raw": "*6*9**##", + "who": "6", + "what": null, + "where": "*", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566982.404, + "iso_time": "2026-10-09T17:29:42.404Z", + "direction": "rx", + "raw": "*#18*51*113*431##", + "who": "18", + "what": null, + "where": "51", + "dimension": "113", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566983.429, + "iso_time": "2026-10-09T17:29:43.429Z", + "direction": "rx", + "raw": "*#22*3#5#1*1*0##", + "who": "22", + "what": null, + "where": "3", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566983.437, + "iso_time": "2026-10-09T17:29:43.437Z", + "direction": "rx", + "raw": "*#16*51*1*0##", + "who": "16", + "what": null, + "where": "51", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566983.651, + "iso_time": "2026-10-09T17:29:43.651Z", + "direction": "rx", + "raw": "*#16*31*1*1##", + "who": "16", + "what": null, + "where": "31", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566983.657, + "iso_time": "2026-10-09T17:29:43.657Z", + "direction": "rx", + "raw": "*#22*3#3#1*1*1##", + "who": "22", + "what": null, + "where": "3", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566983.858, + "iso_time": "2026-10-09T17:29:43.858Z", + "direction": "rx", + "raw": "*#16*41*1*6##", + "who": "16", + "what": null, + "where": "41", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566983.861, + "iso_time": "2026-10-09T17:29:43.861Z", + "direction": "rx", + "raw": "*#22*3#4#1*1*6##", + "who": "22", + "what": null, + "where": "3", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566984.128, + "iso_time": "2026-10-09T17:29:44.128Z", + "direction": "rx", + "raw": "*#22*3#6#1*1*7##", + "who": "22", + "what": null, + "where": "3", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566984.136, + "iso_time": "2026-10-09T17:29:44.136Z", + "direction": "rx", + "raw": "*#16*61*1*7##", + "who": "16", + "what": null, + "where": "61", + "dimension": "1", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566984.788, + "iso_time": "2026-10-09T17:29:44.788Z", + "direction": "rx", + "raw": "*#18*79#0*113*0##", + "who": "18", + "what": null, + "where": "79", + "dimension": "113", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566988.464, + "iso_time": "2026-10-09T17:29:48.464Z", + "direction": "rx", + "raw": "*#18*75#0*113*0##", + "who": "18", + "what": null, + "where": "75", + "dimension": "113", + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1791566990.895, + "iso_time": "2026-10-09T17:29:50.895Z", + "direction": "rx", + "raw": "*#18*71#0*113*0##", + "who": "18", + "what": null, + "where": "71", + "dimension": "113", + "is_ack": false, + "is_nack": false + } + ] +} \ No newline at end of file diff --git a/tests/golden/corpus.json b/tests/golden/corpus.json index 4dd1890ea..494570ba1 100644 --- a/tests/golden/corpus.json +++ b/tests/golden/corpus.json @@ -3716,6 +3716,70 @@ "roundtrip": true, "_file": "who16_sound.yaml" }, + { + "id": "sound.dim.volume.duck.41", + "frame": "*#16*41*1*1##", + "direction": "dimension_response", + "who": 16, + "where": "41", + "dimension": 1, + "dimension_values": [ + "1" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Plant MH202 (issue #669): video intercom stream start triggers hardware audio ducking to volume 1 on amplifier 41.", + "roundtrip": true, + "_file": "who16_sound.yaml" + }, + { + "id": "sound.dim.volume.restore.41", + "frame": "*#16*41*1*6##", + "direction": "dimension_response", + "who": 16, + "where": "41", + "dimension": 1, + "dimension_values": [ + "6" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Plant MH202 (issue #669): video intercom stream stop restores volume 6 on amplifier 41.", + "roundtrip": true, + "_file": "who16_sound.yaml" + }, + { + "id": "sound.dim.volume.restore.61", + "frame": "*#16*61*1*7##", + "direction": "dimension_response", + "who": 16, + "where": "61", + "dimension": 1, + "dimension_values": [ + "7" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Plant MH202 (issue #669): video intercom stream stop restores volume 7 on amplifier 61.", + "roundtrip": true, + "_file": "who16_sound.yaml" + }, + { + "id": "sound.dim.volume.restore.51", + "frame": "*#16*51*1*0##", + "direction": "dimension_response", + "who": 16, + "where": "51", + "dimension": 1, + "dimension_values": [ + "0" + ], + "source": "community-plant-capture", + "mcp_valid": true, + "mcp_notes": "Plant MH202 (issue #669): video intercom stream stop restores volume 0 on amplifier 51.", + "roundtrip": true, + "_file": "who16_sound.yaml" + }, { "id": "energy.req.active_power.meter1", "frame": "*#18*1*1##", @@ -4159,6 +4223,38 @@ "roundtrip": true, "_file": "who22_sound_diffusion.yaml" }, + { + "id": "sounddiff.dim.volume.duck.area4.point1", + "frame": "*#22*3#4#1*1*1##", + "direction": "dimension_response", + "who": 22, + "where": "3", + "dimension": 1, + "dimension_values": [ + "1" + ], + "source": "community-plant-capture", + "mcp_valid": false, + "mcp_notes": "Plant MH202 (issue #669): video intercom stream start ducks volume to 1 on area 4 point 1 (zone 41). Mirrors *#16*41*1*1##.", + "roundtrip": true, + "_file": "who22_sound_diffusion.yaml" + }, + { + "id": "sounddiff.dim.volume.restore.area4.point1", + "frame": "*#22*3#4#1*1*6##", + "direction": "dimension_response", + "who": 22, + "where": "3", + "dimension": 1, + "dimension_values": [ + "6" + ], + "source": "community-plant-capture", + "mcp_valid": false, + "mcp_notes": "Plant MH202 (issue #669): video intercom stream stop restores volume 6 on area 4 point 1 (zone 41). Mirrors *#16*41*1*6##.", + "roundtrip": true, + "_file": "who22_sound_diffusion.yaml" + }, { "id": "cen_plus.event.short_press.btn1.21", "frame": "*25*21#1*21##", @@ -4319,4 +4415,4 @@ }, "_file": "who25_cen_plus.yaml" } -] +] \ No newline at end of file diff --git a/tests/golden/frames/who16_sound.yaml b/tests/golden/frames/who16_sound.yaml index 77d450040..3b51755d3 100644 --- a/tests/golden/frames/who16_sound.yaml +++ b/tests/golden/frames/who16_sound.yaml @@ -338,3 +338,53 @@ mcp_valid: true mcp_notes: "Plant B captured a live RDS broadcast on source 1 as eight ASCII codes. The station name has been replaced with ' Radio 1' here — the payload is what the contributor happened to be listening to, and only the shape (DIMENSION 8, eight codes) is under test." roundtrip: true + +# ── Intercom audio ducking & volume restoration (Issue #669) ────────────────── + +- id: sound.dim.volume.duck.41 + frame: "*#16*41*1*1##" + direction: dimension_response + who: 16 + where: "41" + dimension: 1 + dimension_values: ["1"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Plant MH202 (issue #669): video intercom stream start triggers hardware audio ducking to volume 1 on amplifier 41." + roundtrip: true + +- id: sound.dim.volume.restore.41 + frame: "*#16*41*1*6##" + direction: dimension_response + who: 16 + where: "41" + dimension: 1 + dimension_values: ["6"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Plant MH202 (issue #669): video intercom stream stop restores volume 6 on amplifier 41." + roundtrip: true + +- id: sound.dim.volume.restore.61 + frame: "*#16*61*1*7##" + direction: dimension_response + who: 16 + where: "61" + dimension: 1 + dimension_values: ["7"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Plant MH202 (issue #669): video intercom stream stop restores volume 7 on amplifier 61." + roundtrip: true + +- id: sound.dim.volume.restore.51 + frame: "*#16*51*1*0##" + direction: dimension_response + who: 16 + where: "51" + dimension: 1 + dimension_values: ["0"] + source: community-plant-capture + mcp_valid: true + mcp_notes: "Plant MH202 (issue #669): video intercom stream stop restores volume 0 on amplifier 51." + roundtrip: true diff --git a/tests/golden/frames/who22_sound_diffusion.yaml b/tests/golden/frames/who22_sound_diffusion.yaml index 04a0244df..e06a343ed 100644 --- a/tests/golden/frames/who22_sound_diffusion.yaml +++ b/tests/golden/frames/who22_sound_diffusion.yaml @@ -145,3 +145,29 @@ mcp_valid: false mcp_notes: "RDS text for source 1 under DIMENSION 10, where WHO=16 reports the same payload under DIMENSION 8. Station name replaced with ' Radio 1' for the same reason as sound.dim.rds.response.101." roundtrip: true + +# ── Intercom audio ducking & volume restoration mirrors (Issue #669) ────────── + +- id: sounddiff.dim.volume.duck.area4.point1 + frame: "*#22*3#4#1*1*1##" + direction: dimension_response + who: 22 + where: "3" + dimension: 1 + dimension_values: ["1"] + source: community-plant-capture + mcp_valid: false + mcp_notes: "Plant MH202 (issue #669): video intercom stream start ducks volume to 1 on area 4 point 1 (zone 41). Mirrors *#16*41*1*1##." + roundtrip: true + +- id: sounddiff.dim.volume.restore.area4.point1 + frame: "*#22*3#4#1*1*6##" + direction: dimension_response + who: 22 + where: "3" + dimension: 1 + dimension_values: ["6"] + source: community-plant-capture + mcp_valid: false + mcp_notes: "Plant MH202 (issue #669): video intercom stream stop restores volume 6 on area 4 point 1 (zone 41). Mirrors *#16*41*1*6##." + roundtrip: true diff --git a/tests/test_audio_group_live_replay.py b/tests/test_audio_group_live_replay.py index 9a9ac801a..27989bc1b 100644 --- a/tests/test_audio_group_live_replay.py +++ b/tests/test_audio_group_live_replay.py @@ -350,3 +350,135 @@ async def test_play_writes_the_same_frames_as_the_live_wake(hass, mock_gateway): await _resume_by_play(hass, zones, events) assert [e for e in events if e != "play_media"] == live + + +@pytest.mark.asyncio +async def test_multi_cycle_park_and_resume_preserves_topology(hass, mock_gateway): + """Multiple park and resume cycles keep the group books intact across pause/wake cycles.""" + zones, pool = await _plant(hass, mock_gateway) + + # --- Cycle 1: Park via 60s pause timeout, resume via async_turn_on --- + await _park(hass, zones) + for zone in zones.values(): + assert zone.state in (MediaPlayerState.PAUSED, MediaPlayerState.IDLE) + assert pool.get_members(_entity_id(LEADER)) == [_entity_id(w) for w in MEMBERS] + + # Resume via leader turn_on + mock_gateway.send.reset_mock() + with patch("asyncio.sleep", return_value=None): + await zones[LEADER].async_turn_on() + await hass.async_block_till_done() + + for zone in zones.values(): + assert zone._attr_state == MediaPlayerState.ON + assert pool.get_members(_entity_id(LEADER)) == [_entity_id(w) for w in MEMBERS] + + # --- Cycle 2: Park again, receive bus volume report while parked, then resume via play --- + await _park(hass, zones) + for zone in zones.values(): + assert zone.state in (MediaPlayerState.PAUSED, MediaPlayerState.IDLE) + + # Dimension 1 volume report arrives during parked state: must NOT wake or dissolve + vol_msg = OWNSoundEvent.parse("*#16*36*1*15##") + zones[LEADER].handle_event(vol_msg) + await hass.async_block_till_done() + + assert zones[LEADER].state in (MediaPlayerState.PAUSED, MediaPlayerState.IDLE) + assert pool.get_members(_entity_id(LEADER)) == [_entity_id(w) for w in MEMBERS] + + # Resume via play_media + mock_gateway.send.reset_mock() + events: list[str] = [] + _queue_only_send(mock_gateway, events) + await _resume_by_play(hass, zones, events) + await hass.async_block_till_done() + + for zone in zones.values(): + assert zone._attr_state == MediaPlayerState.ON + assert pool.get_members(_entity_id(LEADER)) == [_entity_id(w) for w in MEMBERS] + + # --- Cycle 3: Park and explicit turn_off disbands group --- + await _park(hass, zones) + await zones[LEADER].async_turn_off() + await hass.async_block_till_done() + + assert pool.get_members(_entity_id(LEADER)) == [] + assert pool.owned_decoder(_entity_id(LEADER)) is None + assert zones[LEADER].state == MediaPlayerState.OFF + + +@pytest.mark.asyncio +async def test_anti_hiss_auto_off_for_owned_and_unowned_rooms(hass, mock_gateway): + """Anti-hiss auto-off (3s) shuts down both owned and unowned rooms hearing a stopped decoder.""" + runtime = MyHOMERuntimeData(gateway=mock_gateway) + pool = DecoderPool(hass, {DECODER: 2}) + runtime.decoder_pool = pool + + # Owned room (holds decoder claim) + owned_zone = _create_test_zone(hass, mock_gateway, runtime, "36", _entity_id("36")) + owned_zone._attr_state = MediaPlayerState.ON + owned_zone._active_decoder = DECODER + await pool.claim(_entity_id("36")) + + # Unowned room (hearing Source 2 on the matrix, but not in any group) + unowned_zone = _create_test_zone(hass, mock_gateway, runtime, "21", _entity_id("21")) + unowned_zone._attr_state = MediaPlayerState.ON + unowned_zone._attr_source = "Cambridge" + + # Tuner room (hearing Source 1 on the matrix, where no decoder is wired) + tuner_zone = _create_test_zone(hass, mock_gateway, runtime, "14", _entity_id("14")) + tuner_zone._attr_state = MediaPlayerState.ON + tuner_zone._attr_source = "Radio" + + # Mock timers to capture anti-hiss callbacks + owned_timers = [] + unowned_timers = [] + tuner_timers = [] + + def mock_call_later_owned(_hass, delay, action): + owned_timers.append((delay, action)) + return MagicMock() + + def mock_call_later_unowned(_hass, delay, action): + unowned_timers.append((delay, action)) + return MagicMock() + + def mock_call_later_tuner(_hass, delay, action): + tuner_timers.append((delay, action)) + return MagicMock() + + # Decoder transitions to idle + hass.states.async_set(DECODER, "idle") + + with patch("custom_components.myhome.media_player_decoder.async_call_later", side_effect=mock_call_later_owned): + owned_zone._async_decoder_state_changed( + MagicMock(data={"entity_id": DECODER, "new_state": State(DECODER, "idle"), "old_state": State(DECODER, "playing")}) + ) + + with patch("custom_components.myhome.media_player_decoder.async_call_later", side_effect=mock_call_later_unowned): + unowned_zone._async_decoder_state_changed( + MagicMock(data={"entity_id": DECODER, "new_state": State(DECODER, "idle"), "old_state": State(DECODER, "playing")}) + ) + + with patch("custom_components.myhome.media_player_decoder.async_call_later", side_effect=mock_call_later_tuner): + tuner_zone._async_decoder_state_changed( + MagicMock(data={"entity_id": DECODER, "new_state": State(DECODER, "idle"), "old_state": State(DECODER, "playing")}) + ) + + # 1. Both owned and unowned rooms armed the 3.0s anti-hiss timer + assert [d for d, _ in owned_timers] == [3.0] + assert [d for d, _ in unowned_timers] == [3.0] + + # Tuner room does not hear this decoder, so no timer was armed + assert tuner_timers == [] + + # 2. Fire the auto-off timers + with patch.object(owned_zone, "async_turn_off", new_callable=AsyncMock) as mock_off_owned, \ + patch.object(unowned_zone, "async_turn_off", new_callable=AsyncMock) as mock_off_unowned: + owned_timers[0][1](None) + unowned_timers[0][1](None) + await hass.async_block_till_done() + + mock_off_owned.assert_called_once() + mock_off_unowned.assert_called_once() + diff --git a/tests/test_intercom_ducking_trace_replay.py b/tests/test_intercom_ducking_trace_replay.py new file mode 100644 index 000000000..c6fcb6514 --- /dev/null +++ b/tests/test_intercom_ducking_trace_replay.py @@ -0,0 +1,212 @@ +"""Replay and regression tests for authentic video intercom ducking and volume restoration. + +Validates the media player entity and centralised power state engine against the +authentic on-wire bus trace captured from an MH202 gateway with F441M audio matrix +and video entrance panel during intercom video streaming (Issue #669). +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import AsyncMock, MagicMock + +import pytest +from homeassistant.components.media_player.const import MediaPlayerState +from homeassistant.helpers.dispatcher import async_dispatcher_send +from OWNd.message import OWNMessage, OWNSoundEvent + +from custom_components.myhome.const import CONF_SOURCE_DEFAULTS, CONF_SOURCE_NAME +from custom_components.myhome.data import MyHOMERuntimeData +from custom_components.myhome.decoder_pool import DecoderPool +from tests.test_component_media_player import _create_test_zone + +FIXTURE_PATH = ( + Path(__file__).parent + / "fixtures" + / "traces" + / "mh202_sound_intercom_ducking" + / "myhome_trace_MH202_sound_intercom_ducking.json" +) + + +@pytest.fixture +def mock_gateway(): + gateway = MagicMock() + gateway.mac = "00:03:50:11:22:33" + gateway.log_id = "[MYHOME gateway - MH202]" + gateway.send = AsyncMock() + gateway.send_status_request = AsyncMock() + return gateway + + +def _resolve_where(frame_str: str) -> str | None: + """Extract the amplifier WHERE target from WHO 16 or WHO 22 frame.""" + if frame_str.startswith("*#16*"): + parts = frame_str.split("*") + return parts[2] if len(parts) > 2 else None + if frame_str.startswith("*#22*3#"): + # *#22*3#AREA#POINT*... + parts = frame_str.split("*") + where_field = parts[2] + _, area, point = where_field.split("#") + return f"{area}{point}" + return None + + +@pytest.mark.asyncio +async def test_mh202_intercom_ducking_trace_keeps_all_off_amplifiers_off(hass, mock_gateway): + """Replay authentic MH202 trace (#669): ducking and restoration must keep OFF zones OFF.""" + assert FIXTURE_PATH.is_file(), f"Missing trace fixture: {FIXTURE_PATH}" + + with open(FIXTURE_PATH, encoding="utf-8") as f: + trace_data = json.load(f) + + frames = trace_data["frames"] + assert len(frames) == 30 + + runtime = MyHOMERuntimeData(gateway=mock_gateway) + pool = DecoderPool(hass, {"media_player.streamer": 2}) + runtime.decoder_pool = pool + + base_options = { + CONF_SOURCE_NAME.format(2): "Cambridge", + CONF_SOURCE_DEFAULTS: {"3": 2, "4": 2, "5": 2, "6": 2, "7": 2, "8": 2}, + } + mock_gateway.config_entry = MagicMock(options=dict(base_options)) + + # All six amplifiers in the house (where: 31, 41, 51, 61, 71, 81) + zones = {} + for where in ("31", "41", "51", "61", "71", "81"): + z = _create_test_zone(hass, mock_gateway, runtime, where, f"media_player.zone_{where}") + z._options = lambda: dict(base_options) + z._gateway_handler.config_entry.options = dict(base_options) + z._attr_state = MediaPlayerState.OFF + zones[where] = z + + # Replay all 30 frames in authentic sequence + for frame_entry in frames: + raw = frame_entry["raw"] + target = _resolve_where(raw) + if target and target in zones: + parsed = OWNSoundEvent.parse(raw) + if parsed: + zones[target].handle_event(parsed) + + await hass.async_block_till_done() + + # 1. Verify every amplifier remains strictly MediaPlayerState.OFF + for where, z in zones.items(): + assert z.state == MediaPlayerState.OFF, f"Zone {where} woke to {z.state}" + assert pool.get_leader(z.entity_id) is None + assert z.group_members is None + + # 2. Verify restored volume levels + assert zones["51"].volume_level == pytest.approx(0.0) + assert zones["31"].volume_level == pytest.approx(1 / 31.0) + assert zones["41"].volume_level == pytest.approx(6 / 31.0) + assert zones["61"].volume_level == pytest.approx(7 / 31.0) + assert zones["71"].volume_level == pytest.approx(1 / 31.0) # remained at ducked level + assert zones["81"].volume_level == pytest.approx(1 / 31.0) + + +@pytest.mark.asyncio +async def test_mh202_intercom_ducking_with_concurrent_active_stream(hass, mock_gateway): + """Intercom ducking trace replay while an independent group is actively streaming.""" + with open(FIXTURE_PATH, encoding="utf-8") as f: + trace_data = json.load(f) + + runtime = MyHOMERuntimeData(gateway=mock_gateway) + pool = DecoderPool(hass, {"media_player.streamer": 2}) + runtime.decoder_pool = pool + hass.states.async_set("media_player.streamer", MediaPlayerState.PLAYING) + + base_options = { + CONF_SOURCE_NAME.format(2): "Cambridge", + CONF_SOURCE_DEFAULTS: {"2": 2, "3": 2, "4": 2, "5": 2, "6": 2, "7": 2, "8": 2}, + } + mock_gateway.config_entry = MagicMock(options=dict(base_options)) + + # Active group: leader (zone 21) and member (zone 22) + leader = _create_test_zone(hass, mock_gateway, runtime, "21", "media_player.living_room") + member = _create_test_zone(hass, mock_gateway, runtime, "22", "media_player.kitchen") + for z in (leader, member): + z._options = lambda: dict(base_options) + z._gateway_handler.config_entry.options = dict(base_options) + z._attr_state = MediaPlayerState.ON + z._attr_source = "Cambridge" + + await pool.claim("media_player.living_room", preferred_source=2) + leader._active_decoder = "media_player.streamer" + await pool.add_member("media_player.living_room", "media_player.kitchen") + + # Inactive zones from trace + inactive_zones = {} + for where in ("31", "41", "51", "61", "71", "81"): + z = _create_test_zone(hass, mock_gateway, runtime, where, f"media_player.zone_{where}") + z._options = lambda: dict(base_options) + z._gateway_handler.config_entry.options = dict(base_options) + z._attr_state = MediaPlayerState.OFF + inactive_zones[where] = z + + # Replay all frames + for frame_entry in trace_data["frames"]: + raw = frame_entry["raw"] + target = _resolve_where(raw) + if target and target in inactive_zones: + parsed = OWNSoundEvent.parse(raw) + if parsed: + inactive_zones[target].handle_event(parsed) + + await hass.async_block_till_done() + + # Active group is undisturbed + assert leader.state == MediaPlayerState.PLAYING + assert member.state == MediaPlayerState.PLAYING + assert leader.group_members == ["media_player.living_room", "media_player.kitchen"] + assert member.group_members == ["media_player.living_room", "media_player.kitchen"] + + # Inactive zones stayed OFF and were never added to the group + for where, z in inactive_zones.items(): + assert z.state == MediaPlayerState.OFF + assert z.group_members is None + assert pool.get_leader(z.entity_id) is None + + +@pytest.mark.asyncio +async def test_mh202_trace_full_dispatcher_pipeline(hass, mock_gateway): + """Feed the trace directly into the gateway dispatcher to verify message filtering.""" + with open(FIXTURE_PATH, encoding="utf-8") as f: + trace_data = json.load(f) + + runtime = MyHOMERuntimeData(gateway=mock_gateway) + pool = DecoderPool(hass, {"media_player.streamer": 2}) + runtime.decoder_pool = pool + mac = mock_gateway.mac + + base_options = { + CONF_SOURCE_NAME.format(2): "Cambridge", + CONF_SOURCE_DEFAULTS: {"4": 2}, + } + mock_gateway.config_entry = MagicMock(options=dict(base_options)) + + zone41 = _create_test_zone(hass, mock_gateway, runtime, "41", "media_player.zone_41") + zone41._options = lambda: dict(base_options) + zone41._gateway_handler.config_entry.options = dict(base_options) + zone41._attr_state = MediaPlayerState.OFF + + # Simulate messages reaching the entity via dispatcher + for frame_entry in trace_data["frames"]: + raw = frame_entry["raw"] + parsed = OWNMessage.parse(raw) + if isinstance(parsed, OWNSoundEvent) and parsed.where == "41": + zone41.handle_event(parsed) + else: + # Send through dispatcher channel to ensure no unhandled exceptions + async_dispatcher_send(hass, f"myhome_message_{mac}", parsed) + + await hass.async_block_till_done() + + # Zone 41 stayed off, restored to volume 6 + assert zone41.state == MediaPlayerState.OFF + assert zone41.volume_level == pytest.approx(6 / 31.0) diff --git a/tests/test_media_player_power.py b/tests/test_media_player_power.py new file mode 100644 index 000000000..2dd083f81 --- /dev/null +++ b/tests/test_media_player_power.py @@ -0,0 +1,280 @@ +"""Unit tests for the centralised media_player_power module.""" + +from __future__ import annotations + +from unittest.mock import MagicMock + +import pytest +from homeassistant.components.media_player.const import MediaPlayerState +from OWNd.message import OWNSoundEvent + +from custom_components.myhome.media_player_power import ( + AUTO_OFF_IDLE_DELAY, + AUTO_OFF_PAUSED_DELAY, + DECODER_PLAYING_STATES, + GROUP_LEAVE_GRACE, + WAKE_ECHO_WINDOW, + PowerTransition, + calculate_auto_off_delay, + determine_power_transition, + is_dimension_1_volume_report, + is_volume_rocker_down, + is_volume_rocker_up, +) + + +def test_timing_constants(): + """Verify standard anti-hiss and lifecycle timing constants.""" + assert AUTO_OFF_IDLE_DELAY == 3.0 + assert AUTO_OFF_PAUSED_DELAY == 60.0 + assert GROUP_LEAVE_GRACE == 5.0 + assert WAKE_ECHO_WINDOW == 3.0 + assert "playing" in DECODER_PLAYING_STATES + assert "buffering" in DECODER_PLAYING_STATES + assert MediaPlayerState.PLAYING in DECODER_PLAYING_STATES + assert MediaPlayerState.BUFFERING in DECODER_PLAYING_STATES + + +@pytest.mark.parametrize("what_val", range(1001, 1016)) +def test_is_volume_rocker_up_valid(what_val): + """Frames *16*1001*WHERE## to *16*1015*WHERE## represent volume-up steps.""" + msg = MagicMock() + msg.is_off = False + msg.what = what_val + assert is_volume_rocker_up(msg) is True + + +@pytest.mark.parametrize("what_val", [1000, 1016, 1101, 1102, 1, 3, 13, "invalid", None]) +def test_is_volume_rocker_up_invalid(what_val): + """Non-rocker-up frames return False.""" + msg = MagicMock() + msg.is_off = False + msg.what = what_val + assert is_volume_rocker_up(msg) is False + + +def test_is_volume_rocker_up_when_is_off_is_false(): + """If a message has is_off=True, it cannot be considered a volume up rocker.""" + msg = MagicMock() + msg.is_off = True + msg.what = 1001 + assert is_volume_rocker_up(msg) is False + + +@pytest.mark.parametrize("what_val", range(1101, 1116)) +def test_is_volume_rocker_down_valid(what_val): + """Frames *16*1101*WHERE## to *16*1115*WHERE## represent volume-down steps.""" + msg = MagicMock() + msg.what = what_val + assert is_volume_rocker_down(msg) is True + + +@pytest.mark.parametrize("what_val", [1100, 1116, 1001, 1, 3, 13, "invalid", None]) +def test_is_volume_rocker_down_invalid(what_val): + """Non-rocker-down frames return False.""" + msg = MagicMock() + msg.what = what_val + assert is_volume_rocker_down(msg) is False + + +def test_is_dimension_1_volume_report(): + """Dimension 1 reports volume status (0..31).""" + # Authentic WHO 16 volume report: *#16*41*1*1## + msg16 = OWNSoundEvent.parse("*#16*41*1*1##") + assert is_dimension_1_volume_report(msg16) is True + + # Authentic WHO 16 restored volume report: *#16*41*1*6## + msg_restored = OWNSoundEvent.parse("*#16*41*1*6##") + assert is_dimension_1_volume_report(msg_restored) is True + + # Generic mock with dimension=1 + mock_dim1 = MagicMock(dimension=1, volume=10) + assert is_dimension_1_volume_report(mock_dim1) is True + + # Rocker up command is NOT a dimension 1 volume report + mock_rocker = MagicMock(dimension=None, volume=None, what=1001, is_off=False) + assert is_dimension_1_volume_report(mock_rocker) is False + + # Status request is NOT a volume report + status_msg = OWNSoundEvent.parse("*#16*0*5##") + assert is_dimension_1_volume_report(status_msg) is False + + +def test_determine_power_transition_source_events(): + """Source device events (*16*3*10S##) must never modify zone power state.""" + msg = MagicMock(is_source_event=True, is_on=True, is_off=False) + transition = determine_power_transition( + message=msg, + current_state=MediaPlayerState.OFF, + is_parked=False, + is_wake_echo=False, + ) + assert transition == PowerTransition.NO_CHANGE + + +def test_determine_power_transition_off_frames(): + """Explicit OFF frames are classified according to wake-echo and parked status.""" + off_msg = MagicMock(is_source_event=False, is_off=True, is_on=False) + + # 1. Wake echo OFF + assert ( + determine_power_transition( + off_msg, + current_state=MediaPlayerState.ON, + is_parked=False, + is_wake_echo=True, + ) + == PowerTransition.WAKE_ECHO_OFF + ) + + # 2. Parked confirmation OFF + assert ( + determine_power_transition( + off_msg, + current_state=MediaPlayerState.OFF, + is_parked=True, + is_wake_echo=False, + ) + == PowerTransition.PARKED_CONFIRM_OFF + ) + + # 3. Real external OFF + assert ( + determine_power_transition( + off_msg, + current_state=MediaPlayerState.ON, + is_parked=False, + is_wake_echo=False, + ) + == PowerTransition.REAL_OFF + ) + + +def test_determine_power_transition_on_frames(): + """Explicit ON frames (*16*3*WHERE## / *16*0*WHERE##) wake the amplifier.""" + on_msg = MagicMock(is_source_event=False, is_on=True, is_off=False) + assert ( + determine_power_transition( + on_msg, + current_state=MediaPlayerState.OFF, + is_parked=False, + is_wake_echo=False, + ) + == PowerTransition.WAKE_ON + ) + + +def test_determine_power_transition_rocker_up(): + """Physical volume-up rocker commands wake the amplifier.""" + rocker_msg = MagicMock( + is_source_event=False, + is_on=False, + is_off=False, + what=1001, + ) + assert ( + determine_power_transition( + rocker_msg, + current_state=MediaPlayerState.OFF, + is_parked=False, + is_wake_echo=False, + ) + == PowerTransition.WAKE_ON + ) + + +def test_determine_power_transition_dimension_1_volume_report_never_wakes(): + """Dimension 1 volume reports (ducking/restoration) MUST NEVER change power state (#669).""" + # Ducking report to volume 1 + duck_msg = OWNSoundEvent.parse("*#16*41*1*1##") + assert ( + determine_power_transition( + duck_msg, + current_state=MediaPlayerState.OFF, + is_parked=False, + is_wake_echo=False, + ) + == PowerTransition.NO_CHANGE + ) + + # Restoration report to volume 6 + restore_msg = OWNSoundEvent.parse("*#16*41*1*6##") + assert ( + determine_power_transition( + restore_msg, + current_state=MediaPlayerState.OFF, + is_parked=False, + is_wake_echo=False, + ) + == PowerTransition.NO_CHANGE + ) + + # When amplifier is ON, volume report also does not change power state + assert ( + determine_power_transition( + restore_msg, + current_state=MediaPlayerState.ON, + is_parked=False, + is_wake_echo=False, + ) + == PowerTransition.NO_CHANGE + ) + + +def test_is_dimension_1_volume_report_fallback(): + """Verify fallback where dimension is unset but volume is present.""" + msg = MagicMock(spec=[], volume=10, what=None) + assert is_dimension_1_volume_report(msg) is True + + + +def test_determine_power_transition_routing_and_status_frames(): + """Matrix routing and status frames must never modify power state.""" + # Volume down rocker command + vol_down = MagicMock(is_source_event=False, is_on=False, is_off=False, what=1101) + assert ( + determine_power_transition( + vol_down, + current_state=MediaPlayerState.OFF, + is_parked=False, + is_wake_echo=False, + ) + == PowerTransition.NO_CHANGE + ) + + # Status response frame + status_msg = OWNSoundEvent.parse("*#16*0*5##") + assert ( + determine_power_transition( + status_msg, + current_state=MediaPlayerState.OFF, + is_parked=False, + is_wake_echo=False, + ) + == PowerTransition.NO_CHANGE + ) + + +@pytest.mark.parametrize( + ("state_val", "expected_delay"), + [ + (MediaPlayerState.PLAYING, None), + ("playing", None), + (MediaPlayerState.BUFFERING, None), + ("buffering", None), + (MediaPlayerState.IDLE, 3.0), + ("idle", 3.0), + ("standby", 3.0), + (MediaPlayerState.OFF, 3.0), + ("off", 3.0), + (MediaPlayerState.PAUSED, 60.0), + ("paused", 60.0), + ("unavailable", None), + ("unknown", None), + (None, None), + ("other_random_state", None), + ], +) +def test_calculate_auto_off_delay(state_val, expected_delay): + """Test delay computation across all media player states.""" + assert calculate_auto_off_delay(state_val) == expected_delay From 56066b05d703e23dca55c3ee8118c7629e05ec96 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:16:49 +0200 Subject: [PATCH 2/3] fix(media_player): actively evaluate dimension 1 and rocker-down in power transitions, support string dimensions, and add wake-window ducking tests --- .../myhome/media_player_power.py | 53 +++++++++++----- tests/test_intercom_ducking_trace_replay.py | 62 +++++++++++++++++++ tests/test_media_player_power.py | 58 +++++++++++++++++ 3 files changed, 156 insertions(+), 17 deletions(-) diff --git a/custom_components/myhome/media_player_power.py b/custom_components/myhome/media_player_power.py index 28572d66b..c5db9f774 100644 --- a/custom_components/myhome/media_player_power.py +++ b/custom_components/myhome/media_player_power.py @@ -62,10 +62,10 @@ def is_volume_rocker_up(message: Any) -> bool: In BTicino systems, tapping volume-up on a wall control powers on an unpowered amplifier (#579). """ - if getattr(message, "is_off", False): + if getattr(message, "is_off", False) is True: return False what = getattr(message, "what", getattr(message, "_what", None)) - if what is None: + if not isinstance(what, (int, str)): return False try: val = int(what) @@ -80,8 +80,10 @@ def is_volume_rocker_down(message: Any) -> bool: Frames *16*1101*WHERE## to *16*1115*WHERE## (step -1 to -15). Volume-down steps adjust the volume level but MUST NOT power on an amplifier. """ + if getattr(message, "is_off", False) is True: + return False what = getattr(message, "what", getattr(message, "_what", None)) - if what is None: + if not isinstance(what, (int, str)): return False try: val = int(what) @@ -101,9 +103,14 @@ def is_dimension_1_volume_report(message: Any) -> bool: These reports MUST NEVER change the amplifier power state (#669). """ dimension = getattr(message, "dimension", getattr(message, "_dimension", None)) - if dimension == 1: - return True - if getattr(message, "volume", None) is not None and not is_volume_rocker_up(message): + if isinstance(dimension, (int, str)): + try: + if int(dimension) == 1: + return True + except (ValueError, TypeError): + pass + vol = getattr(message, "volume", None) + if isinstance(vol, (int, float, str)) and not is_volume_rocker_up(message): return True return False @@ -117,26 +124,38 @@ def determine_power_transition( """Determine the power state transition for an incoming bus message. Rules: - 1. Source device events (*16*3*10S##, *16*13*10S##) relate to external source + 1. Dimension 1 volume status reports (*#16*WHERE*1*##, *#22*3#A#P*1*##) + report volume levels and must NEVER modify amplifier power state (#669, #682). + 2. Volume-down rocker commands (*16*1101..1115*WHERE##) step volume down only + and must never wake an amplifier. + 3. Source device events (*16*3*10S##, *16*13*10S##) relate to external source interfaces and never modify room amplifier power state. - 2. Explicit OFF frames (*16*13*WHERE## / *16*10*WHERE##): + 4. Explicit OFF frames (*16*13*WHERE## / *16*10*WHERE##): - If within the wake echo window: WAKE_ECHO_OFF (ignored). - If the zone is currently parked: PARKED_CONFIRM_OFF (physical confirmation, preserves group membership). - Otherwise: REAL_OFF (powers off, initiates coordinated teardown or handover). - 3. Explicit ON frames (*16*3*WHERE## / *16*0*WHERE##): + 5. Explicit ON frames (*16*3*WHERE## / *16*0*WHERE##): - WAKE_ON (sets state ON, cancels pending off, triggers auto-join). - 4. Physical volume-up rocker commands (*16*1001*WHERE## .. *16*1015*WHERE##): + 6. Physical volume-up rocker commands (*16*1001*WHERE## .. *16*1015*WHERE##): - WAKE_ON (wakes an unpowered or parked amplifier, triggers auto-join). - 5. All other frames — including Dimension 1 volume status reports, matrix routing - frames (*16*3*1ES##), Dimension 5 status responses, and volume-down commands: + 7. All other frames — including matrix routing frames (*16*3*1ES##), Dimension 5 + status responses, and unrecognised frames: - NO_CHANGE (must NEVER turn an off amplifier on). """ - # 1. Source device events never affect amplifier zone power + # 1. Dimension 1 volume status reports never alter power state (#669, #682) + if is_dimension_1_volume_report(message): + return PowerTransition.NO_CHANGE + + # 2. Volume-down rocker commands step volume only and must never wake an amplifier + if is_volume_rocker_down(message): + return PowerTransition.NO_CHANGE + + # 3. Source device events never affect amplifier zone power if getattr(message, "is_source_event", False): return PowerTransition.NO_CHANGE - # 2. Explicit OFF frames + # 4. Explicit OFF frames if getattr(message, "is_off", False): if is_wake_echo: return PowerTransition.WAKE_ECHO_OFF @@ -144,15 +163,15 @@ def determine_power_transition( return PowerTransition.PARKED_CONFIRM_OFF return PowerTransition.REAL_OFF - # 3. Explicit ON frames + # 5. Explicit ON frames if getattr(message, "is_on", False): return PowerTransition.WAKE_ON - # 4. Physical volume-up rocker command wakes amplifier + # 6. Physical volume-up rocker command wakes amplifier if is_volume_rocker_up(message): return PowerTransition.WAKE_ON - # 5. Non-power frames (volume reports, routing, status queries) + # 7. Non-power frames (routing, status queries, high/low tones, etc.) return PowerTransition.NO_CHANGE diff --git a/tests/test_intercom_ducking_trace_replay.py b/tests/test_intercom_ducking_trace_replay.py index c6fcb6514..6d62424eb 100644 --- a/tests/test_intercom_ducking_trace_replay.py +++ b/tests/test_intercom_ducking_trace_replay.py @@ -210,3 +210,65 @@ async def test_mh202_trace_full_dispatcher_pipeline(hass, mock_gateway): # Zone 41 stayed off, restored to volume 6 assert zone41.state == MediaPlayerState.OFF assert zone41.volume_level == pytest.approx(6 / 31.0) + + +@pytest.mark.asyncio +async def test_concurrent_intercom_ducking_during_wake_pending_window(hass, mock_gateway): + """Intercom call arrives while an amplifier is in the middle of a wake-up sequence. + + Tests the critical edge case where an unpowered amplifier receives a turn_on command + (initiating wake sequence and arming the 3.0s wake-echo window), and during this window + an intercom ducking frame (*#16*41*1*1##) arrives, followed by the wake echo OFF + (*16*13*41##), the wake ON (*16*3*41##), and finally intercom restoration (*#16*41*1*6##). + + Expected: + 1. Ducking adjusts volume without aborting the wake sequence. + 2. Echo OFF is ignored as self-echo within the wake window. + 3. Wake ON confirms amplifier is active. + 4. Intercom restoration updates volume to 6/31.0 and amplifier remains ON. + """ + import time + + runtime = MyHOMERuntimeData(gateway=mock_gateway) + pool = DecoderPool(hass, {"media_player.streamer": 2}) + runtime.decoder_pool = pool + + base_options = { + CONF_SOURCE_NAME.format(2): "Cambridge", + CONF_SOURCE_DEFAULTS: {"4": 2}, + } + mock_gateway.config_entry = MagicMock(options=dict(base_options)) + + zone = _create_test_zone(hass, mock_gateway, runtime, "41", "media_player.zone_41") + zone._options = lambda: dict(base_options) + zone._gateway_handler.config_entry.options = dict(base_options) + zone._attr_state = MediaPlayerState.OFF + + # Simulate wake sequence initiation + zone._wake_off_sent_at = time.monotonic() + zone._attr_state = MediaPlayerState.ON + assert zone._is_wake_echo() is True + + # 1. Door entry ducking arrives during wake echo window + duck_frame = OWNSoundEvent.parse("*#16*41*1*1##") + zone.handle_event(duck_frame) + assert zone.state == MediaPlayerState.ON + assert zone.volume_level == pytest.approx(1 / 31.0) + + # 2. Gateway echoes the wake sequence's initial OFF frame + echo_off = OWNSoundEvent.parse("*16*13*41##") + zone.handle_event(echo_off) + # Must NOT turn off the amplifier + assert zone.state == MediaPlayerState.ON + + # 3. Gateway confirms wake sequence ON frame + wake_on = OWNSoundEvent.parse("*16*3*41##") + zone.handle_event(wake_on) + assert zone.state == MediaPlayerState.ON + + # 4. Intercom call ends and restoration frame arrives + restore_frame = OWNSoundEvent.parse("*#16*41*1*6##") + zone.handle_event(restore_frame) + assert zone.state == MediaPlayerState.ON + assert zone.volume_level == pytest.approx(6 / 31.0) + diff --git a/tests/test_media_player_power.py b/tests/test_media_player_power.py index 2dd083f81..4bbefb1e5 100644 --- a/tests/test_media_player_power.py +++ b/tests/test_media_player_power.py @@ -278,3 +278,61 @@ def test_determine_power_transition_routing_and_status_frames(): def test_calculate_auto_off_delay(state_val, expected_delay): """Test delay computation across all media player states.""" assert calculate_auto_off_delay(state_val) == expected_delay + + +def test_is_dimension_1_volume_report_string_dimension(): + """String dimension '1' (e.g. from JSON trace) must be recognized.""" + msg = MagicMock(spec=[], dimension="1", volume=None, what=None, is_off=False) + assert is_dimension_1_volume_report(msg) is True + + # Invalid dimension string + msg_inv = MagicMock(spec=[], dimension="not_an_int", volume=None, what=None, is_off=False) + assert is_dimension_1_volume_report(msg_inv) is False + + +def test_is_volume_rocker_down_when_is_off_is_true(): + """If a message has is_off=True, it cannot be considered a volume down rocker.""" + msg = MagicMock() + msg.is_off = True + msg.what = 1101 + assert is_volume_rocker_down(msg) is False + + +def test_determine_power_transition_dimension_1_overrides_spurious_is_on(): + """Even if a malformed/mocked frame carries is_on=True, dimension 1 takes precedence.""" + msg = MagicMock(dimension=1, volume=10, is_on=True, is_off=False, is_source_event=False) + assert ( + determine_power_transition( + msg, + current_state=MediaPlayerState.OFF, + is_parked=False, + is_wake_echo=False, + ) + == PowerTransition.NO_CHANGE + ) + + +@pytest.mark.parametrize( + "what_val", + [1000, 1016, 1100, 1116, 2001, 2101, 5000, 5001, 6001, 9999], +) +def test_determine_power_transition_non_power_what_codes(what_val): + """Non-standard rocker or audio parameter WHAT codes never change power state.""" + msg = MagicMock( + is_source_event=False, + is_on=False, + is_off=False, + what=what_val, + dimension=None, + volume=None, + ) + assert ( + determine_power_transition( + msg, + current_state=MediaPlayerState.OFF, + is_parked=False, + is_wake_echo=False, + ) + == PowerTransition.NO_CHANGE + ) + From 541b682c084a05458b009c8e78cd46ce231f8ae6 Mon Sep 17 00:00:00 2001 From: Laurens <43173895+GreenGrassBlueOcean@users.noreply.github.com> Date: Sat, 10 Oct 2026 11:30:26 +0200 Subject: [PATCH 3/3] fix(media_player): address audit feedback on dimension-1 fallback guards and drop unused current_state --- custom_components/myhome/media_player.py | 1 - .../myhome/media_player_power.py | 22 ++++++++--- tests/test_media_player_power.py | 37 +++++++++++-------- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/custom_components/myhome/media_player.py b/custom_components/myhome/media_player.py index a26dad68f..cd96f0cfe 100644 --- a/custom_components/myhome/media_player.py +++ b/custom_components/myhome/media_player.py @@ -954,7 +954,6 @@ def handle_event(self, message: OWNSoundEvent) -> None: else: transition = determine_power_transition( message=message, - current_state=self._attr_state, is_parked=self._parked, is_wake_echo=self._is_wake_echo(), ) diff --git a/custom_components/myhome/media_player_power.py b/custom_components/myhome/media_player_power.py index c5db9f774..8c875f449 100644 --- a/custom_components/myhome/media_player_power.py +++ b/custom_components/myhome/media_player_power.py @@ -100,24 +100,34 @@ def is_dimension_1_volume_report(message: Any) -> bool: BTicino amplifiers retain their configured volume in hardware registers even when turned OFF. During video intercom door entry calls, the matrix broadcasts hardware audio ducking and volume restoration frames for all zones in the house. - These reports MUST NEVER change the amplifier power state (#669). + These reports MUST NEVER change the amplifier power state (#669, #682). """ dimension = getattr(message, "dimension", getattr(message, "_dimension", None)) if isinstance(dimension, (int, str)): try: - if int(dimension) == 1: - return True + return int(dimension) == 1 except (ValueError, TypeError): - pass + return False + + # Fallback for synthetic test fixtures / partial mock events where `volume` + # is populated on an event object that omits the dimension attribute. + # To guard against suppressing legitimate transitions on future event models: + # 1. Do not match if the message explicitly signals ON or OFF. + # 2. Do not match if it is a volume rocker command. vol = getattr(message, "volume", None) - if isinstance(vol, (int, float, str)) and not is_volume_rocker_up(message): + if ( + isinstance(vol, (int, float, str)) + and not getattr(message, "is_on", False) + and not getattr(message, "is_off", False) + and not is_volume_rocker_up(message) + and not is_volume_rocker_down(message) + ): return True return False def determine_power_transition( message: Any, - current_state: MediaPlayerState | None, is_parked: bool, is_wake_echo: bool, ) -> PowerTransition: diff --git a/tests/test_media_player_power.py b/tests/test_media_player_power.py index 4bbefb1e5..352ec93d4 100644 --- a/tests/test_media_player_power.py +++ b/tests/test_media_player_power.py @@ -105,7 +105,6 @@ def test_determine_power_transition_source_events(): msg = MagicMock(is_source_event=True, is_on=True, is_off=False) transition = determine_power_transition( message=msg, - current_state=MediaPlayerState.OFF, is_parked=False, is_wake_echo=False, ) @@ -120,7 +119,6 @@ def test_determine_power_transition_off_frames(): assert ( determine_power_transition( off_msg, - current_state=MediaPlayerState.ON, is_parked=False, is_wake_echo=True, ) @@ -131,7 +129,6 @@ def test_determine_power_transition_off_frames(): assert ( determine_power_transition( off_msg, - current_state=MediaPlayerState.OFF, is_parked=True, is_wake_echo=False, ) @@ -142,7 +139,6 @@ def test_determine_power_transition_off_frames(): assert ( determine_power_transition( off_msg, - current_state=MediaPlayerState.ON, is_parked=False, is_wake_echo=False, ) @@ -156,7 +152,6 @@ def test_determine_power_transition_on_frames(): assert ( determine_power_transition( on_msg, - current_state=MediaPlayerState.OFF, is_parked=False, is_wake_echo=False, ) @@ -175,7 +170,6 @@ def test_determine_power_transition_rocker_up(): assert ( determine_power_transition( rocker_msg, - current_state=MediaPlayerState.OFF, is_parked=False, is_wake_echo=False, ) @@ -190,7 +184,6 @@ def test_determine_power_transition_dimension_1_volume_report_never_wakes(): assert ( determine_power_transition( duck_msg, - current_state=MediaPlayerState.OFF, is_parked=False, is_wake_echo=False, ) @@ -202,7 +195,6 @@ def test_determine_power_transition_dimension_1_volume_report_never_wakes(): assert ( determine_power_transition( restore_msg, - current_state=MediaPlayerState.OFF, is_parked=False, is_wake_echo=False, ) @@ -213,7 +205,6 @@ def test_determine_power_transition_dimension_1_volume_report_never_wakes(): assert ( determine_power_transition( restore_msg, - current_state=MediaPlayerState.ON, is_parked=False, is_wake_echo=False, ) @@ -222,10 +213,30 @@ def test_determine_power_transition_dimension_1_volume_report_never_wakes(): def test_is_dimension_1_volume_report_fallback(): - """Verify fallback where dimension is unset but volume is present.""" - msg = MagicMock(spec=[], volume=10, what=None) + """Verify fallback where dimension is unset but scalar volume is present.""" + # When dimension is None and not on/off or rocker, scalar volume is treated as volume report + msg = MagicMock(spec=[], dimension=None, volume=10, what=None, is_off=False, is_on=False) assert is_dimension_1_volume_report(msg) is True + # If dimension is explicitly something else (e.g. 5, 2, 8), presence of volume does NOT make it Dim 1 + msg_other_dim = MagicMock(spec=[], dimension=5, volume=10, what=None, is_off=False, is_on=False) + assert is_dimension_1_volume_report(msg_other_dim) is False + + # String non-1 dimension does NOT trigger fallback + msg_other_dim_str = MagicMock(spec=[], dimension="5", volume=10, what=None, is_off=False, is_on=False) + assert is_dimension_1_volume_report(msg_other_dim_str) is False + + # If dimension is None, but message is explicitly ON or OFF, fallback does not match + msg_on = MagicMock(spec=[], dimension=None, volume=10, what=None, is_on=True, is_off=False) + assert is_dimension_1_volume_report(msg_on) is False + msg_off = MagicMock(spec=[], dimension=None, volume=10, what=None, is_on=False, is_off=True) + assert is_dimension_1_volume_report(msg_off) is False + + # If dimension is None, but message is a rocker command, fallback does not match + msg_rocker_up = MagicMock(spec=[], dimension=None, volume=10, what=1001, is_on=False, is_off=False) + assert is_dimension_1_volume_report(msg_rocker_up) is False + msg_rocker_down = MagicMock(spec=[], dimension=None, volume=10, what=1101, is_on=False, is_off=False) + assert is_dimension_1_volume_report(msg_rocker_down) is False def test_determine_power_transition_routing_and_status_frames(): @@ -235,7 +246,6 @@ def test_determine_power_transition_routing_and_status_frames(): assert ( determine_power_transition( vol_down, - current_state=MediaPlayerState.OFF, is_parked=False, is_wake_echo=False, ) @@ -247,7 +257,6 @@ def test_determine_power_transition_routing_and_status_frames(): assert ( determine_power_transition( status_msg, - current_state=MediaPlayerState.OFF, is_parked=False, is_wake_echo=False, ) @@ -304,7 +313,6 @@ def test_determine_power_transition_dimension_1_overrides_spurious_is_on(): assert ( determine_power_transition( msg, - current_state=MediaPlayerState.OFF, is_parked=False, is_wake_echo=False, ) @@ -329,7 +337,6 @@ def test_determine_power_transition_non_power_what_codes(what_val): assert ( determine_power_transition( msg, - current_state=MediaPlayerState.OFF, is_parked=False, is_wake_echo=False, )