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..cd96f0cfe 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,29 @@ 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, + 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 +983,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..8c875f449 --- /dev/null +++ b/custom_components/myhome/media_player_power.py @@ -0,0 +1,205 @@ +"""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) is True: + return False + what = getattr(message, "what", getattr(message, "_what", None)) + if not isinstance(what, (int, str)): + 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. + """ + if getattr(message, "is_off", False) is True: + return False + what = getattr(message, "what", getattr(message, "_what", None)) + if not isinstance(what, (int, str)): + 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, #682). + """ + dimension = getattr(message, "dimension", getattr(message, "_dimension", None)) + if isinstance(dimension, (int, str)): + try: + return int(dimension) == 1 + except (ValueError, TypeError): + 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 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, + is_parked: bool, + is_wake_echo: bool, +) -> PowerTransition: + """Determine the power state transition for an incoming bus message. + + Rules: + 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. + 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). + 5. Explicit ON frames (*16*3*WHERE## / *16*0*WHERE##): + - WAKE_ON (sets state ON, cancels pending off, triggers auto-join). + 6. Physical volume-up rocker commands (*16*1001*WHERE## .. *16*1015*WHERE##): + - WAKE_ON (wakes an unpowered or parked amplifier, triggers auto-join). + 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. 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 + + # 4. 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 + + # 5. Explicit ON frames + if getattr(message, "is_on", False): + return PowerTransition.WAKE_ON + + # 6. Physical volume-up rocker command wakes amplifier + if is_volume_rocker_up(message): + return PowerTransition.WAKE_ON + + # 7. Non-power frames (routing, status queries, high/low tones, etc.) + 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..6d62424eb --- /dev/null +++ b/tests/test_intercom_ducking_trace_replay.py @@ -0,0 +1,274 @@ +"""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) + + +@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 new file mode 100644 index 000000000..352ec93d4 --- /dev/null +++ b/tests/test_media_player_power.py @@ -0,0 +1,345 @@ +"""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, + 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, + is_parked=False, + is_wake_echo=True, + ) + == PowerTransition.WAKE_ECHO_OFF + ) + + # 2. Parked confirmation OFF + assert ( + determine_power_transition( + off_msg, + is_parked=True, + is_wake_echo=False, + ) + == PowerTransition.PARKED_CONFIRM_OFF + ) + + # 3. Real external OFF + assert ( + determine_power_transition( + off_msg, + 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, + 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, + 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, + 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, + 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, + 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 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(): + """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, + 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, + 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 + + +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, + 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, + is_parked=False, + is_wake_echo=False, + ) + == PowerTransition.NO_CHANGE + ) +