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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ We now maintain a comprehensive, community-curated **[GitHub Wiki](https://githu
| **MH200** | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | ✅ | ✅ | | | | ✅ | ✅ | |
| **MH200N** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
| **MH201** | ✅ | ✅ | ✅ | ✅ | | | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | | | |
| **MH202** | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | | | ✅ | | ✅ | | ✅ | |
| **MH202** | | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | ✅ | ✅ | | ✅ | |
| **MyHomeServer1** | | ✅ | ✅ | ✅ | | ✅ | ✅ | ✅ | ✅ | ✅ | | ✅ | | ✅ | ✅ | ✅ | |
<!-- TRACE_MATRIX_END -->

Expand Down
63 changes: 24 additions & 39 deletions custom_components/myhome/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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


Expand Down Expand Up @@ -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
Expand All @@ -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*<volume>##, WHO 22 mirror
# *#22*3#A#P*1*<volume>##) 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
Expand Down
37 changes: 16 additions & 21 deletions custom_components/myhome/media_player_decoder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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:
Expand Down
205 changes: 205 additions & 0 deletions custom_components/myhome/media_player_power.py
Original file line number Diff line number Diff line change
@@ -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*<vol>##, *#22*3#A#P*1*<vol>##)
- 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*<vol>##, *#22*3#A#P*1*<vol>##) 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*<vol>##, *#22*3#A#P*1*<vol>##)
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
Loading
Loading