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
11 changes: 11 additions & 0 deletions custom_components/myhome/config_flow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions custom_components/myhome/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 7 additions & 0 deletions custom_components/myhome/icons.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}

85 changes: 84 additions & 1 deletion custom_components/myhome/media_player.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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 "<zone>#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.
Expand All @@ -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.

Expand Down Expand Up @@ -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)},
)
2 changes: 1 addition & 1 deletion custom_components/myhome/quality_scale.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
16 changes: 16 additions & 0 deletions custom_components/myhome/services.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading
Loading