diff --git a/custom_components/myhome/config_flow.py b/custom_components/myhome/config_flow.py index 2b5c226c..b252151c 100644 --- a/custom_components/myhome/config_flow.py +++ b/custom_components/myhome/config_flow.py @@ -52,6 +52,7 @@ CONF_SOURCE_DEFAULTS, CONF_SOURCE_NAME, CONF_SOURCE_SLOTS, + CONF_SOURCE_TUNER, CONF_SSDP_LOCATION, CONF_SSDP_ST, CONF_TRANSITION_MODE, @@ -798,6 +799,8 @@ async def async_step_user(self, user_input=None, errors=None): # type: ignore for i in range(1, CONF_SOURCE_SLOTS + 1): name_key = CONF_SOURCE_NAME.format(i) self.options[name_key] = str(user_input.get(name_key, "") or "").strip() # type: ignore + tuner_key = CONF_SOURCE_TUNER.format(i) + self.options[tuner_key] = bool(user_input.get(tuner_key, False)) # type: ignore # Persist decoder slots for i in range(1, CONF_DECODER_SLOTS + 1): @@ -906,6 +909,14 @@ async def async_step_user(self, user_input=None, errors=None): # type: ignore name_key, description={"suggested_value": _name}, )] = selector.TextSelector() + # A tuner accepts frequency, station and RDS messages that a line + # interface does not, and nothing on the bus tells them apart until + # the device speaks, so the user declares it. + tuner_key = CONF_SOURCE_TUNER.format(i) + schema_dict[vol.Required( + tuner_key, + default=bool(self.options.get(tuner_key, False)), # type: ignore + )] = selector.BooleanSelector() # Default source per environment — only for environments that have zones. _stored_defaults = self.options.get(CONF_SOURCE_DEFAULTS) or {} # type: ignore diff --git a/custom_components/myhome/const.py b/custom_components/myhome/const.py index 4eebe135..b7764144 100644 --- a/custom_components/myhome/const.py +++ b/custom_components/myhome/const.py @@ -131,6 +131,15 @@ def get_ownd_version() -> str: CONF_SOURCE_NAME = "source_{}_name" # Friendly name, e.g. "Cambridge" CONF_SOURCE_SLOTS = 4 # Matrix inputs S1-S4 SOURCE_UNCONFIGURED_SUFFIX = " (not configured)" +# A source that is a tuner (F500 / F500N) accepts frequency, station and RDS +# messages that an RCA interface does not. The user declares it, because a +# source device that has not spoken yet is indistinguishable on the bus. +CONF_SOURCE_TUNER = "source_{}_tuner" +#: Stored stations a WHO=16 tuner exposes (5 for F500, up to 15 for F500N). +TUNER_STATION_COUNT = 5 +TUNER_MAX_STATION_COUNT = 15 +SERVICE_TUNER_SEEK_UP = "tuner_seek_up" +SERVICE_TUNER_SEEK_DOWN = "tuner_seek_down" # Default source per environment. The F441M routes per output and an output # serves one environment, so a default belongs to an environment, not to a diff --git a/custom_components/myhome/icons.json b/custom_components/myhome/icons.json index 3d8a4293..8ab68890 100644 --- a/custom_components/myhome/icons.json +++ b/custom_components/myhome/icons.json @@ -26,6 +26,13 @@ }, "reset_cover_travel_time": { "service": "mdi:timer-refresh-outline" + }, + "tuner_seek_up": { + "service": "mdi:fast-forward-outline" + }, + "tuner_seek_down": { + "service": "mdi:rewind-outline" } } } + diff --git a/custom_components/myhome/media_player.py b/custom_components/myhome/media_player.py index 810dc0cc..ea00766a 100644 --- a/custom_components/myhome/media_player.py +++ b/custom_components/myhome/media_player.py @@ -83,6 +83,7 @@ from homeassistant.const import Platform from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_platform from homeassistant.helpers import entity_registry as er from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -97,8 +98,11 @@ CONF_SOURCE_DEFAULTS, CONF_SOURCE_NAME, CONF_SOURCE_SLOTS, + CONF_SOURCE_TUNER, DOMAIN, LOGGER, + SERVICE_TUNER_SEEK_DOWN, + SERVICE_TUNER_SEEK_UP, SOURCE_UNCONFIGURED_SUFFIX, ) from .data import MyHOMEConfigEntry, MyHOMERuntimeData, get_runtime_data @@ -110,6 +114,7 @@ async_delete_incompatible_decoder_issue, async_prune_incompatible_decoder_issues, ) +from .sound_source import MyHOMESoundSource, source_address if TYPE_CHECKING: from .gateway import MyHOMEGatewayHandler @@ -206,15 +211,37 @@ def build(ctx: DeviceContext) -> MyHOMEMediaPlayer: gateway=runtime.gateway, ) + # Declared tuner sources exist before any bus traffic; zones are discovered. + sound_sources = _build_sound_sources(hass, config_entry, runtime.gateway) + for source in sound_sources: + source.async_on_remove( + runtime.router.subscribe("16", [source.device_key], source.handle_event) + ) + if sound_sources: + async_add_entities(sound_sources) + discovery = PlatformDiscovery( hass, config_entry, async_add_entities, platform=Platform.MEDIA_PLAYER, who="16", event_type=OWNSoundEvent, build=build, address=_zone_address, pre_message=_route_pseudo_zones(runtime.router), - key_suffix="#16", + route_keys=_sound_route_keys, key_suffix="#16", ) # Audio zones are keyed "#16" in unique ids; the registry restore reads that key back. discovery.start() + platform = entity_platform.current_platform.get() + if platform is not None: + platform.async_register_entity_service( + SERVICE_TUNER_SEEK_UP, + {}, + "async_seek_up", + ) + platform.async_register_entity_service( + SERVICE_TUNER_SEEK_DOWN, + {}, + "async_seek_down", + ) + def _zone_address(message: Any) -> Address | None: """Sound-system frames address a zone (amplifier); sources are never devices. @@ -229,6 +256,44 @@ def _zone_address(message: Any) -> Address | None: return Address(str(zone), key_suffix="#16") +def _sound_route_keys(message: Any, address: Address | None) -> list[str]: + """Return the entity keys a WHO=16 frame belongs to. + + Source frames carry no zone address, so without an explicit key they would + be dropped before reaching a declared tuner entity. + """ + if getattr(message, "is_source_event", False): + where = str(getattr(message, "zone", "") or "") + return [f"{where}#16"] if where else [] + return [address.key] if address is not None else [] + + +def _build_sound_sources( + hass: HomeAssistant, config_entry: MyHOMEConfigEntry, gateway: Any +) -> list[MyHOMESoundSource]: + """Create an entity for every matrix input the user declared to be a tuner.""" + options = config_entry.options + sources: list[MyHOMESoundSource] = [] + for i in range(1, CONF_SOURCE_SLOTS + 1): + if not options.get(CONF_SOURCE_TUNER.format(i)): + continue + where = source_address(i) + name = str(options.get(CONF_SOURCE_NAME.format(i), "") or "").strip() + sources.append( + MyHOMESoundSource( + hass=hass, + name=name or f"Audio Source {i}", + device_id=f"{where}#16", + who="16", + where=where, + manufacturer="BTicino", + model="Audio Source", + gateway=gateway, + ) + ) + return sources + + def _zone_environment(zone: str) -> str | None: """Return the environment (room) an amplifier address belongs to. @@ -1623,3 +1688,21 @@ def handle_event(self, message: OWNSoundEvent) -> None: self._attr_is_volume_muted = False self._publish_state() + + async def async_seek_up(self) -> None: + """Seek forward on the tuner; only valid on tuner source entities.""" + raise HomeAssistantError( + f"{self.entity_id}: seek is only supported on tuner source entities", + translation_domain=DOMAIN, + translation_key="seek_not_supported", + translation_placeholders={"entity_id": str(self.entity_id)}, + ) + + async def async_seek_down(self) -> None: + """Seek backward on the tuner; only valid on tuner source entities.""" + raise HomeAssistantError( + f"{self.entity_id}: seek is only supported on tuner source entities", + translation_domain=DOMAIN, + translation_key="seek_not_supported", + translation_placeholders={"entity_id": str(self.entity_id)}, + ) diff --git a/custom_components/myhome/quality_scale.yaml b/custom_components/myhome/quality_scale.yaml index 99f5aa15..eb3f4e4d 100644 --- a/custom_components/myhome/quality_scale.yaml +++ b/custom_components/myhome/quality_scale.yaml @@ -31,7 +31,7 @@ rules: comment: OWNd is published on PyPI from the public OpenWebNet-HA/OWNd repository with CI; MIT/GPL licensed. docs-actions: status: done - comment: docs/configuration/services.md documents all nine services and their fields (services.yaml, strings.json and icons.json list the same nine). + comment: docs/configuration/services.md documents all eleven services and their fields (services.yaml, strings.json and icons.json list the same eleven). docs-triggers: status: done comment: docs/configuration/cen_cenplus.md documents CEN / CEN+ device triggers. diff --git a/custom_components/myhome/services.yaml b/custom_components/myhome/services.yaml index 578ba6ee..a0970007 100644 --- a/custom_components/myhome/services.yaml +++ b/custom_components/myhome/services.yaml @@ -197,3 +197,19 @@ reset_cover_travel_time: entity: domain: cover integration: myhome + +tuner_seek_up: + name: Tuner seek up + description: Seek forward to the next receivable FM radio frequency on an F500 tuner source. + target: + entity: + domain: media_player + integration: myhome + +tuner_seek_down: + name: Tuner seek down + description: Seek backward to the previous receivable FM radio frequency on an F500 tuner source. + target: + entity: + domain: media_player + integration: myhome diff --git a/custom_components/myhome/sound_source.py b/custom_components/myhome/sound_source.py new file mode 100644 index 00000000..50c1a22c --- /dev/null +++ b/custom_components/myhome/sound_source.py @@ -0,0 +1,384 @@ +"""WHO=16 sound sources: the tuner half of the BTicino sound system. + +A sound source (`WHERE` 101-109) is the device feeding one input of the audio +matrix. Two kinds exist in practice: + +* a line interface such as the L4561 stereo control, which only reports whether + it is active, and +* a tuner such as the F500, which additionally reports the frequency it is + listening to, the stored station in use, and the RDS text broadcast by that + station. + +Nothing on the bus distinguishes the two until the device speaks, and a tuner +that is off says nothing, so the user declares which matrix inputs are tuners in +the integration options. Only those get an entity here. + +Frames +------ +Taken from `WHO_16.pdf` v1.0.1 and the OpenWebNet Encyclopedia page for WHO 16: + +========================== ========================================== +Operation Frame +========================== ========================================== +Power on / off ``*16*3*10S##`` / ``*16*13*10S##`` +Next / previous station ``*16*6001*10S##`` / ``*16*6101*10S##`` +Seek up / down ``*16*5000*10S##`` / ``*16*5100*10S##`` +Start / stop RDS reporting ``*16*101*10S##`` / ``*16*102*10S##`` +Select stored station ``*#16*10S*#7*##`` +Set frequency ``*#16*10S*#6*0*##`` +Frequency report ``*#16*10S*6*0*##`` +Station report ``*#16*10S*7*0*##`` +RDS report ``*#16*10S*8*<8 ASCII codes>##`` +========================== ========================================== + +The station *write* carries its parameter directly while the station *report* +prefixes it with ``0``. That asymmetry is in the specification and is preserved +here rather than normalised away. + +Frequencies are documented as "expressed in Hz ... composed by 6 digits", but +every example in the same document uses kHz (``107000`` is 107.00 MHz). This +module follows the examples, as the Encyclopedia does. + +Scope +----- +Tested against a live installation (MH200N gateway + F500N tuner with antenna, +contributed by @manfredgittmaier-afk on PR #427): +* Power on/off (``*16*3*10S##`` / ``*16*13*10S##``) +* Next / previous station advance (``*16*6001*10S##`` / ``*16*6101*10S##``) +* Hardware seek up / down (``*16*5000*10S##`` / ``*16*5100*10S##``) +* Direct frequency write with leading zero (``*#16*10S*#6*0*##``; write without zero is ignored) +* Station selection without leading zero (``*#16*10S*#7*##``) +* Station report with leading zero (``*#16*10S*7*0*##``) +* Frequency report in kHz (``*#16*10S*6*0*##``) +* Autonomous RDS station name reporting (``*#16*10S*8*...##``) and blanking transition +* Dynamic station list expansion up to 15 presets for F500N +""" +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, +) +from homeassistant.components.media_player.const import ( + MediaPlayerEntityFeature, + MediaPlayerState, + MediaType, +) +from homeassistant.const import Platform +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from OWNd.message import OWNSoundCommand, OWNSoundEvent + +from .const import DOMAIN, LOGGER, TUNER_MAX_STATION_COUNT, TUNER_STATION_COUNT +from .myhome_device import MyHOMEEntity + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from .gateway import MyHOMEGatewayHandler + +#: Lowest and highest FM frequency accepted, in kHz. Outside this the value is +#: almost certainly a mistake (a preset number, or MHz passed as kHz). +FM_MIN_KHZ = 87500 +FM_MAX_KHZ = 108000 + + +def source_address(source: int) -> str: + """Return the bus address of source device ``source`` (1-9).""" + return str(100 + int(source)) + + +def rds_text(values: list[str] | tuple[str, ...]) -> str | None: + """Decode an RDS dimension payload into readable text. + + The payload is eight decimal ASCII codes rather than characters. Codes + outside the printable range are dropped instead of rendering control + characters into the media title. + """ + chars = [ + chr(int(value)) + for value in values + if str(value).isdigit() and 32 <= int(value) <= 126 + ] + text = "".join(chars).strip() + return text or None + + +class MyHOMESoundSource(MyHOMEEntity, MediaPlayerEntity): + """A WHO=16 tuner source device. + + Presets are exposed as the entity's source list, because that is what a + listener picks. The frequency is an attribute rather than a source, since + it is continuous. + """ + + _attr_device_class = MediaPlayerDeviceClass.RECEIVER + _attr_supported_features = ( + MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.NEXT_TRACK + | MediaPlayerEntityFeature.PREVIOUS_TRACK + | MediaPlayerEntityFeature.SELECT_SOURCE + | MediaPlayerEntityFeature.PLAY_MEDIA + ) + + def __init__( + self, + hass: HomeAssistant, + name: str, + device_id: str, + who: str, + where: str, + manufacturer: str, + model: str, + gateway: MyHOMEGatewayHandler, + entity_name: str | None = None, + ) -> None: + """Initialise a tuner source entity.""" + super().__init__( + hass=hass, + name=name, + platform=Platform.MEDIA_PLAYER, + device_id=device_id, + who=who, + where=where, + manufacturer=manufacturer, + model=model, + gateway=gateway, + entity_name=entity_name, + ) + #: Router key this entity subscribes under, matching its unique id tail. + self.device_key = f"{where}#16" + self._attr_state: MediaPlayerState | None = None + self._attr_source: str | None = None + self._attr_media_title: str | None = None + self._frequency_khz: int | None = None + self._station: int | None = None + self._station_count: int = TUNER_STATION_COUNT + + # ── Presentation ────────────────────────────────────────────────────────── + + @property + def source_list(self) -> list[str]: + """Return the stored stations this tuner can be switched to.""" + return [f"Station {n}" for n in range(1, self._station_count + 1)] + + @property + def media_content_type(self) -> str | None: + """Report playing content as a channel while the tuner is on.""" + return MediaType.CHANNEL if self._attr_state == MediaPlayerState.ON else None + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Expose the tuning state that has no standard media_player attribute.""" + attributes: dict[str, Any] = {} + if self._frequency_khz is not None: + attributes["frequency"] = round(self._frequency_khz / 1000.0, 2) + if self._station is not None: + attributes["station"] = self._station + return attributes + + # ── Lifecycle ───────────────────────────────────────────────────────────── + + async def async_added_to_hass(self) -> None: + """Register listeners and ask the tuner to report RDS. + + A tuner does not broadcast its RDS text until asked (`WHAT` 101), so + without this the media title stays empty. Gateways that do not support + it answer NACK, which costs nothing. + """ + self._register_availability_listener() + await self._gateway_handler.send( + OWNSoundCommand(f"*16*101*{self._where}##") + ) + + async def async_update(self) -> None: + """Request the tuner's frequency, station and RDS text.""" + for dimension in (6, 7, 8): + await self._gateway_handler.send_status_request( + OWNSoundCommand(f"*#16*{self._where}*{dimension}##") + ) + + # ── Commands ────────────────────────────────────────────────────────────── + + async def async_turn_on(self, **kwargs: Any) -> None: + """Switch the source device on.""" + await self._gateway_handler.send(OWNSoundCommand(f"*16*3*{self._where}##")) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Switch the source device to standby. + + Rooms listening to this input fall silent; the matrix routing is not + changed, so they stay pointed at it. + """ + await self._gateway_handler.send(OWNSoundCommand(f"*16*13*{self._where}##")) + + async def async_media_next_track(self) -> None: + """Advance to the next station.""" + await self._gateway_handler.send(OWNSoundCommand(f"*16*6001*{self._where}##")) + + async def async_media_previous_track(self) -> None: + """Return to the previous station.""" + await self._gateway_handler.send(OWNSoundCommand(f"*16*6101*{self._where}##")) + + async def async_seek_up(self) -> None: + """Seek forward to the next receivable FM frequency.""" + await self._gateway_handler.send(OWNSoundCommand(f"*16*5000*{self._where}##")) + + async def async_seek_down(self) -> None: + """Seek backward to the previous receivable FM frequency.""" + await self._gateway_handler.send(OWNSoundCommand(f"*16*5100*{self._where}##")) + + async def async_select_source(self, source: str) -> None: + """Switch to a stored station. + + Raises: + HomeAssistantError: If ``source`` is not one of the stored stations. + """ + station = self._station_number(source) + if station is None: + raise HomeAssistantError( + f"{self.entity_id}: unknown station {source!r}", + translation_domain=DOMAIN, + translation_key="unknown_station", + translation_placeholders={ + "entity_id": str(self.entity_id), "station": str(source), + }, + ) + await self.async_select_station(station) + + async def async_select_station(self, station: int) -> None: + """Switch to stored station ``station`` (1-15).""" + if not 1 <= int(station) <= TUNER_MAX_STATION_COUNT: + raise HomeAssistantError( + f"{self.entity_id}: station {station} is outside the valid range (1-{TUNER_MAX_STATION_COUNT})", + translation_domain=DOMAIN, + translation_key="unknown_station", + translation_placeholders={ + "entity_id": str(self.entity_id), + "station": str(station), + }, + ) + await self._gateway_handler.send( + OWNSoundCommand(f"*#16*{self._where}*#7*{station}##") + ) + self._station = station + if station > self._station_count: + self._station_count = station + self._attr_source = f"Station {station}" + self.async_schedule_update_ha_state() + + async def async_set_frequency(self, megahertz: float) -> None: + """Tune to ``megahertz``, e.g. ``107.0``. + + Raises: + HomeAssistantError: If the frequency is outside the FM band. + """ + kilohertz = int(round(float(megahertz) * 1000)) + if not FM_MIN_KHZ <= kilohertz <= FM_MAX_KHZ: + raise HomeAssistantError( + f"{self.entity_id}: {megahertz} MHz is outside the FM band", + translation_domain=DOMAIN, + translation_key="frequency_out_of_range", + translation_placeholders={ + "entity_id": str(self.entity_id), "frequency": str(megahertz), + }, + ) + await self._gateway_handler.send( + OWNSoundCommand(f"*#16*{self._where}*#6*0*{kilohertz:06d}##") + ) + self._frequency_khz = kilohertz + self._station = None + self._attr_source = None + self.async_schedule_update_ha_state() + + async def async_play_media(self, media_type: str, media_id: str, **kwargs: Any) -> None: + """Tune by station number or by frequency. + + ``media_id`` matching an integer from ``"1"`` up to the available station + count (1–5 for F500, up to 15 for F500N) selects that stored station; + anything else is read as a frequency in MHz, so ``"107.0"`` tunes to 107.0 MHz. + + Raises: + HomeAssistantError: If ``media_id`` is neither. + """ + candidate = str(media_id).strip() + if candidate.isdigit() and 1 <= int(candidate) <= self._station_count: + await self.async_select_station(int(candidate)) + return + try: + megahertz = float(candidate) + except ValueError: + raise HomeAssistantError( + f"{self.entity_id}: {media_id!r} is neither a station nor a frequency", + translation_domain=DOMAIN, + translation_key="invalid_tuner_media", + translation_placeholders={ + "entity_id": str(self.entity_id), "media_id": str(media_id), + }, + ) from None + await self.async_set_frequency(megahertz) + + # ── Bus events ──────────────────────────────────────────────────────────── + + @callback + def handle_event(self, message: OWNSoundEvent) -> None: + """Apply a WHO=16 event addressed to this source device.""" + dimension = getattr(message, "dimension", None) + values = [str(v) for v in (getattr(message, "dimension_value", None) or [])] + + if dimension == 6 and values: + self._set_frequency_from_bus(values[-1]) + elif dimension == 7 and values: + self._set_station_from_bus(values[-1]) + elif dimension == 8 and values: + self._attr_media_title = rds_text(values) + elif getattr(message, "is_on", False): + self._attr_state = MediaPlayerState.ON + elif getattr(message, "is_off", False): + self._attr_state = MediaPlayerState.OFF + # A tuner in standby is not listening to anything. + self._attr_media_title = None + + self._publish_state() + + def _set_frequency_from_bus(self, raw: str) -> None: + """Record a reported frequency, ignoring a payload that cannot be one.""" + if not raw.isdigit(): + return + kilohertz = int(raw) + if FM_MIN_KHZ <= kilohertz <= FM_MAX_KHZ: + if self._frequency_khz != kilohertz: + self._frequency_khz = kilohertz + self._station = None + self._attr_source = None + else: + LOGGER.debug( + "%s: ignoring reported frequency %s kHz, outside the FM band", + self.entity_id, + kilohertz, + ) + + def _set_station_from_bus(self, raw: str) -> None: + """Record a reported stored station.""" + if not raw.isdigit(): + return + station = int(raw) + if 1 <= station <= TUNER_MAX_STATION_COUNT: + self._station = station + if station > self._station_count: + self._station_count = station + self._attr_source = f"Station {station}" + + # ── Helpers ─────────────────────────────────────────────────────────────── + + def _station_number(self, source: str) -> int | None: + """Resolve a station label such as ``"Station 3"`` to its number.""" + prefix = "Station " + if source.startswith(prefix): + candidate = source[len(prefix):].strip() + if candidate.isdigit() and 1 <= int(candidate) <= self._station_count: + return int(candidate) + return None diff --git a/custom_components/myhome/strings.json b/custom_components/myhome/strings.json index 7775977c..7716ce09 100644 --- a/custom_components/myhome/strings.json +++ b/custom_components/myhome/strings.json @@ -89,9 +89,13 @@ "description": "Advanced system settings and streaming decoder mapping", "data": { "source_1_name": "Source 1 — name of what is wired to matrix input S1 (leave blank if nothing is connected)", + "source_1_tuner": "Source 1 is a tuner (F500): adds a radio entity with stations, frequency and RDS", "source_2_name": "Source 2 — name of what is wired to matrix input S2 (leave blank if nothing is connected)", + "source_2_tuner": "Source 2 is a tuner (F500): adds a radio entity with stations, frequency and RDS", "source_3_name": "Source 3 — name of what is wired to matrix input S3 (leave blank if nothing is connected)", + "source_3_tuner": "Source 3 is a tuner (F500): adds a radio entity with stations, frequency and RDS", "source_4_name": "Source 4 — name of what is wired to matrix input S4 (leave blank if nothing is connected)", + "source_4_tuner": "Source 4 is a tuner (F500): adds a radio entity with stations, frequency and RDS", "address": "IP address", "password": "Password", "config_file_path": "Configuration file path", @@ -253,6 +257,14 @@ "reset_cover_travel_time": { "name": "Reset cover travel time", "description": "Forgets the measured or manually set travel times and returns to the myhome.yaml travel_time or the 25 s default." + }, + "tuner_seek_up": { + "name": "Tuner seek up", + "description": "Seek forward to the next receivable FM radio frequency on an F500 tuner source." + }, + "tuner_seek_down": { + "name": "Tuner seek down", + "description": "Seek backward to the previous receivable FM radio frequency on an F500 tuner source." } }, "issues": { @@ -351,6 +363,18 @@ }, "decoder_wake_timeout": { "message": "{entity_id}: decoder {decoder} did not wake up within 5 seconds" + }, + "unknown_station": { + "message": "{entity_id}: unknown station \"{station}\"" + }, + "frequency_out_of_range": { + "message": "{entity_id}: {frequency} MHz is outside the FM band" + }, + "invalid_tuner_media": { + "message": "{entity_id}: \"{media_id}\" is neither a station nor a frequency" + }, + "seek_not_supported": { + "message": "{entity_id}: seek is only supported on tuner source entities" } }, "entity": { diff --git a/custom_components/myhome/translations/en.json b/custom_components/myhome/translations/en.json index 7775977c..7716ce09 100644 --- a/custom_components/myhome/translations/en.json +++ b/custom_components/myhome/translations/en.json @@ -89,9 +89,13 @@ "description": "Advanced system settings and streaming decoder mapping", "data": { "source_1_name": "Source 1 — name of what is wired to matrix input S1 (leave blank if nothing is connected)", + "source_1_tuner": "Source 1 is a tuner (F500): adds a radio entity with stations, frequency and RDS", "source_2_name": "Source 2 — name of what is wired to matrix input S2 (leave blank if nothing is connected)", + "source_2_tuner": "Source 2 is a tuner (F500): adds a radio entity with stations, frequency and RDS", "source_3_name": "Source 3 — name of what is wired to matrix input S3 (leave blank if nothing is connected)", + "source_3_tuner": "Source 3 is a tuner (F500): adds a radio entity with stations, frequency and RDS", "source_4_name": "Source 4 — name of what is wired to matrix input S4 (leave blank if nothing is connected)", + "source_4_tuner": "Source 4 is a tuner (F500): adds a radio entity with stations, frequency and RDS", "address": "IP address", "password": "Password", "config_file_path": "Configuration file path", @@ -253,6 +257,14 @@ "reset_cover_travel_time": { "name": "Reset cover travel time", "description": "Forgets the measured or manually set travel times and returns to the myhome.yaml travel_time or the 25 s default." + }, + "tuner_seek_up": { + "name": "Tuner seek up", + "description": "Seek forward to the next receivable FM radio frequency on an F500 tuner source." + }, + "tuner_seek_down": { + "name": "Tuner seek down", + "description": "Seek backward to the previous receivable FM radio frequency on an F500 tuner source." } }, "issues": { @@ -351,6 +363,18 @@ }, "decoder_wake_timeout": { "message": "{entity_id}: decoder {decoder} did not wake up within 5 seconds" + }, + "unknown_station": { + "message": "{entity_id}: unknown station \"{station}\"" + }, + "frequency_out_of_range": { + "message": "{entity_id}: {frequency} MHz is outside the FM band" + }, + "invalid_tuner_media": { + "message": "{entity_id}: \"{media_id}\" is neither a station nor a frequency" + }, + "seek_not_supported": { + "message": "{entity_id}: seek is only supported on tuner source entities" } }, "entity": { diff --git a/custom_components/myhome/translations/fr.json b/custom_components/myhome/translations/fr.json index e953899f..a22823d3 100644 --- a/custom_components/myhome/translations/fr.json +++ b/custom_components/myhome/translations/fr.json @@ -71,9 +71,13 @@ "description": "Parametres système avancés", "data": { "source_1_name": "Source 1 — nom de l'appareil raccordé à l'entrée S1 de la matrice (laisser vide si rien n'est connecté)", + "source_1_tuner": "La source 1 est un tuner (F500) : ajoute une entité radio avec stations, fréquence et RDS", "source_2_name": "Source 2 — nom de l'appareil raccordé à l'entrée S2 de la matrice (laisser vide si rien n'est connecté)", + "source_2_tuner": "La source 2 est un tuner (F500) : ajoute une entité radio avec stations, fréquence et RDS", "source_3_name": "Source 3 — nom de l'appareil raccordé à l'entrée S3 de la matrice (laisser vide si rien n'est connecté)", + "source_3_tuner": "La source 3 est un tuner (F500) : ajoute une entité radio avec stations, fréquence et RDS", "source_4_name": "Source 4 — nom de l'appareil raccordé à l'entrée S4 de la matrice (laisser vide si rien n'est connecté)", + "source_4_tuner": "La source 4 est un tuner (F500) : ajoute une entité radio avec stations, fréquence et RDS", "address": "Adresse IP", "password": "Mot de passe", "config_file_path": "Chemin du fichier de configuration", @@ -180,6 +184,14 @@ "description": "Adresse MAC de la passerelle (facultatif ; par défaut pour toutes les passerelles actives)." } } + }, + "tuner_seek_up": { + "name": "Recherche fréquence avant du tuner", + "description": "Recherche la fréquence radio FM captable suivante sur une source tuner F500." + }, + "tuner_seek_down": { + "name": "Recherche fréquence arrière du tuner", + "description": "Recherche la fréquence radio FM captable précédente sur une source tuner F500." } }, "issues": { @@ -223,6 +235,10 @@ }, "decoder_wake_timeout": { "message": "{entity_id}: le décodeur {decoder} ne s'est pas réveillé dans les 5 secondes" + }, + "seek_not_supported": { + "message": "{entity_id} : la recherche de fréquence n'est prise en charge que sur les entités tuner" } } } + diff --git a/custom_components/myhome/translations/it.json b/custom_components/myhome/translations/it.json index cf24015e..eb6ad694 100644 --- a/custom_components/myhome/translations/it.json +++ b/custom_components/myhome/translations/it.json @@ -71,9 +71,13 @@ "description": "Impostazioni di sistema avanzate", "data": { "source_1_name": "Sorgente 1 — nome di ciò che è collegato all'ingresso S1 della matrice (lasciare vuoto se non è collegato nulla)", + "source_1_tuner": "La sorgente 1 è un sintonizzatore (F500): aggiunge un'entità radio con stazioni, frequenza e RDS", "source_2_name": "Sorgente 2 — nome di ciò che è collegato all'ingresso S2 della matrice (lasciare vuoto se non è collegato nulla)", + "source_2_tuner": "La sorgente 2 è un sintonizzatore (F500): aggiunge un'entità radio con stazioni, frequenza e RDS", "source_3_name": "Sorgente 3 — nome di ciò che è collegato all'ingresso S3 della matrice (lasciare vuoto se non è collegato nulla)", + "source_3_tuner": "La sorgente 3 è un sintonizzatore (F500): aggiunge un'entità radio con stazioni, frequenza e RDS", "source_4_name": "Sorgente 4 — nome di ciò che è collegato all'ingresso S4 della matrice (lasciare vuoto se non è collegato nulla)", + "source_4_tuner": "La sorgente 4 è un sintonizzatore (F500): aggiunge un'entità radio con stazioni, frequenza e RDS", "address": "Indirizzo IP", "password": "Password", "config_file_path": "Percorso del file di configurazione", @@ -180,6 +184,14 @@ "description": "Indirizzo MAC del gateway (opzionale; predefinito per tutti i gateway attivi)." } } + }, + "tuner_seek_up": { + "name": "Ricerca frequenza successiva sintonizzatore", + "description": "Cerca in avanti la frequenza radio FM ricevibile successiva su una sorgente sintonizzatore F500." + }, + "tuner_seek_down": { + "name": "Ricerca frequenza precedente sintonizzatore", + "description": "Cerca all'indietro la frequenza radio FM ricevibile precedente su una sorgente sintonizzatore F500." } }, "issues": { @@ -223,6 +235,10 @@ }, "decoder_wake_timeout": { "message": "{entity_id}: il decoder {decoder} non si è riattivato entro 5 secondi" + }, + "seek_not_supported": { + "message": "{entity_id}: la ricerca di frequenza è supportata solo sulle entità sintonizzatore" } } } + diff --git a/custom_components/myhome/translations/nl.json b/custom_components/myhome/translations/nl.json index 62b832ea..7dfc9c60 100644 --- a/custom_components/myhome/translations/nl.json +++ b/custom_components/myhome/translations/nl.json @@ -71,9 +71,13 @@ "description": "Gevorderde systeem settings", "data": { "source_1_name": "Bron 1 — naam van wat op matrix-ingang S1 is aangesloten (leeg laten als er niets op zit)", + "source_1_tuner": "Bron 1 is een tuner (F500): voegt een radio-entiteit toe met zenders, frequentie en RDS", "source_2_name": "Bron 2 — naam van wat op matrix-ingang S2 is aangesloten (leeg laten als er niets op zit)", + "source_2_tuner": "Bron 2 is een tuner (F500): voegt een radio-entiteit toe met zenders, frequentie en RDS", "source_3_name": "Bron 3 — naam van wat op matrix-ingang S3 is aangesloten (leeg laten als er niets op zit)", + "source_3_tuner": "Bron 3 is een tuner (F500): voegt een radio-entiteit toe met zenders, frequentie en RDS", "source_4_name": "Bron 4 — naam van wat op matrix-ingang S4 is aangesloten (leeg laten als er niets op zit)", + "source_4_tuner": "Bron 4 is een tuner (F500): voegt een radio-entiteit toe met zenders, frequentie en RDS", "address": "IP address", "password": "Wachtwoord", "config_file_path": "Path onfiguratie bestand", @@ -180,6 +184,14 @@ "description": "MAC-adres van de gateway (optioneel; standaard voor alle actieve gateways)." } } + }, + "tuner_seek_up": { + "name": "Tuner vooruit zoeken", + "description": "Zoek vooruit naar de volgende ontvangbare FM-frequentie op een F500-tunerbron." + }, + "tuner_seek_down": { + "name": "Tuner achteruit zoeken", + "description": "Zoek achteruit naar de vorige ontvangbare FM-frequentie op een F500-tunerbron." } }, "issues": { @@ -223,6 +235,10 @@ }, "decoder_wake_timeout": { "message": "{entity_id}: decoder {decoder} werd niet binnen 5 seconden wakker" + }, + "seek_not_supported": { + "message": "{entity_id}: frequentie zoeken wordt alleen ondersteund op tunerbron-entiteiten" } } } + diff --git a/docs/configuration/media_player.md b/docs/configuration/media_player.md index 84f57866..5d55e60b 100644 --- a/docs/configuration/media_player.md +++ b/docs/configuration/media_player.md @@ -123,6 +123,42 @@ applied while another zone in the environment is streaming. Without either setting the integration does not route while streaming and relies on the "Hardware Routing First" model below, as earlier releases did. +### Tuner sources (F500 / F500N) + +A matrix input can hold a **tuner** rather than a line interface. Tick *Source N +is a tuner* in the options and the integration adds a radio entity for that +input, alongside the amplifier zones. + +| Home Assistant | OpenWebNet | +| :--- | :--- | +| On / off | `*16*3*10S##` / `*16*13*10S##` | +| Next / previous track | next / previous station (`*16*6001*10S##` / `*16*6101*10S##`) | +| Source list | stored stations (defaults to 1–5 for F500, dynamically expands up to 1–15 for F500N: `*#16*10S*#7*##`) | +| Seek up / down | `myhome.tuner_seek_up` / `myhome.tuner_seek_down` (`*16*5000*10S##` / `*16*5100*10S##`) | +| `play_media`, content type `channel` | `"1"`–`"15"` selects a station; anything else is read as MHz, so `"107.5"` tunes there | +| `media_title` | RDS text, reported as eight ASCII codes on `DIMENSION 8` | +| `frequency` attribute | `DIMENSION 6`, in MHz | +| `station` attribute | `DIMENSION 7` | + +The entity asks the tuner to start reporting RDS (`*16*101*10S##`) when it is +added, although modern tuners broadcast their RDS text autonomously when active. + +Two notes on the frames, both from the specification rather than choice: a +station **write** carries its parameter directly (`*#16*101*#7*3##`) while the +**report** prefixes it with a zero (`*#16*101*7*0*3##`), and frequencies are +described as "expressed in Hz" while every example in the same document uses +kHz. The integration follows the examples. + +Why declare it instead of detecting it: a source device that has not spoken is +indistinguishable from one that is not there, and a tuner in standby says +nothing at all. + +> **Tested on real hardware.** Frequency tuning (`*#16*10S*#6*0*##` with leading zero; +> write without zero is ignored), hardware seek up / down (`*16*5000*10S##` / `*16*5100*10S##`), +> station stepping (`*16*6001*10S##` / `*16*6101*10S##`), station selection (`*#16*10S*#7*##`), +> kHz frequency parsing, and autonomous RDS text (`DIMENSION 8`) are hardware-verified +> against a live MH200N + F500N tuner with antenna (contributed by `@manfredgittmaier-afk` on PR #427). + ### The "Hardware Routing First" Model For installations that have not named their sources, `play_media` leaves the diff --git a/docs/configuration/services.md b/docs/configuration/services.md index 99f316bf..e294bf5b 100644 --- a/docs/configuration/services.md +++ b/docs/configuration/services.md @@ -17,6 +17,8 @@ This document provides a comprehensive reference for all custom services registe | [`myhome.stop_cover_calibration`](#myhomestop_cover_calibration) | Gateway | Stop the running calibration and cancel queued ones. | | [`myhome.set_cover_travel_time`](#myhomeset_cover_travel_time) | `cover` | Store stopwatch-measured travel times without driving the cover. | | [`myhome.reset_cover_travel_time`](#myhomereset_cover_travel_time) | `cover` | Forget measured / manual travel times; back to YAML or the default. | +| [`myhome.tuner_seek_up`](#myhometuner_seek_up) | `media_player` | Seek forward to the next receivable FM radio frequency on an F500 tuner. | +| [`myhome.tuner_seek_down`](#myhometuner_seek_down) | `media_player` | Seek backward to the previous receivable FM radio frequency on an F500 tuner. | --- @@ -212,3 +214,40 @@ action: myhome.reset_cover_travel_time target: entity_id: cover.bedroom_shutter ``` + +--- + +## 10. `myhome.tuner_seek_up` + +Commands an F500 / F500N sound source tuner entity (`WHERE` 101–109) to seek forward (`*16*5000*10S##`) to the next receivable FM radio frequency on the SCS bus. + +### Fields +| Parameter | Type | Required | Description | Example | +| :--- | :---: | :---: | :--- | :--- | +| `entity_id` | target | Yes | Target MyHOME tuner source `media_player` entity. | `media_player.audio_source_1` | + +### Example YAML Call +```yaml +action: myhome.tuner_seek_up +target: + entity_id: media_player.audio_source_1 +``` + +--- + +## 11. `myhome.tuner_seek_down` + +Commands an F500 / F500N sound source tuner entity (`WHERE` 101–109) to seek backward (`*16*5100*10S##`) to the previous receivable FM radio frequency on the SCS bus. + +### Fields +| Parameter | Type | Required | Description | Example | +| :--- | :---: | :---: | :--- | :--- | +| `entity_id` | target | Yes | Target MyHOME tuner source `media_player` entity. | `media_player.audio_source_1` | + +### Example YAML Call +```yaml +action: myhome.tuner_seek_down +target: + entity_id: media_player.audio_source_1 +``` + diff --git a/tests/fixtures/traces/f500_tuner/README.md b/tests/fixtures/traces/f500_tuner/README.md new file mode 100644 index 00000000..260eea4c --- /dev/null +++ b/tests/fixtures/traces/f500_tuner/README.md @@ -0,0 +1,28 @@ +# F500N Tuner Traces (PR #427) + +Authentic on-wire bus frames captured by `@manfredgittmaier-afk` on a live installation: +- **Gateway**: MH200N (firmware 2.0.32) +- **Tuner**: Legrand / BTicino F500N (Source 101) with antenna connected +- **Reported on**: [PR #427 (comment 5847535313)](https://github.com/OpenWebNet-HA/MyHOME/pull/427#issuecomment-5847535313) + +### Validated Protocol Behaviors +1. **Next / Previous Station**: + - `*16*6001*101##` (next station) and `*16*6101*101##` (previous station) +2. **Station Write Asymmetry**: + - Write: `*#16*101*#7*##` (without leading zero) + - Status Report: `*#16*101*7*0*##` (with leading zero) +3. **Frequency Format**: + - `*#16*101*6*0*96200##` -> 96.2 MHz (expressed in kHz, 5 digits) +4. **Autonomous RDS**: + - `*#16*101*8*75*82*79*78*69*72*73*84##` -> `"KRONEHIT"` (8 ASCII bytes) + - Follow-up dynamic text: `*#16*101*8*107*114*111*110*101*104*105*116##` -> `"kronehit"` +5. **WHO=22 Mirroring**: + - Every WHO=16 tuner frame is echoed on WHO=22 with compound source address `5#2#1`. +6. **Direct Frequency Tuning (Dimension 6)**: + - Write with leading zero: `*#16*101*#6*0*96200##` -> tunes to 96.2 MHz (reported in [comment 5848039616](https://github.com/OpenWebNet-HA/MyHOME/pull/427#issuecomment-5848039616)). + - Write without zero: `*#16*101*#6*96200##` -> ignored, no reply on bus. +7. **Hardware Seek Up / Down**: + - Seek up: `*16*5000*101##` -> locks to next station (e.g. 89.5 MHz). + - Seek down: `*16*5100*101##` -> locks to previous station (e.g. 88.8 MHz). +8. **RDS Blanking and Dynamic Text**: + - After tuning, tuner sends 8 spaces `*#16*101*8*32*32*32*32*32*32*32*32##` before delivering station title. diff --git a/tests/fixtures/traces/f500_tuner/myhome_trace_MH200N_f500n_tuner.json b/tests/fixtures/traces/f500_tuner/myhome_trace_MH200N_f500n_tuner.json new file mode 100644 index 00000000..db31c0d0 --- /dev/null +++ b/tests/fixtures/traces/f500_tuner/myhome_trace_MH200N_f500n_tuner.json @@ -0,0 +1,224 @@ +{ + "capture": { + "kind": "trace", + "started_at": null, + "filters": { + "who": null, + "where": null, + "direction": null + }, + "window": { + "first": "2026-09-26T15:35:00.000Z", + "last": "2026-09-26T15:36:00.000Z", + "frames": 13, + "buffer_frames": 13, + "buffer_depth": 200, + "truncated": false + } + }, + "environment": { + "home_assistant_version": "2026.9.3", + "integration_version": "2.0.0b13", + "ownd_version": "2.0.0b8", + "exported_at": "2026-09-26T15:37:15.000Z" + }, + "gateway": { + "model": "MH200N", + "manufacturer": "BTicino S.p.A.", + "firmware": "2.0.32", + "mac_prefix": "00:03:50", + "connection_type": "tcp", + "queue_pacing": 0.05, + "is_connected": true, + "identification": { + "model": "MH200N", + "source": "bus", + "configured_model": "MH200N", + "ssdp_model": null, + "who13_code": "MH200N", + "who13_model": "MH200N", + "who13_model_official": "MH200N", + "who13_model_observed": "MH200N", + "who13_firmware": "2.0.32", + "who13_kernel": null, + "who13_distribution": null, + "profile": "MH200NProfile", + "conflict": null + } + }, + "telemetry": { + "total_rx": 10, + "total_tx": 3, + "captured_in_buffer": 13, + "buffer_depth": 200, + "queue_depth": 0 + }, + "frames": [ + { + "timestamp": 1790436900.0, + "iso_time": "2026-09-26T15:35:00.000000+00:00", + "direction": "tx", + "raw": "*16*6001*101##", + "who": "16", + "what": "6001", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790436900.1, + "iso_time": "2026-09-26T15:35:00.100000+00:00", + "direction": "rx", + "raw": "*#16*101*6*0*96200##", + "who": "16", + "what": null, + "where": "101", + "dimension": "6", + "dimension_values": ["0", "96200"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790436900.12, + "iso_time": "2026-09-26T15:35:00.120000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*5*1*9620##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "5", + "dimension_values": ["1", "9620"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790436900.2, + "iso_time": "2026-09-26T15:35:00.200000+00:00", + "direction": "rx", + "raw": "*#16*101*7*0*2##", + "who": "16", + "what": null, + "where": "101", + "dimension": "7", + "dimension_values": ["0", "2"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790436900.22, + "iso_time": "2026-09-26T15:35:00.220000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*11*1*9620*2##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "11", + "dimension_values": ["1", "9620", "2"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790436900.25, + "iso_time": "2026-09-26T15:35:00.250000+00:00", + "direction": "rx", + "raw": "*22*2#1*6*2##", + "who": "22", + "what": "6", + "where": "2#1", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790436900.3, + "iso_time": "2026-09-26T15:35:00.300000+00:00", + "direction": "rx", + "raw": "*#16*101*8*75*82*79*78*69*72*73*84##", + "who": "16", + "what": null, + "where": "101", + "dimension": "8", + "dimension_values": ["75", "82", "79", "78", "69", "72", "73", "84"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790436900.32, + "iso_time": "2026-09-26T15:35:00.320000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*10*75*82*79*78*69*72*73*84##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "10", + "dimension_values": ["75", "82", "79", "78", "69", "72", "73", "84"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790436901.0, + "iso_time": "2026-09-26T15:35:01.000000+00:00", + "direction": "rx", + "raw": "*#16*101*8*107*114*111*110*101*104*105*116##", + "who": "16", + "what": null, + "where": "101", + "dimension": "8", + "dimension_values": ["107", "114", "111", "110", "101", "104", "105", "116"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790436910.0, + "iso_time": "2026-09-26T15:35:10.000000+00:00", + "direction": "tx", + "raw": "*#16*101*#7*3##", + "who": "16", + "what": null, + "where": "101", + "dimension": "7", + "dimension_values": ["3"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790436910.2, + "iso_time": "2026-09-26T15:35:10.200000+00:00", + "direction": "rx", + "raw": "*#16*101*7*0*3##", + "who": "16", + "what": null, + "where": "101", + "dimension": "7", + "dimension_values": ["0", "3"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790436920.0, + "iso_time": "2026-09-26T15:35:20.000000+00:00", + "direction": "tx", + "raw": "*16*6101*101##", + "who": "16", + "what": "6101", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790436920.2, + "iso_time": "2026-09-26T15:35:20.200000+00:00", + "direction": "rx", + "raw": "*#16*101*7*0*2##", + "who": "16", + "what": null, + "where": "101", + "dimension": "7", + "dimension_values": ["0", "2"], + "is_ack": false, + "is_nack": false + } + ] +} diff --git a/tests/fixtures/traces/f500_tuner/myhome_trace_MH200N_f500n_tuner_seek_and_frequency.json b/tests/fixtures/traces/f500_tuner/myhome_trace_MH200N_f500n_tuner_seek_and_frequency.json new file mode 100644 index 00000000..07566674 --- /dev/null +++ b/tests/fixtures/traces/f500_tuner/myhome_trace_MH200N_f500n_tuner_seek_and_frequency.json @@ -0,0 +1,355 @@ +{ + "capture": { + "kind": "trace", + "started_at": null, + "filters": { + "who": null, + "where": null, + "direction": null + }, + "window": { + "first": "2026-09-26T16:40:09.390Z", + "last": "2026-09-26T16:43:04.071Z", + "frames": 23, + "buffer_frames": 23, + "buffer_depth": 200, + "truncated": false + } + }, + "environment": { + "home_assistant_version": "2026.9.3", + "integration_version": "2.0.0b13", + "ownd_version": "2.0.0b8", + "exported_at": "2026-09-26T16:48:25.000Z" + }, + "gateway": { + "model": "MH200N", + "manufacturer": "BTicino S.p.A.", + "firmware": "1.0", + "mac_prefix": "00:03:50", + "connection_type": "tcp", + "queue_pacing": 0.15, + "is_connected": true, + "identification": { + "model": "MH200N", + "source": "bus", + "configured_model": "MH200N", + "ssdp_model": null, + "who13_code": "MH200N", + "who13_model": "MH200N", + "who13_model_official": "MH200N", + "who13_model_observed": "MH200N", + "who13_firmware": "1.0", + "who13_kernel": null, + "who13_distribution": null, + "profile": "MH200NProfile", + "conflict": null + } + }, + "telemetry": { + "total_rx": 20, + "total_tx": 3, + "captured_in_buffer": 23, + "buffer_depth": 200, + "queue_depth": 0 + }, + "frames": [ + { + "timestamp": 1790440809.390, + "iso_time": "2026-09-26T16:40:09.390000+00:00", + "direction": "tx", + "raw": "*#16*101*#6*0*96200##", + "who": "16", + "what": null, + "where": "101", + "dimension": "6", + "dimension_values": ["0", "96200"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440809.571, + "iso_time": "2026-09-26T16:40:09.571000+00:00", + "direction": "rx", + "raw": "*#16*101*6*0*96200##", + "who": "16", + "what": null, + "where": "101", + "dimension": "6", + "dimension_values": ["0", "96200"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440809.582, + "iso_time": "2026-09-26T16:40:09.582000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*5*1*9620##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "5", + "dimension_values": ["1", "9620"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440809.747, + "iso_time": "2026-09-26T16:40:09.747000+00:00", + "direction": "rx", + "raw": "*#16*101*8*32*32*32*32*32*32*32*32##", + "who": "16", + "what": null, + "where": "101", + "dimension": "8", + "dimension_values": ["32", "32", "32", "32", "32", "32", "32", "32"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440809.752, + "iso_time": "2026-09-26T16:40:09.752000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*10*32*32*32*32*32*32*32*32##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "10", + "dimension_values": ["32", "32", "32", "32", "32", "32", "32", "32"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440810.781, + "iso_time": "2026-09-26T16:40:10.781000+00:00", + "direction": "rx", + "raw": "*#16*101*8*107*114*111*110*101*104*105*116##", + "who": "16", + "what": null, + "where": "101", + "dimension": "8", + "dimension_values": ["107", "114", "111", "110", "101", "104", "105", "116"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440810.793, + "iso_time": "2026-09-26T16:40:10.793000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*10*107*114*111*110*101*104*105*116##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "10", + "dimension_values": ["107", "114", "111", "110", "101", "104", "105", "116"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440973.004, + "iso_time": "2026-09-26T16:42:53.004000+00:00", + "direction": "tx", + "raw": "*16*5000*101##", + "who": "16", + "what": "5000", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440973.304, + "iso_time": "2026-09-26T16:42:53.304000+00:00", + "direction": "rx", + "raw": "*#16*101*6*0*89500##", + "who": "16", + "what": null, + "where": "101", + "dimension": "6", + "dimension_values": ["0", "89500"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440973.309, + "iso_time": "2026-09-26T16:42:53.309000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*5*1*8950##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "5", + "dimension_values": ["1", "8950"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440973.459, + "iso_time": "2026-09-26T16:42:53.459000+00:00", + "direction": "rx", + "raw": "*#16*101*8*32*32*32*32*32*32*32*32##", + "who": "16", + "what": null, + "where": "101", + "dimension": "8", + "dimension_values": ["32", "32", "32", "32", "32", "32", "32", "32"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440973.469, + "iso_time": "2026-09-26T16:42:53.469000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*10*32*32*32*32*32*32*32*32##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "10", + "dimension_values": ["32", "32", "32", "32", "32", "32", "32", "32"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440981.752, + "iso_time": "2026-09-26T16:43:01.752000+00:00", + "direction": "rx", + "raw": "*#16*101*8*66*97*121*101*114*110*32*50##", + "who": "16", + "what": null, + "where": "101", + "dimension": "8", + "dimension_values": ["66", "97", "121", "101", "114", "110", "32", "50"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440981.753, + "iso_time": "2026-09-26T16:43:01.753000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*10*66*97*121*101*114*110*32*50##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "10", + "dimension_values": ["66", "97", "121", "101", "114", "110", "32", "50"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440981.868, + "iso_time": "2026-09-26T16:43:01.868000+00:00", + "direction": "tx", + "raw": "*16*5100*101##", + "who": "16", + "what": "5100", + "where": "101", + "dimension": null, + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440982.174, + "iso_time": "2026-09-26T16:43:02.174000+00:00", + "direction": "rx", + "raw": "*#16*101*6*0*88800##", + "who": "16", + "what": null, + "where": "101", + "dimension": "6", + "dimension_values": ["0", "88800"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440982.178, + "iso_time": "2026-09-26T16:43:02.178000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*5*1*8880##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "5", + "dimension_values": ["1", "8880"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440982.191, + "iso_time": "2026-09-26T16:43:02.191000+00:00", + "direction": "rx", + "raw": "*#16*101*7*0*1##", + "who": "16", + "what": null, + "where": "101", + "dimension": "7", + "dimension_values": ["0", "1"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440982.197, + "iso_time": "2026-09-26T16:43:02.197000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*11*1*8880*1##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "11", + "dimension_values": ["1", "8880", "1"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440982.325, + "iso_time": "2026-09-26T16:43:02.325000+00:00", + "direction": "rx", + "raw": "*#16*101*8*66*97*121*101*114*110*32*50##", + "who": "16", + "what": null, + "where": "101", + "dimension": "8", + "dimension_values": ["66", "97", "121", "101", "114", "110", "32", "50"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440982.330, + "iso_time": "2026-09-26T16:43:02.330000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*10*66*97*121*101*114*110*32*50##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "10", + "dimension_values": ["66", "97", "121", "101", "114", "110", "32", "50"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440984.059, + "iso_time": "2026-09-26T16:43:04.059000+00:00", + "direction": "rx", + "raw": "*#16*101*8*32*32*79*69*32*51*32*32##", + "who": "16", + "what": null, + "where": "101", + "dimension": "8", + "dimension_values": ["32", "32", "79", "69", "32", "51", "32", "32"], + "is_ack": false, + "is_nack": false + }, + { + "timestamp": 1790440984.071, + "iso_time": "2026-09-26T16:43:04.071000+00:00", + "direction": "rx", + "raw": "*#22*5#2#1*10*32*32*79*69*32*51*32*32##", + "who": "22", + "what": null, + "where": "5#2#1", + "dimension": "10", + "dimension_values": ["32", "32", "79", "69", "32", "51", "32", "32"], + "is_ack": false, + "is_nack": false + } + ] +} diff --git a/tests/test_component_sound_source.py b/tests/test_component_sound_source.py new file mode 100644 index 00000000..cdf3223e --- /dev/null +++ b/tests/test_component_sound_source.py @@ -0,0 +1,346 @@ +"""Test the WHO=16 tuner source entity. + +None of this is verified against a tuner with an antenna: the frames come from +`WHO_16.pdf` v1.0.1, and only the RDS report shape is confirmed by a capture +from a live installation. The tests pin what the integration sends and how it +reads replies, not that a radio obeys. +""" +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest +from homeassistant.components.media_player import MediaPlayerState, MediaType +from homeassistant.const import CONF_MAC +from homeassistant.exceptions import HomeAssistantError +from OWNd.message import OWNSoundEvent + +from custom_components.myhome.const import CONF_SOURCE_NAME, CONF_SOURCE_TUNER +from custom_components.myhome.data import MyHOMERuntimeData +from custom_components.myhome.media_player import _build_sound_sources, _sound_route_keys +from custom_components.myhome.sound_source import MyHOMESoundSource, rds_text, source_address +from tests.conftest import attach_platform + + +@pytest.fixture +def mock_gateway(): + gateway = MagicMock() + gateway.mac = "00:11:22:33:44:55" + gateway.log_id = "[MYHOME gateway - 192.168.1.5]" + gateway.send = AsyncMock() + gateway.send_status_request = AsyncMock() + return gateway + + +@pytest.fixture +def tuner(hass, mock_gateway): + entity = MyHOMESoundSource( + hass=hass, + name="Radio", + device_id="101#16", + who="16", + where="101", + manufacturer="BTicino", + model="Audio Source", + gateway=mock_gateway, + ) + entity.hass = hass + entity.entity_id = "media_player.radio" + entity.async_schedule_update_ha_state = MagicMock() + entry = MagicMock() + entry.data = {CONF_MAC: mock_gateway.mac} + entry.runtime_data = MyHOMERuntimeData(gateway=mock_gateway) + attach_platform(entity, entry) + return entity + + +def _sent(gateway): + return [str(call.args[0]) for call in gateway.send.call_args_list] + + +def test_source_address(): + """Source devices live at 101-109.""" + assert source_address(1) == "101" + assert source_address(4) == "104" + + +def test_rds_text_decodes_ascii_codes(): + """RDS arrives as eight decimal ASCII codes, not characters.""" + # " Radio 1", the shape captured on a live installation + assert rds_text(["32", "82", "97", "100", "105", "111", "32", "49"]) == "Radio 1" + # Control codes are dropped rather than rendered into the title + assert rds_text(["7", "82", "97", "100", "105", "111"]) == "Radio" + assert rds_text(["32", "32"]) is None + assert rds_text([]) is None + assert rds_text(["not-a-code"]) is None + + +@pytest.mark.asyncio +async def test_power_and_station_commands(hass, tuner, mock_gateway): + """Power, station stepping and preset selection use the source address.""" + await tuner.async_turn_on() + await tuner.async_turn_off() + await tuner.async_media_next_track() + await tuner.async_media_previous_track() + await tuner.async_select_source("Station 3") + + assert _sent(mock_gateway) == [ + "*16*3*101##", + "*16*13*101##", + "*16*6001*101##", + "*16*6101*101##", + # The station write carries its parameter directly; the report adds a 0 + "*#16*101*#7*3##", + ] + assert tuner.source == "Station 3" + + +@pytest.mark.asyncio +async def test_select_source_rejects_an_unknown_station(hass, tuner, mock_gateway): + """An unknown label is refused rather than turned into a frame.""" + assert tuner.source_list == [f"Station {n}" for n in range(1, 6)] + + with pytest.raises(HomeAssistantError): + await tuner.async_select_source("Station 9") + with pytest.raises(HomeAssistantError): + await tuner.async_select_source("BNR") + mock_gateway.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_seek_commands(hass, tuner, mock_gateway): + """Hardware seek up and down commands send *16*5000*101## and *16*5100*101##.""" + await tuner.async_seek_up() + await tuner.async_seek_down() + assert _sent(mock_gateway) == ["*16*5000*101##", "*16*5100*101##"] + + +@pytest.mark.asyncio +async def test_set_frequency(hass, tuner, mock_gateway): + """Frequency is written as six digits in kHz, per the specification's examples.""" + tuner._station = 1 + tuner._attr_source = "Station 1" + await tuner.async_set_frequency(107.0) + assert _sent(mock_gateway) == ["*#16*101*#6*0*107000##"] + assert tuner.extra_state_attributes["frequency"] == 107.0 + assert "station" not in tuner.extra_state_attributes + assert tuner.source is None + + +@pytest.mark.asyncio +async def test_set_frequency_outside_the_fm_band_is_refused(hass, tuner, mock_gateway): + """A frequency that cannot be FM is a mistake, not a command.""" + for value in (10.7, 120.0, 0): + with pytest.raises(HomeAssistantError): + await tuner.async_set_frequency(value) + mock_gateway.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_play_media_takes_a_station_or_a_frequency(hass, tuner, mock_gateway): + """A small number is a preset; anything else is read as MHz.""" + await tuner.async_play_media(MediaType.CHANNEL, "2") + assert _sent(mock_gateway) == ["*#16*101*#7*2##"] + + mock_gateway.send.reset_mock() + await tuner.async_play_media(MediaType.CHANNEL, "107.5") + assert _sent(mock_gateway) == ["*#16*101*#6*0*107500##"] + + mock_gateway.send.reset_mock() + with pytest.raises(HomeAssistantError): + await tuner.async_play_media(MediaType.CHANNEL, "BNR Nieuwsradio") + mock_gateway.send.assert_not_called() + + +@pytest.mark.asyncio +async def test_added_to_hass_asks_for_rds(hass, tuner, mock_gateway): + """Without WHAT 101 a tuner never reports its RDS text.""" + await tuner.async_added_to_hass() + assert "*16*101*101##" in _sent(mock_gateway) + + +@pytest.mark.asyncio +async def test_update_requests_tuning_state(hass, tuner, mock_gateway): + """An update asks for frequency, station and RDS.""" + await tuner.async_update() + requested = [str(c.args[0]) for c in mock_gateway.send_status_request.call_args_list] + assert requested == ["*#16*101*6##", "*#16*101*7##", "*#16*101*8##"] + + +def test_handle_event_reads_frequency_station_and_rds(hass, tuner): + """Reports update the tuning state; the report form carries a leading 0.""" + tuner.handle_event(MagicMock( + spec=OWNSoundEvent, dimension=6, dimension_value=["0", "107000"], + is_on=False, is_off=False, + )) + assert tuner.extra_state_attributes["frequency"] == 107.0 + + tuner.handle_event(MagicMock( + spec=OWNSoundEvent, dimension=7, dimension_value=["0", "4"], + is_on=False, is_off=False, + )) + assert tuner.source == "Station 4" + assert tuner.extra_state_attributes["station"] == 4 + + tuner.handle_event(MagicMock( + spec=OWNSoundEvent, dimension=8, + dimension_value=["32", "82", "97", "100", "105", "111", "32", "49"], + is_on=False, is_off=False, + )) + assert tuner.media_title == "Radio 1" + + # A subsequent frequency report to a new frequency clears the stored station + tuner.handle_event(MagicMock( + spec=OWNSoundEvent, dimension=6, dimension_value=["0", "96200"], + is_on=False, is_off=False, + )) + assert tuner.extra_state_attributes["frequency"] == 96.2 + assert "station" not in tuner.extra_state_attributes + assert tuner.source is None + + +def test_handle_event_ignores_impossible_payloads(hass, tuner): + """A payload that cannot be a frequency or station is dropped, not stored.""" + for payload in (["0", "45000"], ["0", "200000"], ["0", "not-a-number"]): + tuner.handle_event(MagicMock( + spec=OWNSoundEvent, dimension=6, dimension_value=payload, + is_on=False, is_off=False, + )) + assert "frequency" not in tuner.extra_state_attributes + + for payload in (["0", "0"], ["0", "16"], ["0", "x"]): + tuner.handle_event(MagicMock( + spec=OWNSoundEvent, dimension=7, dimension_value=payload, + is_on=False, is_off=False, + )) + assert "station" not in tuner.extra_state_attributes + assert tuner.source is None + + +def test_handle_event_power_state(hass, tuner): + """Standby clears the title: a tuner in standby is listening to nothing.""" + tuner.handle_event(MagicMock( + spec=OWNSoundEvent, dimension=8, + dimension_value=["32", "82", "97", "100", "105", "111", "32", "49"], + is_on=False, is_off=False, + )) + tuner.handle_event(MagicMock(spec=OWNSoundEvent, dimension=None, dimension_value=[], is_on=True, is_off=False)) + assert tuner.state == MediaPlayerState.ON + assert tuner.media_content_type == MediaType.CHANNEL + assert tuner.media_title == "Radio 1" + + tuner.handle_event(MagicMock(spec=OWNSoundEvent, dimension=None, dimension_value=[], is_on=False, is_off=True)) + assert tuner.state == MediaPlayerState.OFF + assert tuner.media_title is None + assert tuner.media_content_type is None + + +def test_build_sound_sources_only_for_declared_tuners(hass, mock_gateway): + """Only inputs the user marked as tuners get an entity.""" + entry = MagicMock() + entry.options = { + CONF_SOURCE_NAME.format(1): "Radio", + CONF_SOURCE_TUNER.format(1): True, + CONF_SOURCE_NAME.format(2): "Cambridge", + CONF_SOURCE_TUNER.format(2): False, + CONF_SOURCE_TUNER.format(3): True, + } + + sources = _build_sound_sources(hass, entry, mock_gateway) + + assert [s._where for s in sources] == ["101", "103"] + assert sources[0].device_info["name"] == "Radio" + # An unnamed tuner still gets a usable name + assert sources[1].device_info["name"] == "Audio Source 3" + + +def test_sound_route_keys_delivers_source_frames(hass): + """Source frames carry no zone address and would otherwise be dropped.""" + from custom_components.myhome.discovery import Address + + source_event = MagicMock(spec=OWNSoundEvent, is_source_event=True, zone="101") + assert _sound_route_keys(source_event, None) == ["101#16"] + + zone_event = MagicMock(spec=OWNSoundEvent, is_source_event=False, zone="23") + assert _sound_route_keys(zone_event, Address("23", key_suffix="#16")) == ["23#16"] + + # Neither a source nor an addressed zone: nothing to deliver to + assert _sound_route_keys(zone_event, None) == [] + assert _sound_route_keys(MagicMock(spec=OWNSoundEvent, is_source_event=True, zone=""), None) == [] + + +@pytest.mark.asyncio +async def test_setup_entry_adds_and_subscribes_declared_tuners(hass, mock_gateway): + """A declared tuner exists from setup, without waiting for bus traffic.""" + from homeassistant.const import CONF_MAC as _CONF_MAC + + from custom_components.myhome.media_player import async_setup_entry + from tests.conftest import attach_runtime + + entry = MagicMock() + entry.entry_id = "test_entry_id" + entry.data = {_CONF_MAC: mock_gateway.mac} + entry.options = { + CONF_SOURCE_NAME.format(1): "Radio", + CONF_SOURCE_TUNER.format(1): True, + } + + added: list = [] + attach_runtime(hass, entry) + entry.runtime_data.gateway = mock_gateway + router = entry.runtime_data.router + + from homeassistant.helpers import entity_platform + + mock_platform = MagicMock() + platform_token = entity_platform.current_platform.set(mock_platform) + try: + with patch("homeassistant.helpers.entity_registry.async_get"), \ + patch("homeassistant.helpers.entity_registry.async_entries_for_config_entry", return_value=[]): + await async_setup_entry(hass, entry, lambda entities, *a, **k: added.extend(entities)) + finally: + entity_platform.current_platform.reset(platform_token) + + assert [type(e).__name__ for e in added] == ["MyHOMESoundSource"] + # Subscribed under its own key, so source frames reach it + assert router.subscribers("16", "101#16") == 1 + # Both tuner_seek_up and tuner_seek_down registered as entity services + registered_services = [call[0][0] for call in mock_platform.async_register_entity_service.call_args_list] + assert "tuner_seek_up" in registered_services + assert "tuner_seek_down" in registered_services + + # A source frame now lands on the entity + tuner = added[0] + tuner.hass = hass + tuner.entity_id = "media_player.radio" + tuner.async_schedule_update_ha_state = MagicMock() + router.publish("16", ["101#16"], MagicMock( + spec=OWNSoundEvent, dimension=None, dimension_value=[], is_on=True, is_off=False, + )) + assert tuner.state == MediaPlayerState.ON + + +@pytest.mark.asyncio +async def test_zone_player_rejects_seek(hass, mock_gateway): + """An audio zone entity is not a tuner and rejects seek up / down.""" + from custom_components.myhome.media_player import MyHOMEMediaPlayer + + player = MyHOMEMediaPlayer( + hass=hass, + name="Audio Zone 1", + entity_name=None, + device_id="1#16", + who="16", + where="1", + manufacturer="BTicino", + model="Audio System", + gateway=mock_gateway, + ) + player.entity_id = "media_player.audio_zone_1" + + with pytest.raises(HomeAssistantError) as err_up: + await player.async_seek_up() + assert err_up.value.translation_key == "seek_not_supported" + + with pytest.raises(HomeAssistantError) as err_down: + await player.async_seek_down() + assert err_down.value.translation_key == "seek_not_supported" + diff --git a/tests/test_sound_source_trace_replay.py b/tests/test_sound_source_trace_replay.py new file mode 100644 index 00000000..89c147d5 --- /dev/null +++ b/tests/test_sound_source_trace_replay.py @@ -0,0 +1,257 @@ +"""Replay and integration tests for WHO=16 F500/F500N tuner sound sources. + +Validates the MyHOMESoundSource entity against authentic on-wire traces captured +from an MH200N gateway connected to an F500N tuner (PR #427 / comment 5847535313). +""" + +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 MediaType +from homeassistant.const import CONF_MAC +from homeassistant.exceptions import HomeAssistantError +from OWNd.message import OWNSoundEvent + +from custom_components.myhome.const import TUNER_STATION_COUNT +from custom_components.myhome.data import MyHOMERuntimeData +from custom_components.myhome.sound_source import MyHOMESoundSource +from tests.conftest import attach_platform + +FIXTURE_PATH = ( + Path(__file__).parent + / "fixtures" + / "traces" + / "f500_tuner" + / "myhome_trace_MH200N_f500n_tuner.json" +) + +FIXTURE_SEEK_FREQ_PATH = ( + Path(__file__).parent + / "fixtures" + / "traces" + / "f500_tuner" + / "myhome_trace_MH200N_f500n_tuner_seek_and_frequency.json" +) + + +@pytest.fixture +def mock_gateway(): + """Mock gateway handler for tuner commands.""" + gateway = MagicMock() + gateway.mac = "00:11:22:33:44:55" + gateway.log_id = "[MYHOME gateway - 192.168.1.5]" + gateway.send = AsyncMock() + gateway.send_status_request = AsyncMock() + return gateway + + +@pytest.fixture +def tuner(hass, mock_gateway): + """Instantiate a test MyHOMESoundSource entity.""" + entity = MyHOMESoundSource( + hass=hass, + name="Living Room Radio", + device_id="101#16", + who="16", + where="101", + manufacturer="BTicino", + model="Audio Source", + gateway=mock_gateway, + ) + entity.hass = hass + entity.entity_id = "media_player.living_room_radio" + entity.async_schedule_update_ha_state = MagicMock() + entry = MagicMock() + entry.data = {CONF_MAC: mock_gateway.mac} + entry.runtime_data = MyHOMERuntimeData(gateway=mock_gateway) + attach_platform(entity, entry) + return entity + + +def test_f500n_authentic_trace_replay(tuner): + """Replay the authentic MH200N + F500N hardware trace through the entity.""" + 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) == 13 + + # Initial state + assert tuner.source_list == [f"Station {i}" for i in range(1, TUNER_STATION_COUNT + 1)] + assert tuner.extra_state_attributes == {} + assert tuner.media_title is None + + # Step through trace frames + for frame in frames: + if frame["direction"] != "rx": + continue + + raw = frame["raw"] + who = frame.get("who") + dimension = frame.get("dimension") + values = frame.get("dimension_values", []) + + # The tuner entity subscribes to WHO 16 frames addressed to 101 + if who == "16" and frame.get("where") == "101": + event = MagicMock(spec=OWNSoundEvent) + event.raw = raw + event.who = "16" + event.where = "101" + event.dimension = int(dimension) if dimension is not None else None + event.dimension_value = values + event.is_on = False + event.is_off = False + tuner.handle_event(event) + + if raw == "*#16*101*6*0*96200##": + # 96200 kHz = 96.2 MHz + assert tuner.extra_state_attributes["frequency"] == 96.2 + + elif raw == "*#16*101*7*0*2##": + assert tuner.extra_state_attributes["station"] == 2 + assert tuner.source == "Station 2" + + elif raw == "*#16*101*8*75*82*79*78*69*72*73*84##": + # "KRONEHIT" (uppercase RDS) + assert tuner.media_title == "KRONEHIT" + + elif raw == "*#16*101*8*107*114*111*110*101*104*105*116##": + # "kronehit" (lowercase RDS dynamic follow-up) + assert tuner.media_title == "kronehit" + + elif raw == "*#16*101*7*0*3##": + assert tuner.extra_state_attributes["station"] == 3 + assert tuner.source == "Station 3" + + +async def test_f500n_extended_presets_and_dynamic_expansion(tuner, mock_gateway): + """An F500N supports up to 15 stations; receiving or selecting > 5 expands source_list.""" + assert len(tuner.source_list) == 5 + + # 1. Bus event reports station 8 (within 1..15) + event = MagicMock(spec=OWNSoundEvent) + event.dimension = 7 + event.dimension_value = ["0", "8"] + event.is_on = False + event.is_off = False + tuner.handle_event(event) + + assert tuner.source == "Station 8" + assert tuner.extra_state_attributes["station"] == 8 + # Dynamic expansion of source_list up to station 8 + assert len(tuner.source_list) == 8 + assert "Station 8" in tuner.source_list + assert tuner.source_list == [f"Station {i}" for i in range(1, 9)] + + # 2. Selecting station 12 via select_station + await tuner.async_select_station(12) + assert str(mock_gateway.send.call_args.args[0]) == "*#16*101*#7*12##" + assert tuner.source == "Station 12" + assert len(tuner.source_list) == 12 + assert "Station 12" in tuner.source_list + + # 3. Selecting station 15 via select_source + await tuner.async_select_source("Station 12") + assert tuner.source == "Station 12" + + # 4. Station 15 via play_media (CHANNEL) + # First expand to 15 via bus event + event.dimension_value = ["0", "15"] + tuner.handle_event(event) + assert len(tuner.source_list) == 15 + + await tuner.async_play_media(MediaType.CHANNEL, "15") + assert str(mock_gateway.send.call_args.args[0]) == "*#16*101*#7*15##" + assert tuner.source == "Station 15" + + # Out of range station (16) raises error on select_station + with pytest.raises(HomeAssistantError): + await tuner.async_select_station(16) + + +async def test_f500n_seek_and_frequency_trace_replay(tuner, mock_gateway): + """Replay direct frequency tuning, seek up/down, RDS blanking and dynamic station title.""" + assert FIXTURE_SEEK_FREQ_PATH.is_file(), f"Missing trace fixture: {FIXTURE_SEEK_FREQ_PATH}" + + with open(FIXTURE_SEEK_FREQ_PATH, encoding="utf-8") as f: + trace_data = json.load(f) + + frames = trace_data["frames"] + assert len(frames) == 23 + + # Initially at Station 1 + tuner.handle_event(MagicMock( + spec=OWNSoundEvent, dimension=6, dimension_value=["0", "88800"], + is_on=False, is_off=False, + )) + tuner.handle_event(MagicMock( + spec=OWNSoundEvent, dimension=7, dimension_value=["0", "1"], + is_on=False, is_off=False, + )) + assert tuner.extra_state_attributes["station"] == 1 + assert tuner.source == "Station 1" + assert tuner.extra_state_attributes["frequency"] == 88.8 + + for frame in frames: + raw = frame["raw"] + who = frame.get("who") + dimension = frame.get("dimension") + values = frame.get("dimension_values", []) + + if who == "16" and frame.get("where") == "101" and frame["direction"] == "rx": + event = MagicMock(spec=OWNSoundEvent) + event.raw = raw + event.who = "16" + event.where = "101" + event.dimension = int(dimension) if dimension is not None else None + event.dimension_value = values + event.is_on = False + event.is_off = False + tuner.handle_event(event) + + if raw == "*#16*101*6*0*96200##": + # Direct frequency write to 96.2 MHz clears station preset + assert tuner.extra_state_attributes["frequency"] == 96.2 + assert "station" not in tuner.extra_state_attributes + assert tuner.source is None + + elif raw == "*#16*101*8*32*32*32*32*32*32*32*32##": + # Blank RDS transition frame + assert tuner.media_title is None + + elif raw == "*#16*101*8*107*114*111*110*101*104*105*116##": + # "kronehit" + assert tuner.media_title == "kronehit" + + elif raw == "*#16*101*6*0*89500##": + # Seek up locked on 89.5 MHz (unstored) + assert tuner.extra_state_attributes["frequency"] == 89.5 + assert "station" not in tuner.extra_state_attributes + assert tuner.source is None + + elif raw == "*#16*101*8*66*97*121*101*114*110*32*50##": + # "Bayern 2" + assert tuner.media_title == "Bayern 2" + + elif raw == "*#16*101*7*0*1##": + # Seek down locked on 88.8 MHz (stored as station 1) + assert tuner.extra_state_attributes["station"] == 1 + assert tuner.source == "Station 1" + + elif raw == "*#16*101*8*32*32*79*69*32*51*32*32##": + # " OE 3 " stripped to "OE 3" + assert tuner.media_title == "OE 3" + + # Test seek up and seek down command methods + await tuner.async_seek_up() + assert str(mock_gateway.send.call_args.args[0]) == "*16*5000*101##" + + await tuner.async_seek_down() + assert str(mock_gateway.send.call_args.args[0]) == "*16*5100*101##" +