diff --git a/CODEOWNERS b/CODEOWNERS index f73c4561383493..d15571e1b03619 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -48,6 +48,8 @@ build.json @home-assistant/supervisor /tests/components/acmeda/ @atmurray /homeassistant/components/actron_air/ @kclif9 @JagadishDhanamjayam /tests/components/actron_air/ @kclif9 @JagadishDhanamjayam +/homeassistant/components/adam_audio/ @Perhan35 +/tests/components/adam_audio/ @Perhan35 /homeassistant/components/adax/ @danielhiversen @lazytarget /tests/components/adax/ @danielhiversen @lazytarget /homeassistant/components/adguard/ @frenck diff --git a/homeassistant/components/adam_audio/__init__.py b/homeassistant/components/adam_audio/__init__.py new file mode 100644 index 00000000000000..c5d6e8b6c6591a --- /dev/null +++ b/homeassistant/components/adam_audio/__init__.py @@ -0,0 +1,112 @@ +"""ADAM Audio Home Assistant Integration. + +Supports ADAM Audio A-Series studio monitors via AES70/OCA over UDP. +Auto-discovers speakers via mDNS (_oca._udp.local.) and also accepts +manually configured IP addresses as a fallback. + +Each physical speaker becomes an HA Device with Switch, Select, and Number +child entities. A virtual 'All Speakers' group device is automatically +created to control all speakers simultaneously. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.const import Platform +import homeassistant.helpers.config_validation as cv + +from .const import DOMAIN, LOGGER +from .coordinator import AdamAudioCoordinator +from .data import AdamAudioData, AdamAudioIntegrationData + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + from homeassistant.helpers.typing import ConfigType + + from .data import AdamAudioConfigEntry + + +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + +PLATFORMS: list[Platform] = [ + Platform.NUMBER, + Platform.SELECT, + Platform.SWITCH, +] + + +async def async_setup(hass: HomeAssistant, _config: ConfigType) -> bool: + """Set up the ADAM Audio integration.""" + if DOMAIN not in hass.data: + hass.data[DOMAIN] = AdamAudioIntegrationData(coordinators={}) + return True + + +def get_coordinators(hass: HomeAssistant) -> list[AdamAudioCoordinator]: + """Return all currently loaded ADAM Audio coordinators.""" + data: AdamAudioIntegrationData | None = hass.data.get(DOMAIN) + if not data: + return [] + return list(data.coordinators.values()) + + +# https://developers.home-assistant.io/docs/config_entries_index/#setting-up-an-entry +async def async_setup_entry( + hass: HomeAssistant, + entry: AdamAudioConfigEntry, +) -> bool: + """Set up ADAM Audio from a config entry (one entry = one physical speaker).""" + coordinator = AdamAudioCoordinator(hass, entry) + await coordinator.async_setup() # raises ConfigEntryNotReady if unreachable + + entry.runtime_data = AdamAudioData( + client=coordinator.client, + coordinator=coordinator, + ) + + # Ensure integration-wide state exists (especially for tests) + integration_data = hass.data.setdefault( + DOMAIN, AdamAudioIntegrationData(coordinators={}) + ) + integration_data.coordinators[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + # Re-run setup if the entry's options are updated (e.g., host changed). + entry.async_on_unload(entry.add_update_listener(_async_reload_entry)) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, + entry: AdamAudioConfigEntry, +) -> bool: + """Unload a config entry cleanly.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + # Integration data might missing if async_setup was skipped (e.g. tests) + integration_data: AdamAudioIntegrationData | None = hass.data.get(DOMAIN) + if integration_data: + coordinator = integration_data.coordinators.pop(entry.entry_id, None) + if coordinator: + await coordinator.async_shutdown() + + LOGGER.debug( + "Unloaded entry %s; %d coordinators remaining", + entry.entry_id, + len(integration_data.coordinators), + ) + else: + LOGGER.debug("Skipping coordinator cleanup (domain data missing)") + + return unload_ok + + +async def _async_reload_entry( + hass: HomeAssistant, + entry: AdamAudioConfigEntry, +) -> None: + """Reload entry after options update.""" + await hass.config_entries.async_reload(entry.entry_id) diff --git a/homeassistant/components/adam_audio/client.py b/homeassistant/components/adam_audio/client.py new file mode 100644 index 00000000000000..ba5e52818fda07 --- /dev/null +++ b/homeassistant/components/adam_audio/client.py @@ -0,0 +1,367 @@ +"""Async-safe client wrapper around the AES70/OCA Device layer. + +All blocking socket I/O runs in HA's executor thread pool so the event loop +is never blocked. A single asyncio.Lock serialises all access so commands +and polls never interleave on the UDP socket. + +State management +──────────────── +• SET commands update ``self.state`` optimistically so the UI responds + instantly without waiting for the next poll cycle. +• After each SET, a read-back verification confirms the device accepted the + change. If verification fails, the command is retried up to MAX_RETRIES + times with RETRY_DELAY seconds between attempts. +• ``async_fetch_state()`` polls all 9 GET commands from the device and + overwrites ``self.state`` with the real values. This is called by the + coordinator on every update interval. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +import time +from typing import TYPE_CHECKING, Any + +from pyadamaudiocontroller import Device + +from homeassistant.exceptions import HomeAssistantError + +from .const import LOGGER + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + +@dataclass +class AdamAudioState: + """Current device state.""" + + mute: bool = False + sleep: bool = False + input_source: int = 1 + voicing: int = 0 + bass: int = 0 + desk: int = 0 + presence: int = 0 + treble: int = 0 + + +class AdamAudioClient: + """Manages one UDP connection to a single ADAM Audio A-Series device.""" + + SOCKET_TIMEOUT: float = 10.0 + KEEPALIVE_TIMEOUT: int = 30 + MAX_RETRIES: int = 3 + RETRY_DELAY: float = 0.5 # seconds between retries + + def __init__(self, hass: HomeAssistant, host: str, port: int) -> None: + """Initialize the client.""" + self._hass = hass + self.host = host + self.port = port + self._device: Device | None = None + self._lock = asyncio.Lock() + self._last_keepalive: float = 0.0 + self.available: bool = False + self.device_name: str = "" + self.description: str = "" + self.serial: str = "" + self.state = AdamAudioState() + + async def async_setup(self) -> bool: + """Connect to the device and fetch metadata.""" + return await self._hass.async_add_executor_job(self._setup) + + def _setup(self) -> bool: + """Executor target for initial connection.""" + try: + self._device = Device.from_address(self.host, self.port) + self._device.set_timeout(self.SOCKET_TIMEOUT) + self._device.send_keepalive() + self._last_keepalive = time.monotonic() + self.device_name = self._device.get_name() + self.description = self._device.get_description() + self.serial = self._device.get_serial_number() + self.available = True + LOGGER.info( + "Connected to ADAM Audio '%s' at %s", self.description, self.host + ) + except (OSError, TimeoutError, ValueError, RuntimeError) as err: + LOGGER.warning("Cannot reach ADAM Audio device at %s — %s", self.host, err) + self.available = False + return False + else: + return True + + async def async_shutdown(self) -> None: + """Release the UDP socket.""" + if self._device is not None: + await self._hass.async_add_executor_job(self._device.close) + + async def async_fetch_state(self) -> bool: + """Authoritative full state poll via sequential UDP requests.""" + async with self._lock: + try: + success = await self._hass.async_add_executor_job( + self._fetch_state_blocking + ) + self.available = success + except (OSError, TimeoutError, ValueError, RuntimeError) as err: + LOGGER.debug( + "State fetch critical failure for %s: %s", + self.host, + err, + exc_info=True, + ) + self.available = False + return False + else: + return success + + def _fetch_state_blocking(self) -> bool: + """Executor target for batched state polling.""" + if not self._device: + return False + + self._device.drain() + + # Opportunistic keepalive + try: + now = time.monotonic() + if self._last_keepalive == 0.0 or ( + now - self._last_keepalive > self.KEEPALIVE_TIMEOUT / 2 + ): + self._device.send_keepalive(timeout_secs=5.0) + self._last_keepalive = now + except OSError: + # Drain any late-arriving keepalive response so it doesn't + # get read by the batch poll below. + self._device.drain() + + try: + responses = self._device.get_full_state_pdus() + if not responses or len(responses) < 8: + return False + + # Maps response indices to state attributes + self.state.mute = responses[0].params[0].value == 5 + self.state.sleep = bool(responses[1].params[0].value) + self.state.input_source = int(responses[2].params[0].value) + self.state.voicing = int(responses[3].params[0].value) + self.state.bass = int(responses[4].params[0].value) + self.state.desk = int(responses[5].params[0].value) + self.state.presence = int(responses[6].params[0].value) + self.state.treble = int(responses[7].params[0].value) + except ( + OSError, + TimeoutError, + ValueError, + RuntimeError, + IndexError, + AttributeError, + TypeError, + ): + LOGGER.warning("Batched poll failed for %s", self.host, exc_info=True) + return False + else: + return True + + @property + def _dev(self) -> Device: + """Return the underlying device, raising if not connected.""" + if self._device is None: + raise HomeAssistantError(f"Device at {self.host} is not connected") + return self._device + + def _ensure_keepalive(self) -> None: + """Send keepalive only if the session is truly stale (>30s).""" + if self._device is None: + return + if time.monotonic() - self._last_keepalive > self.KEEPALIVE_TIMEOUT: + try: + self._device.send_keepalive(timeout_secs=1.0) + self._last_keepalive = time.monotonic() + except OSError: + pass + + def _run_set(self, fn: Callable, *args: Any) -> None: + """Executor target for SET commands.""" + self._ensure_keepalive() + fn(*args) + + def _run_get(self, fn: Callable) -> Any: + """Executor target for GET (verification) commands.""" + return fn() + + async def _async_send_with_retry( + self, + set_fn: Callable, + set_args: tuple, + get_fn: Callable, + expected_value: Any, + ) -> None: + """Send a SET command and verify via GET read-back. + + Retries up to MAX_RETRIES times with RETRY_DELAY between attempts. + The lock is acquired for each attempt (send + verify) then released + between retries so polling isn't starved. + """ + for attempt in range(1, self.MAX_RETRIES + 1): + send_failed = False + async with self._lock: + # --- Send --- + try: + await self._hass.async_add_executor_job( + self._run_set, set_fn, *set_args + ) + self.available = True + except OSError as err: + self.available = False + if attempt == self.MAX_RETRIES: + LOGGER.info( + "Command %s failed after %d attempts on %s: %s", + set_fn.__name__, + self.MAX_RETRIES, + self.host, + err, + ) + raise HomeAssistantError( + f"Command {set_fn.__name__} failed after " + f"{self.MAX_RETRIES} attempts: {err}" + ) from err + send_failed = True + + if not send_failed: + # --- Verify --- + try: + actual = await self._hass.async_add_executor_job( + self._run_get, get_fn + ) + if actual == expected_value: + return # ✓ Verified + LOGGER.debug( + "Verify mismatch for %s: expected %s, got %s " + "(attempt %d/%d)", + set_fn.__name__, + expected_value, + actual, + attempt, + self.MAX_RETRIES, + ) + except OSError, TimeoutError, ValueError, RuntimeError: + LOGGER.debug( + "Verify read failed for %s (attempt %d/%d)", + set_fn.__name__, + attempt, + self.MAX_RETRIES, + ) + + # Retry after releasing the lock + if attempt < self.MAX_RETRIES: + await asyncio.sleep(self.RETRY_DELAY) + + LOGGER.info( + "Command %s failed after %d attempts on %s (verification never matched)", + set_fn.__name__, + self.MAX_RETRIES, + self.host, + ) + + # ── Legacy send (for commands without GET verification) ──────────────── + + async def _async_send(self, fn: Callable, *args: Any) -> None: + """Send a single SET command (no verification).""" + async with self._lock: + try: + await self._hass.async_add_executor_job(self._run_set, fn, *args) + self.available = True + except OSError as err: + self.available = False + LOGGER.error("Command to %s failed: %s", self.host, err) + raise HomeAssistantError( + f"Command to {self.host} failed: {err}" + ) from err + + # ── Public SET API ─────────────────────────────────────────────────────── + + async def async_set_mute(self, value: bool) -> None: + """Set the mute state.""" + await self._async_send_with_retry( + self._dev.set_mute, + (value,), + self._dev.get_mute, + value, + ) + self.state.mute = value + + async def async_set_sleep(self, value: bool) -> None: + """Set the sleep/standby state.""" + await self._async_send_with_retry( + self._dev.set_sleep, + (value,), + self._dev.get_sleep, + value, + ) + self.state.sleep = value + + async def async_set_input(self, value: int) -> None: + """Set the input source.""" + await self._async_send_with_retry( + self._dev.set_input, + (value,), + self._dev.get_input, + value, + ) + self.state.input_source = value + + async def async_set_voicing(self, value: int) -> None: + """Set the voicing mode.""" + await self._async_send_with_retry( + self._dev.set_voicing, + (value,), + self._dev.get_voicing, + value, + ) + self.state.voicing = value + + async def async_set_bass(self, value: int) -> None: + """Set the bass EQ level.""" + await self._async_send_with_retry( + self._dev.set_bass, + (value,), + self._dev.get_bass, + value, + ) + self.state.bass = value + + async def async_set_desk(self, value: int) -> None: + """Set the desk EQ level.""" + await self._async_send_with_retry( + self._dev.set_desk, + (value,), + self._dev.get_desk, + value, + ) + self.state.desk = value + + async def async_set_presence(self, value: int) -> None: + """Set the presence EQ level.""" + await self._async_send_with_retry( + self._dev.set_presence, + (value,), + self._dev.get_presence, + value, + ) + self.state.presence = value + + async def async_set_treble(self, value: int) -> None: + """Set the treble EQ level.""" + await self._async_send_with_retry( + self._dev.set_treble, + (value,), + self._dev.get_treble, + value, + ) + self.state.treble = value diff --git a/homeassistant/components/adam_audio/config_flow.py b/homeassistant/components/adam_audio/config_flow.py new file mode 100644 index 00000000000000..7a6c0f1d230367 --- /dev/null +++ b/homeassistant/components/adam_audio/config_flow.py @@ -0,0 +1,193 @@ +"""Config flow for ADAM Audio. + +Two entry points: + 1. Zeroconf auto-discovery — HA detects an _oca._udp.local. service and + triggers async_step_zeroconf. The user just confirms. + 2. Manual — User adds the integration from the UI and + types in an IP address + port. + +In both cases we attempt a real connection to the device to verify +reachability and fetch the human-readable description ("Left", "Right", …) +before creating the config entry. +""" + +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_HOST, CONF_PORT +from homeassistant.helpers.selector import ( + NumberSelector, + NumberSelectorConfig, + NumberSelectorMode, + TextSelector, +) +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .client import AdamAudioClient +from .const import ( + CONF_DESCRIPTION, + CONF_DEVICE_NAME, + CONF_SERIAL, + DEFAULT_PORT, + DOMAIN, + LOGGER, +) + +_MANUAL_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): TextSelector(), + vol.Optional(CONF_PORT, default=DEFAULT_PORT): NumberSelector( + NumberSelectorConfig(min=1, max=65535, step=1, mode=NumberSelectorMode.BOX) + ), + } +) + + +class AdamAudioConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow that handles both zeroconf discovery and manual IP entry. + + A unique_id is set to the device's serial number (preferred) or hardware + name so that the same physical speaker is never registered twice, even if + its IP address changes and it is re-discovered. + """ + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovery: dict[str, Any] = {} + + # ── Manual entry ────────────────────────────────────────────────────────── + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step (manual IP entry).""" + errors: dict[str, str] = {} + + if user_input is not None: + host = user_input[CONF_HOST].strip() + port = int(user_input.get(CONF_PORT, DEFAULT_PORT)) + + result = await self._async_try_connect(host, port) + if result is None: + errors["base"] = "cannot_connect" + else: + device_name, description, serial = result + await self.async_set_unique_id(serial or device_name) + self._abort_if_unique_id_configured( + updates={CONF_HOST: host, CONF_PORT: port} + ) + return self.async_create_entry( + title=description or device_name, + data={ + CONF_HOST: host, + CONF_PORT: port, + CONF_DEVICE_NAME: device_name, + CONF_DESCRIPTION: description, + CONF_SERIAL: serial, + }, + ) + + return self.async_show_form( + step_id="user", + data_schema=_MANUAL_SCHEMA, + errors=errors, + description_placeholders={"default_port": str(DEFAULT_PORT)}, + ) + + # ── Zeroconf discovery ──────────────────────────────────────────────────── + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery of an _oca._udp.local. service.""" + host: str = discovery_info.host + port: int = discovery_info.port or DEFAULT_PORT + + # Derive a stable device_id from the mDNS hostname. + # hostname is like "ASeries-41472b.local." → strip to "ASeries-41472b" + device_id: str = ( + discovery_info.hostname.rstrip(".") + .removesuffix(".local") + .removesuffix(".local.") + ) + if not device_id: + device_id = host.replace(".", "_") + + # Attempt to connect and retrieve device metadata (including serial). + result = await self._async_try_connect(host, port) + if result is not None: + _device_name, description, serial = result + else: + _device_name, description, serial = device_id, device_id, "" + + # Prefer serial (stable, device-embedded) as unique_id; fall back to hostname. + await self.async_set_unique_id(serial or device_id) + # If this device is already configured, just update its IP/port silently. + self._abort_if_unique_id_configured(updates={CONF_HOST: host, CONF_PORT: port}) + + self._discovery = { + CONF_HOST: host, + CONF_PORT: port, + CONF_DEVICE_NAME: _device_name or device_id, + CONF_DESCRIPTION: description, + CONF_SERIAL: serial, + } + + self.context["title_placeholders"] = { + "name": description or device_id, + "host": host, + } + + return await self.async_step_zeroconf_confirm() + + async def async_step_zeroconf_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirmation step shown after zeroconf discovery.""" + if user_input is not None: + return self.async_create_entry( + title=self._discovery.get(CONF_DESCRIPTION) + or self._discovery.get(CONF_DEVICE_NAME, "ADAM Audio"), + data=self._discovery, + ) + + description = self._discovery.get(CONF_DESCRIPTION, "") + host = self._discovery.get(CONF_HOST, "") + + return self.async_show_form( + step_id="zeroconf_confirm", + description_placeholders={ + "name": description or self._discovery.get(CONF_DEVICE_NAME, ""), + "host": host, + }, + ) + + # ── Helpers ─────────────────────────────────────────────────────────────── + + async def _async_try_connect( + self, host: str, port: int + ) -> tuple[str, str, str] | None: + """Open a temporary connection to verify the device is reachable. + + Returns (device_name, description, serial) on success, None on failure. + """ + client = AdamAudioClient(self.hass, host, port) + try: + connected = await client.async_setup() + if not connected: + return None + except OSError, TimeoutError, ValueError, RuntimeError: + LOGGER.debug( + "Connection attempt to %s:%d failed", host, port, exc_info=True + ) + return None + else: + return client.device_name, client.description, client.serial + finally: + await client.async_shutdown() diff --git a/homeassistant/components/adam_audio/const.py b/homeassistant/components/adam_audio/const.py new file mode 100644 index 00000000000000..4ac6ad4ff8d2be --- /dev/null +++ b/homeassistant/components/adam_audio/const.py @@ -0,0 +1,64 @@ +"""Constants for the ADAM Audio integration.""" + +from logging import Logger, getLogger + +LOGGER: Logger = getLogger(__package__) + +DOMAIN = "adam_audio" +MANUFACTURER = "ADAM Audio" + +DEFAULT_PORT = 49494 +SOCKET_TIMEOUT = 10.0 + +# How often HA polls the device for current state (reflects physical knob / +# A Control changes in HA). Lower = more responsive, higher = less chatter. +POLL_INTERVAL = 15 # seconds +KEEPALIVE_TIMEOUT_SECS = 30 # OCA keepalive timeout we advertise to the device + +SERVICE_TYPE = "_oca._udp.local." + +# Backward-compat alias kept so nothing else needs changing. +KEEPALIVE_INTERVAL = POLL_INTERVAL + +# ── Config entry keys ──────────────────────────────────────────────────────── +CONF_HOST = "host" +CONF_PORT = "port" +CONF_DEVICE_NAME = "device_name" # hardware name, e.g. "ASeries-41472b" +CONF_DESCRIPTION = "description" # user-facing name, e.g. "Left" +CONF_SERIAL = "serial" + +# ── Entity keys ────────────────────────────────────────────────────────────── +ENTITY_MUTE = "mute" +ENTITY_SLEEP = "sleep" +ENTITY_INPUT = "input_source" +ENTITY_VOICING = "voicing" +ENTITY_BASS = "bass" +ENTITY_DESK = "desk" +ENTITY_PRESENCE = "presence" +ENTITY_TREBLE = "treble" + +# ── Group ──────────────────────────────────────────────────────────────────── +GROUP_DEVICE_ID = "group_all_speakers" +GROUP_DEVICE_NAME = "All Speakers" + +# ── Voicing ────────────────────────────────────────────────────────────────── +VOICING_OPTIONS = ["Pure", "UNR", "Ext"] +VOICING_TO_INT: dict[str, int] = {"Pure": 0, "UNR": 1, "Ext": 2} +VOICING_FROM_INT: dict[int, str] = {0: "Pure", 1: "UNR", 2: "Ext"} + +# ── Input source ───────────────────────────────────────────────────────────── +INPUT_OPTIONS = ["RCA", "XLR"] +INPUT_TO_INT: dict[str, int] = {"RCA": 0, "XLR": 1} +INPUT_FROM_INT: dict[int, str] = {0: "RCA", 1: "XLR"} + +# ── EQ ranges (direct integer values sent to device) ──────────────────────── +BASS_MIN = -2 +BASS_MAX = 1 +DESK_MIN = -2 +DESK_MAX = 0 +PRESENCE_MIN = -1 +PRESENCE_MAX = 1 +TREBLE_MIN = -1 +TREBLE_MAX = 1 +EQ_STEP = 1 +EQ_UNIT = "" diff --git a/homeassistant/components/adam_audio/coordinator.py b/homeassistant/components/adam_audio/coordinator.py new file mode 100644 index 00000000000000..836964d14bd1b9 --- /dev/null +++ b/homeassistant/components/adam_audio/coordinator.py @@ -0,0 +1,134 @@ +"""DataUpdateCoordinator for a single ADAM Audio device. + +Update loop +----------- +Every POLL_INTERVAL seconds the coordinator calls client.async_fetch_state(), +which sends a keepalive followed by GET commands for every controllable +parameter. The GET responses populate client.state with the real device +values, so changes made via the physical knob or ADAM Audio's A +Control app are reflected in Home Assistant within one poll cycle. + +If the fetch fails (device unreachable), UpdateFailed is raised so HA marks +all child entities as unavailable until the next successful poll. +""" + +from __future__ import annotations + +from datetime import timedelta +from typing import TYPE_CHECKING + +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .client import AdamAudioClient, AdamAudioState +from .const import ( + CONF_DESCRIPTION, + CONF_DEVICE_NAME, + CONF_HOST, + CONF_PORT, + CONF_SERIAL, + DOMAIN, + LOGGER, + MANUFACTURER, + POLL_INTERVAL, +) + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + from .data import AdamAudioConfigEntry + + +# https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities +class AdamAudioCoordinator(DataUpdateCoordinator[AdamAudioState]): + """Manages one ADAM Audio device. + + One coordinator is created per config entry (= per physical speaker). + The update loop runs every POLL_INTERVAL seconds and issues a full state + poll (keepalive + all GET commands) to keep HA in sync with the device. + """ + + config_entry: AdamAudioConfigEntry + + def __init__(self, hass: HomeAssistant, entry: AdamAudioConfigEntry) -> None: + """Initialize the coordinator.""" + self.client = AdamAudioClient( + hass, + host=entry.data[CONF_HOST], + port=entry.data[CONF_PORT], + ) + + # Metadata — may be refreshed from the live device during async_setup + self.device_unique_id: str = entry.data[CONF_DEVICE_NAME] + self.device_description: str = entry.data.get(CONF_DESCRIPTION, "ADAM Audio") + self.device_serial: str = entry.data.get(CONF_SERIAL, "") + + super().__init__( + hass, + LOGGER, + name=f"{DOMAIN}_{self.device_unique_id}", + update_interval=timedelta(seconds=POLL_INTERVAL), + always_update=False, + config_entry=entry, + ) + + # ── Public setup / teardown ─────────────────────────────────────────────── + + async def async_setup(self) -> None: + """Connect to the device. + + Raises ConfigEntryNotReady if the device cannot be reached so HA + retries later. + """ + connected = await self.client.async_setup() + if not connected: + raise ConfigEntryNotReady( + f"Cannot connect to ADAM Audio device at " + f"{self.client.host}:{self.client.port}. " + "Is the speaker powered on and on the local network?" + ) + # Prefer live values over what was persisted in the config entry. + if self.client.description: + self.device_description = self.client.description + if self.client.serial: + self.device_serial = self.client.serial + + # First refresh also does a full state poll so entities have real + # values from the moment they appear in HA. + await self.async_config_entry_first_refresh() + + async def async_shutdown(self) -> None: + """Release resources when the config entry is unloaded.""" + await self.client.async_shutdown() + + # ── Coordinator update callback ─────────────────────────────────────────── + + async def _async_update_data(self) -> AdamAudioState: + """Fetch current device state. + + Sends keepalive + all GET commands. On success, client.state holds + the values the device reported; entities read from there. + Raises UpdateFailed to mark entities unavailable if unreachable. + """ + success = await self.client.async_fetch_state() + if not success: + raise UpdateFailed( + f"Device '{self.device_description}' unreachable at " + f"{self.client.host}:{self.client.port}" + ) + return self.client.state + + # ── Device info (shared by all child entities) ──────────────────────────── + + @property + def device_info(self) -> DeviceInfo: + """Return device info for the device registry.""" + return DeviceInfo( + identifiers={(DOMAIN, self.device_unique_id)}, + name=self.device_description, + manufacturer=MANUFACTURER, + model="A-Series", + serial_number=self.device_serial or None, + configuration_url=None, + ) diff --git a/homeassistant/components/adam_audio/data.py b/homeassistant/components/adam_audio/data.py new file mode 100644 index 00000000000000..fe95ec2d1850e9 --- /dev/null +++ b/homeassistant/components/adam_audio/data.py @@ -0,0 +1,33 @@ +"""Custom types for adam_audio.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntry + + from .client import AdamAudioClient + from .coordinator import AdamAudioCoordinator + + +type AdamAudioConfigEntry = ConfigEntry[AdamAudioData] + + +@dataclass +class AdamAudioData: + """Data for the ADAM Audio integration.""" + + client: AdamAudioClient + coordinator: AdamAudioCoordinator + + +@dataclass +class AdamAudioIntegrationData: + """Integration-wide data stored in hass.data[DOMAIN].""" + + coordinators: dict[str, AdamAudioCoordinator] + group_switches_added: bool = False + group_numbers_added: bool = False + group_selects_added: bool = False diff --git a/homeassistant/components/adam_audio/entity.py b/homeassistant/components/adam_audio/entity.py new file mode 100644 index 00000000000000..b1e48e66c6f981 --- /dev/null +++ b/homeassistant/components/adam_audio/entity.py @@ -0,0 +1,115 @@ +"""Base entity classes for ADAM Audio. + +Two flavours: + AdamAudioEntity - CoordinatorEntity bound to a single physical device. + AdamAudioGroupEntity - Plain Entity that fans commands out to ALL devices + registered at call-time. It self-subscribes to every + coordinator's update bus so the group state stays fresh. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from . import get_coordinators +from .const import DOMAIN, GROUP_DEVICE_ID, GROUP_DEVICE_NAME, MANUFACTURER +from .coordinator import AdamAudioCoordinator + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + + +class AdamAudioEntity(CoordinatorEntity[AdamAudioCoordinator]): + """Base entity for a single physical speaker.""" + + _attr_has_entity_name = True + + def __init__(self, coordinator: AdamAudioCoordinator) -> None: + """Initialize the entity.""" + super().__init__(coordinator) + self._attr_device_info = coordinator.device_info + + @property + def available(self) -> bool: + """Mark unavailable if the coordinator fails or the client drops.""" + return super().available and self.coordinator.client.available + + +class AdamAudioGroupEntity(Entity): + """Base entity for the virtual 'All Speakers' device. + + Commands are dispatched concurrently to every real device coordinator. + State is derived from the collective state of all coordinators. + """ + + _attr_has_entity_name = True + _attr_should_poll = False + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize the group entity.""" + self._hass = hass + self._unsub_listeners: list = [] + self._subscribed_count: int = 0 + + # ── Device info ────────────────────────────────────────────────────────── + + @property + def device_info(self) -> DeviceInfo: + """Return device info for the 'All Speakers' group device.""" + return DeviceInfo( + identifiers={(DOMAIN, GROUP_DEVICE_ID)}, + name=GROUP_DEVICE_NAME, + manufacturer=MANUFACTURER, + model="Group", + ) + + # ── Coordinator helpers ────────────────────────────────────────────────── + + def _coordinators(self) -> list[AdamAudioCoordinator]: + """Return all currently loaded device coordinators.""" + return get_coordinators(self._hass) + + # ── HA lifecycle hooks ─────────────────────────────────────────────────── + + async def async_added_to_hass(self) -> None: + """Subscribe to all coordinators so the group state stays live.""" + self._subscribe_coordinators() + + def _subscribe_coordinators(self) -> None: + """(Re-)subscribe to update events from every known coordinator.""" + for unsub in self._unsub_listeners: + unsub() + self._unsub_listeners.clear() + + @callback + def _on_coordinator_update() -> None: + self.async_write_ha_state() + + for coordinator in self._coordinators(): + self._unsub_listeners.append( + coordinator.async_add_listener(_on_coordinator_update) + ) + self._subscribed_count = len(self._unsub_listeners) + + @callback + def async_write_ha_state(self) -> None: + """Re-subscribe if new coordinators were added since last subscription.""" + current_count = len(self._coordinators()) + if current_count != self._subscribed_count: + self._subscribe_coordinators() + super().async_write_ha_state() + + async def async_will_remove_from_hass(self) -> None: + """Clean up coordinator listeners.""" + for unsub in self._unsub_listeners: + unsub() + + @property + def available(self) -> bool: + """Group is available if at least one device is online.""" + return any(c.client.available for c in self._coordinators()) diff --git a/homeassistant/components/adam_audio/manifest.json b/homeassistant/components/adam_audio/manifest.json new file mode 100644 index 00000000000000..e3fa2e13f8f3b6 --- /dev/null +++ b/homeassistant/components/adam_audio/manifest.json @@ -0,0 +1,18 @@ +{ + "domain": "adam_audio", + "name": "ADAM Audio", + "codeowners": ["@Perhan35"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/adam_audio", + "integration_type": "device", + "iot_class": "local_polling", + "loggers": ["pyadamaudiocontroller"], + "quality_scale": "bronze", + "requirements": ["pyadamaudiocontroller==1.0.0"], + "zeroconf": [ + { + "name": "aseries*", + "type": "_oca._udp.local." + } + ] +} diff --git a/homeassistant/components/adam_audio/number.py b/homeassistant/components/adam_audio/number.py new file mode 100644 index 00000000000000..7c298198154f55 --- /dev/null +++ b/homeassistant/components/adam_audio/number.py @@ -0,0 +1,230 @@ +"""Number platform for ADAM Audio — EQ controls. + +EQ controls (Bass, Desk, Presence, Treble) use integer dB steps. +""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from homeassistant.components.number import NumberEntity, NumberMode + +from .client import AdamAudioState +from .const import ( + BASS_MAX, + BASS_MIN, + DESK_MAX, + DESK_MIN, + DOMAIN, + ENTITY_BASS, + ENTITY_DESK, + ENTITY_PRESENCE, + ENTITY_TREBLE, + EQ_STEP, + EQ_UNIT, + GROUP_DEVICE_ID, + PRESENCE_MAX, + PRESENCE_MIN, + TREBLE_MAX, + TREBLE_MIN, +) +from .coordinator import AdamAudioCoordinator +from .entity import AdamAudioEntity, AdamAudioGroupEntity + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + + from .data import AdamAudioConfigEntry, AdamAudioIntegrationData + + +# ── Entity descriptors ──────────────────────────────────────────────────────── + + +@dataclass(frozen=True) +class _NumberDesc: + """Descriptor for a number entity.""" + + translation_key: str + icon: str + native_min: float + native_max: float + native_step: float + native_unit: str + state_getter: Callable[[AdamAudioState], float] + # Async setter signature: async_set_XXX(value) + setter_name: str + # Voicing modes where this control is active (None = always available) + valid_voicings: tuple[int, ...] | None = None + + +_NUMBER_DESCRIPTORS: tuple[_NumberDesc, ...] = ( + _NumberDesc( + translation_key=ENTITY_BASS, + icon="mdi:equalizer", + native_min=BASS_MIN, + native_max=BASS_MAX, + native_step=EQ_STEP, + native_unit=EQ_UNIT, + state_getter=lambda s: float(s.bass), + setter_name="async_set_bass", + valid_voicings=(0, 1), # Pure, UNR + ), + _NumberDesc( + translation_key=ENTITY_DESK, + icon="mdi:tune-vertical", + native_min=DESK_MIN, + native_max=DESK_MAX, + native_step=EQ_STEP, + native_unit=EQ_UNIT, + state_getter=lambda s: float(s.desk), + setter_name="async_set_desk", + valid_voicings=(0, 1), # Pure, UNR + ), + _NumberDesc( + translation_key=ENTITY_PRESENCE, + icon="mdi:tune", + native_min=PRESENCE_MIN, + native_max=PRESENCE_MAX, + native_step=EQ_STEP, + native_unit=EQ_UNIT, + state_getter=lambda s: float(s.presence), + setter_name="async_set_presence", + valid_voicings=(0, 1), # Pure, UNR + ), + _NumberDesc( + translation_key=ENTITY_TREBLE, + icon="mdi:tune-vertical-variant", + native_min=TREBLE_MIN, + native_max=TREBLE_MAX, + native_step=EQ_STEP, + native_unit=EQ_UNIT, + state_getter=lambda s: float(s.treble), + setter_name="async_set_treble", + valid_voicings=(0, 1), # Pure, UNR + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AdamAudioConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the number platform.""" + coordinator = entry.runtime_data.coordinator + integration_data: AdamAudioIntegrationData = hass.data[DOMAIN] + + entities: list[NumberEntity] = [ + AdamAudioNumber(coordinator, desc) for desc in _NUMBER_DESCRIPTORS + ] + + if not integration_data.group_numbers_added: + integration_data.group_numbers_added = True + entities += [AdamAudioGroupNumber(hass, desc) for desc in _NUMBER_DESCRIPTORS] + + async_add_entities(entities) + + +# ── Per-device number ───────────────────────────────────────────────────────── + + +class AdamAudioNumber(AdamAudioEntity, NumberEntity): + """Number entity for a single speaker.""" + + _attr_mode = NumberMode.SLIDER + + def __init__(self, coordinator: AdamAudioCoordinator, desc: _NumberDesc) -> None: + """Initialize the number entity.""" + super().__init__(coordinator) + self._desc = desc + self._attr_translation_key = desc.translation_key + self._attr_unique_id = ( + f"{DOMAIN}_{coordinator.device_unique_id}_{desc.translation_key}" + ) + self._attr_icon = desc.icon + self._attr_native_min_value = desc.native_min + self._attr_native_max_value = desc.native_max + self._attr_native_step = desc.native_step + self._attr_native_unit_of_measurement = desc.native_unit + + @property + def available(self) -> bool: + """Return true if the entity is available and the voicing is valid.""" + if not super().available: + return False + if self._desc.valid_voicings is not None: + return self.coordinator.client.state.voicing in self._desc.valid_voicings + return True + + @property + def native_value(self) -> float: + """Return the current value.""" + return self._desc.state_getter(self.coordinator.client.state) + + async def async_set_native_value(self, value: float) -> None: + """Set the value on the device.""" + setter = getattr(self.coordinator.client, self._desc.setter_name) + # EQ controls expect int + if self._desc.native_step == 1.0: + value = int(value) + await setter(value) + self.async_write_ha_state() + + +# ── Group number ────────────────────────────────────────────────────────────── + + +class AdamAudioGroupNumber(AdamAudioGroupEntity, NumberEntity): + """Number entity that controls ALL speakers simultaneously.""" + + _attr_mode = NumberMode.SLIDER + + def __init__(self, hass: HomeAssistant, desc: _NumberDesc) -> None: + """Initialize the group number entity.""" + super().__init__(hass) + self._desc = desc + self._attr_translation_key = desc.translation_key + self._attr_unique_id = f"{DOMAIN}_{GROUP_DEVICE_ID}_{desc.translation_key}" + self._attr_icon = desc.icon + self._attr_native_min_value = desc.native_min + self._attr_native_max_value = desc.native_max + self._attr_native_step = desc.native_step + self._attr_native_unit_of_measurement = desc.native_unit + + @property + def available(self) -> bool: + """Return true if at least one speaker supports the voicing.""" + if not super().available: + return False + if self._desc.valid_voicings is not None: + return any( + c.client.state.voicing in self._desc.valid_voicings + for c in self._coordinators() + ) + return True + + @property + def native_value(self) -> float: + """Return the average across all speakers.""" + coordinators = self._coordinators() + if not coordinators: + return self._desc.native_min + values = [self._desc.state_getter(c.client.state) for c in coordinators] + return round(sum(values) / len(values), 1) + + async def async_set_native_value(self, value: float) -> None: + """Set the value on all speakers.""" + if self._desc.native_step == 1.0: + value = int(value) + coordinators = self._coordinators() + await asyncio.gather( + *(getattr(c.client, self._desc.setter_name)(value) for c in coordinators) + ) + # Push the optimistic state to all per-speaker entities instantly + for c in coordinators: + c.async_set_updated_data(c.client.state) + self.async_write_ha_state() diff --git a/homeassistant/components/adam_audio/quality_scale.yaml b/homeassistant/components/adam_audio/quality_scale.yaml new file mode 100644 index 00000000000000..1f6df350350816 --- /dev/null +++ b/homeassistant/components/adam_audio/quality_scale.yaml @@ -0,0 +1,26 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not register custom actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Entities do not subscribe to events. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done diff --git a/homeassistant/components/adam_audio/select.py b/homeassistant/components/adam_audio/select.py new file mode 100644 index 00000000000000..39b319173c2c4b --- /dev/null +++ b/homeassistant/components/adam_audio/select.py @@ -0,0 +1,175 @@ +"""Select platform for ADAM Audio — Input Source and Voicing. + +Each physical speaker exposes two selects; one 'All Speakers' group select +for each is created once. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING + +from homeassistant.components.select import SelectEntity + +from .const import ( + DOMAIN, + ENTITY_INPUT, + ENTITY_VOICING, + GROUP_DEVICE_ID, + INPUT_FROM_INT, + INPUT_OPTIONS, + INPUT_TO_INT, + VOICING_FROM_INT, + VOICING_OPTIONS, + VOICING_TO_INT, +) +from .coordinator import AdamAudioCoordinator +from .entity import AdamAudioEntity, AdamAudioGroupEntity + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + + from .data import AdamAudioConfigEntry, AdamAudioIntegrationData + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AdamAudioConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the select platform.""" + coordinator = entry.runtime_data.coordinator + integration_data: AdamAudioIntegrationData = hass.data[DOMAIN] + + entities: list[SelectEntity] = [ + AdamAudioVoicingSelect(coordinator), + AdamAudioInputSelect(coordinator), + ] + + if not integration_data.group_selects_added: + integration_data.group_selects_added = True + entities += [ + AdamAudioGroupVoicingSelect(hass), + AdamAudioGroupInputSelect(hass), + ] + + async_add_entities(entities) + + +# ── Per-device selects ──────────────────────────────────────────────────────── + + +class AdamAudioInputSelect(AdamAudioEntity, SelectEntity): + """Input source selector for a single speaker (RCA / XLR).""" + + _attr_translation_key = "input_source" + _attr_icon = "mdi:import" + _attr_options = INPUT_OPTIONS + + def __init__(self, coordinator: AdamAudioCoordinator) -> None: + """Initialize the input select.""" + super().__init__(coordinator) + self._attr_unique_id = f"{DOMAIN}_{coordinator.device_unique_id}_{ENTITY_INPUT}" + + @property + def current_option(self) -> str: + """Return the current input source.""" + return INPUT_FROM_INT.get(self.coordinator.client.state.input_source, "XLR") + + async def async_select_option(self, option: str) -> None: + """Set the input source.""" + await self.coordinator.client.async_set_input(INPUT_TO_INT[option]) + self.async_write_ha_state() + + +class AdamAudioVoicingSelect(AdamAudioEntity, SelectEntity): + """Voicing selector for a single speaker (Pure / UNR / Ext).""" + + _attr_translation_key = "voicing" + _attr_icon = "mdi:equalizer-outline" + _attr_options = VOICING_OPTIONS + + def __init__(self, coordinator: AdamAudioCoordinator) -> None: + """Initialize the voicing select.""" + super().__init__(coordinator) + self._attr_unique_id = ( + f"{DOMAIN}_{coordinator.device_unique_id}_{ENTITY_VOICING}" + ) + + @property + def current_option(self) -> str: + """Return the current voicing mode.""" + return VOICING_FROM_INT.get(self.coordinator.client.state.voicing, "Pure") + + async def async_select_option(self, option: str) -> None: + """Set the voicing mode.""" + await self.coordinator.client.async_set_voicing(VOICING_TO_INT[option]) + self.async_write_ha_state() + + +# ── Group selects ───────────────────────────────────────────────────────────── + + +class AdamAudioGroupInputSelect(AdamAudioGroupEntity, SelectEntity): + """Input source selector that controls ALL speakers.""" + + _attr_translation_key = "input_source" + _attr_icon = "mdi:import" + _attr_options = INPUT_OPTIONS + _attr_unique_id = f"{DOMAIN}_{GROUP_DEVICE_ID}_{ENTITY_INPUT}" + + @property + def current_option(self) -> str: + """Return the common input source, or the first speaker's value.""" + coordinators = self._coordinators() + if not coordinators: + return INPUT_OPTIONS[0] + values = {c.client.state.input_source for c in coordinators} + raw = ( + next(iter(values)) + if len(values) == 1 + else coordinators[0].client.state.input_source + ) + return INPUT_FROM_INT.get(raw, "XLR") + + async def async_select_option(self, option: str) -> None: + """Set the input source on all speakers.""" + value = INPUT_TO_INT[option] + coordinators = self._coordinators() + await asyncio.gather(*(c.client.async_set_input(value) for c in coordinators)) + for c in coordinators: + c.async_set_updated_data(c.client.state) + self.async_write_ha_state() + + +class AdamAudioGroupVoicingSelect(AdamAudioGroupEntity, SelectEntity): + """Voicing selector that controls ALL speakers.""" + + _attr_translation_key = "voicing" + _attr_icon = "mdi:equalizer-outline" + _attr_options = VOICING_OPTIONS + _attr_unique_id = f"{DOMAIN}_{GROUP_DEVICE_ID}_{ENTITY_VOICING}" + + @property + def current_option(self) -> str: + """Return the common voicing mode, or the first speaker's value.""" + coordinators = self._coordinators() + if not coordinators: + return VOICING_OPTIONS[0] + values = {c.client.state.voicing for c in coordinators} + raw = ( + next(iter(values)) + if len(values) == 1 + else coordinators[0].client.state.voicing + ) + return VOICING_FROM_INT.get(raw, "Pure") + + async def async_select_option(self, option: str) -> None: + """Set the voicing mode on all speakers.""" + value = VOICING_TO_INT[option] + coordinators = self._coordinators() + await asyncio.gather(*(c.client.async_set_voicing(value) for c in coordinators)) + for c in coordinators: + c.async_set_updated_data(c.client.state) + self.async_write_ha_state() diff --git a/homeassistant/components/adam_audio/strings.json b/homeassistant/components/adam_audio/strings.json new file mode 100644 index 00000000000000..77addd5a9f45bb --- /dev/null +++ b/homeassistant/components/adam_audio/strings.json @@ -0,0 +1,60 @@ +{ + "config": { + "abort": { + "already_configured": "This speaker is already configured. Its network address has been updated." + }, + "error": { + "cannot_connect": "Cannot connect to the device. Make sure the speaker is powered on and reachable on your local network, then try again." + }, + "step": { + "user": { + "data": { + "host": "IP address or hostname", + "port": "UDP port (default 49494)" + }, + "data_description": { + "host": "The IP address or hostname of your ADAM Audio speaker.", + "port": "The UDP port of your ADAM Audio speaker." + }, + "description": "Enter the IP address of your ADAM Audio A-Series speaker. If you don't know the port, leave it at the default ({default_port}).", + "title": "Add ADAM Audio speaker" + }, + "zeroconf_confirm": { + "description": "Found **{name}** at `{host}`. Do you want to add it to Home Assistant?", + "title": "ADAM Audio speaker found" + } + } + }, + "entity": { + "number": { + "bass": { + "name": "Bass" + }, + "desk": { + "name": "Desk" + }, + "presence": { + "name": "Presence" + }, + "treble": { + "name": "Treble" + } + }, + "select": { + "input_source": { + "name": "Input Source" + }, + "voicing": { + "name": "Voicing" + } + }, + "switch": { + "mute": { + "name": "Mute" + }, + "sleep": { + "name": "Sleep" + } + } + } +} diff --git a/homeassistant/components/adam_audio/switch.py b/homeassistant/components/adam_audio/switch.py new file mode 100644 index 00000000000000..266a8226b7d15c --- /dev/null +++ b/homeassistant/components/adam_audio/switch.py @@ -0,0 +1,190 @@ +"""Switch platform for ADAM Audio — Mute and Sleep. + +Each physical speaker exposes two switches. A single 'All Speakers' group +switch is also created the first time the platform is loaded; subsequent +config-entry loads skip it because the unique_id is already registered. +""" + +from __future__ import annotations + +import asyncio +from typing import TYPE_CHECKING, Any + +from homeassistant.components.switch import SwitchEntity + +from .const import DOMAIN, ENTITY_MUTE, ENTITY_SLEEP, GROUP_DEVICE_ID +from .coordinator import AdamAudioCoordinator +from .entity import AdamAudioEntity, AdamAudioGroupEntity + +if TYPE_CHECKING: + from homeassistant.core import HomeAssistant + from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + + from .data import AdamAudioConfigEntry, AdamAudioIntegrationData + + +async def async_setup_entry( + hass: HomeAssistant, + entry: AdamAudioConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the switch platform.""" + coordinator = entry.runtime_data.coordinator + integration_data: AdamAudioIntegrationData = hass.data[DOMAIN] + + entities: list[SwitchEntity] = [ + AdamAudioSleepSwitch(coordinator), + AdamAudioMuteSwitch(coordinator), + ] + + # Create group entities exactly once per HA lifecycle. + if not integration_data.group_switches_added: + integration_data.group_switches_added = True + entities += [ + AdamAudioGroupSleepSwitch(hass), + AdamAudioGroupMuteSwitch(hass), + ] + + async_add_entities(entities) + + +# ── Per-device switches ─────────────────────────────────────────────────────── + + +class AdamAudioMuteSwitch(AdamAudioEntity, SwitchEntity): + """Mute switch for a single speaker.""" + + _attr_translation_key = "mute" + _attr_icon = "mdi:volume-off" + + def __init__(self, coordinator: AdamAudioCoordinator) -> None: + """Initialize the mute switch.""" + super().__init__(coordinator) + self._attr_unique_id = f"{DOMAIN}_{coordinator.device_unique_id}_{ENTITY_MUTE}" + + @property + def is_on(self) -> bool: + """Return true if muted.""" + return self.coordinator.client.state.mute + + @property + def icon(self) -> str: + """Return icon based on mute state.""" + return "mdi:volume-off" if self.is_on else "mdi:volume-high" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on mute.""" + await self.coordinator.client.async_set_mute(True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off mute.""" + await self.coordinator.client.async_set_mute(False) + self.async_write_ha_state() + + +class AdamAudioSleepSwitch(AdamAudioEntity, SwitchEntity): + """Standby (sleep) switch for a single speaker.""" + + _attr_translation_key = "sleep" + _attr_icon = "mdi:power-sleep" + + def __init__(self, coordinator: AdamAudioCoordinator) -> None: + """Initialize the sleep switch.""" + super().__init__(coordinator) + self._attr_unique_id = f"{DOMAIN}_{coordinator.device_unique_id}_{ENTITY_SLEEP}" + + @property + def is_on(self) -> bool: + """Return true if sleeping.""" + return self.coordinator.client.state.sleep + + @property + def icon(self) -> str: + """Return icon based on sleep state.""" + return "mdi:power-sleep" if self.is_on else "mdi:power" + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on sleep mode.""" + await self.coordinator.client.async_set_sleep(True) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off sleep mode.""" + await self.coordinator.client.async_set_sleep(False) + self.async_write_ha_state() + + +# ── Group switches ──────────────────────────────────────────────────────────── + + +class AdamAudioGroupMuteSwitch(AdamAudioGroupEntity, SwitchEntity): + """Mute switch that controls ALL speakers simultaneously.""" + + _attr_translation_key = "mute" + _attr_unique_id = f"{DOMAIN}_{GROUP_DEVICE_ID}_{ENTITY_MUTE}" + + @property + def icon(self) -> str: + """Return icon based on mute state.""" + return "mdi:volume-off" if self.is_on else "mdi:volume-high" + + @property + def is_on(self) -> bool: + """Return true when ALL speakers are muted.""" + coordinators = self._coordinators() + if not coordinators: + return False + return all(c.client.state.mute for c in coordinators) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Mute all speakers.""" + coordinators = self._coordinators() + await asyncio.gather(*(c.client.async_set_mute(True) for c in coordinators)) + for c in coordinators: + c.async_set_updated_data(c.client.state) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Unmute all speakers.""" + coordinators = self._coordinators() + await asyncio.gather(*(c.client.async_set_mute(False) for c in coordinators)) + for c in coordinators: + c.async_set_updated_data(c.client.state) + self.async_write_ha_state() + + +class AdamAudioGroupSleepSwitch(AdamAudioGroupEntity, SwitchEntity): + """Sleep switch that controls ALL speakers simultaneously.""" + + _attr_translation_key = "sleep" + _attr_unique_id = f"{DOMAIN}_{GROUP_DEVICE_ID}_{ENTITY_SLEEP}" + + @property + def icon(self) -> str: + """Return icon based on sleep state.""" + return "mdi:power-sleep" if self.is_on else "mdi:power" + + @property + def is_on(self) -> bool: + """Return true when ALL speakers are sleeping.""" + coordinators = self._coordinators() + if not coordinators: + return False + return all(c.client.state.sleep for c in coordinators) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Put all speakers to sleep.""" + coordinators = self._coordinators() + await asyncio.gather(*(c.client.async_set_sleep(True) for c in coordinators)) + for c in coordinators: + c.async_set_updated_data(c.client.state) + self.async_write_ha_state() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Wake all speakers.""" + coordinators = self._coordinators() + await asyncio.gather(*(c.client.async_set_sleep(False) for c in coordinators)) + for c in coordinators: + c.async_set_updated_data(c.client.state) + self.async_write_ha_state() diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a9abead1473dd9..09b1576db18831 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -29,6 +29,7 @@ "accuweather", "acmeda", "actron_air", + "adam_audio", "adax", "adguard", "advantage_air", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 79b8133084f49c..2251b1193be24a 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -53,6 +53,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "adam_audio": { + "name": "ADAM Audio", + "integration_type": "device", + "config_flow": true, + "iot_class": "local_polling" + }, "adax": { "name": "Adax", "integration_type": "hub", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 50bb4f31414eda..b1d53c3a654852 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -813,6 +813,12 @@ "domain": "nut", }, ], + "_oca._udp.local.": [ + { + "domain": "adam_audio", + "name": "aseries*", + }, + ], "_octoprint._tcp.local.": [ { "domain": "octoprint", diff --git a/requirements_all.txt b/requirements_all.txt index 028b32546d1e0e..e6470b09f5818b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1939,6 +1939,9 @@ py_ccm15==0.1.2 # homeassistant.components.html5 py_vapid==1.9.4 +# homeassistant.components.adam_audio +pyadamaudiocontroller==1.0.0 + # homeassistant.components.ads pyads==3.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42d8acebfda4c5..925f255bca733e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1679,6 +1679,9 @@ py_ccm15==0.1.2 # homeassistant.components.html5 py_vapid==1.9.4 +# homeassistant.components.adam_audio +pyadamaudiocontroller==1.0.0 + # homeassistant.components.hisense_aehw4a1 pyaehw4a1==0.3.9 diff --git a/tests/components/adam_audio/__init__.py b/tests/components/adam_audio/__init__.py new file mode 100644 index 00000000000000..3cf52e2dea81f8 --- /dev/null +++ b/tests/components/adam_audio/__init__.py @@ -0,0 +1 @@ +"""Tests for the ADAM Audio integration.""" diff --git a/tests/components/adam_audio/conftest.py b/tests/components/adam_audio/conftest.py new file mode 100644 index 00000000000000..fb5d0cb86b2d10 --- /dev/null +++ b/tests/components/adam_audio/conftest.py @@ -0,0 +1,110 @@ +"""Fixtures for ADAM Audio integration tests.""" + +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.adam_audio.client import AdamAudioState +from homeassistant.components.adam_audio.const import ( + CONF_DESCRIPTION, + CONF_DEVICE_NAME, + CONF_HOST, + CONF_PORT, + CONF_SERIAL, + DOMAIN, +) + +from tests.common import MockConfigEntry + +MOCK_HOST = "192.168.1.100" +MOCK_PORT = 49494 +MOCK_DEVICE_NAME = "ASeries-test01" +MOCK_DESCRIPTION = "Left Speaker" +MOCK_SERIAL = "SN-12345" + + +@pytest.fixture(autouse=True) +def clear_state_leakage() -> None: + """Clear global state to prevent leakage across tests. + + Note: With state moved to hass.data[DOMAIN], the fresh 'hass' fixture + provided by pytest-homeassistant-custom-component already handles + most resets. This fixture is kept for future-proofing. + """ + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry.""" + return MockConfigEntry( + version=1, + minor_version=1, + domain=DOMAIN, + title=MOCK_DESCRIPTION, + data={ + CONF_HOST: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_DEVICE_NAME: MOCK_DEVICE_NAME, + CONF_DESCRIPTION: MOCK_DESCRIPTION, + CONF_SERIAL: MOCK_SERIAL, + }, + source="user", + unique_id=MOCK_SERIAL, + options={}, + discovery_keys={}, + ) + + +@pytest.fixture +def mock_state() -> AdamAudioState: + """Create a default mock device state.""" + return AdamAudioState( + mute=False, + sleep=False, + input_source=1, + voicing=0, + bass=0, + desk=0, + presence=0, + treble=0, + ) + + +@pytest.fixture +def mock_client(mock_state: AdamAudioState) -> Generator[MagicMock]: + """Create a mock AdamAudioClient.""" + with ( + patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + autospec=True, + ) as mock_coord, + patch( + "homeassistant.components.adam_audio.config_flow.AdamAudioClient", + autospec=True, + ) as mock_flow, + ): + # Both mocks point to the same return value + client = mock_coord.return_value + mock_flow.return_value = client + client.host = MOCK_HOST + client.port = MOCK_PORT + client.available = True + client.device_name = MOCK_DEVICE_NAME + client.description = MOCK_DESCRIPTION + client.serial = MOCK_SERIAL + client.state = mock_state + client.async_setup = AsyncMock(return_value=True) + client.async_shutdown = AsyncMock() + client.async_fetch_state = AsyncMock(return_value=True) + client.async_set_mute = AsyncMock() + client.async_set_sleep = AsyncMock() + client.async_set_input = AsyncMock() + client.async_set_voicing = AsyncMock() + client.async_set_bass = AsyncMock() + client.async_set_desk = AsyncMock() + client.async_set_presence = AsyncMock() + client.async_set_treble = AsyncMock() + yield client diff --git a/tests/components/adam_audio/test_client.py b/tests/components/adam_audio/test_client.py new file mode 100644 index 00000000000000..90f74766ee7b4c --- /dev/null +++ b/tests/components/adam_audio/test_client.py @@ -0,0 +1,240 @@ +"""Tests for ADAM Audio client.""" + +import time +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.adam_audio.client import AdamAudioClient +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + + +@pytest.fixture +def adam_client(hass: HomeAssistant) -> AdamAudioClient: + """Fixture for client.""" + client = AdamAudioClient(hass, "192.168.1.100", 49494) + client._device = MagicMock() + return client + + +async def test_client_fetch_state(adam_client: AdamAudioClient) -> None: + """Test fetching all states from device.""" + # Mock the batched fetch call + pdus = [] + for val in (5, 1, 1, 2, 1, 0, -1, 0): + param = MagicMock() + param.value = val + pdu = MagicMock() + pdu.params = [param] + pdus.append(pdu) + + adam_client._device.get_full_state_pdus.return_value = pdus + + # Test + success = await adam_client.async_fetch_state() + assert success is True + + assert adam_client.state.mute is True + assert adam_client.state.sleep is True + assert adam_client.state.input_source == 1 + assert adam_client.state.voicing == 2 + assert adam_client.state.bass == 1 + assert adam_client.state.desk == 0 + assert adam_client.state.presence == -1 + assert adam_client.state.treble == 0 + + +async def test_client_fetch_state_failure(adam_client: AdamAudioClient) -> None: + """Test fetching state failures.""" + adam_client._device.get_full_state_pdus.side_effect = TimeoutError("timeout") + + success = await adam_client.async_fetch_state() + assert success is False + assert adam_client.available is False + + +async def test_client_setters(adam_client: AdamAudioClient) -> None: + """Test state setters.""" + # Call + adam_client._device.get_mute.return_value = True + await adam_client.async_set_mute(True) + adam_client._device.set_mute.assert_called_once_with(True) + assert adam_client.state.mute is True + + adam_client._device.get_sleep.return_value = False + await adam_client.async_set_sleep(False) + adam_client._device.set_sleep.assert_called_once_with(False) + assert adam_client.state.sleep is False + + adam_client._device.get_input.return_value = 1 + await adam_client.async_set_input(1) + adam_client._device.set_input.assert_called_once_with(1) + assert adam_client.state.input_source == 1 + + adam_client._device.get_voicing.return_value = 2 + await adam_client.async_set_voicing(2) + adam_client._device.set_voicing.assert_called_once_with(2) + assert adam_client.state.voicing == 2 + + adam_client._device.get_bass.return_value = 1 + await adam_client.async_set_bass(1) + adam_client._device.set_bass.assert_called_once_with(1) + assert adam_client.state.bass == 1 + + adam_client._device.get_desk.return_value = -1 + await adam_client.async_set_desk(-1) + adam_client._device.set_desk.assert_called_once_with(-1) + assert adam_client.state.desk == -1 + + adam_client._device.get_presence.return_value = 0 + await adam_client.async_set_presence(0) + adam_client._device.set_presence.assert_called_once_with(0) + assert adam_client.state.presence == 0 + + adam_client._device.get_treble.return_value = 1 + await adam_client.async_set_treble(1) + adam_client._device.set_treble.assert_called_once_with(1) + assert adam_client.state.treble == 1 + + +async def test_client_setup_shutdown(hass: HomeAssistant) -> None: + """Test client connection logic.""" + client = AdamAudioClient(hass, "192.168.1.100", 49494) + + with patch("homeassistant.components.adam_audio.client.Device") as mock_device_cls: + mock_dev = mock_device_cls.from_address.return_value + mock_dev.get_name.return_value = "A7V" + mock_dev.get_description.return_value = "Left" + + success = await client.async_setup() + + assert success is True + assert client.device_name == "A7V" + assert client.description == "Left" + + await client.async_shutdown() + mock_dev.close.assert_called_once() + + +async def test_client_setup_failure(hass: HomeAssistant) -> None: + """Test client connection logic on failure.""" + client = AdamAudioClient(hass, "192.168.1.100", 49494) + + with patch("homeassistant.components.adam_audio.client.Device") as mock_device_cls: + mock_device_cls.from_address.side_effect = OSError("Connection refused") + success = await client.async_setup() + assert success is False + assert client.available is False + + +async def test_client_fetch_state_short_responses(adam_client: AdamAudioClient) -> None: + """Test fetch state handles < 8 responses properly.""" + adam_client._device.get_full_state_pdus.return_value = [MagicMock()] + success = await adam_client.async_fetch_state() + assert success is False + assert adam_client.available is False + + +async def test_client_setters_max_retries_fail(adam_client: AdamAudioClient) -> None: + """Test setters raise exception after MAX_RETRIES.""" + adam_client._device.get_mute.return_value = ( + False # won't match True -> fails verification + ) + adam_client._device.set_mute.side_effect = OSError("Socket dead") + adam_client._device.set_mute.__name__ = "set_mute" + adam_client._device.get_mute.__name__ = "get_mute" + + with pytest.raises(HomeAssistantError): + await adam_client.async_set_mute(True) + assert adam_client._device.set_mute.call_count == 3 + + +async def test_client_fetch_state_critical_exception( + adam_client: AdamAudioClient, +) -> None: + """Test fetch_state handles unexpected (non-timeout) exceptions.""" + adam_client._device.drain.side_effect = RuntimeError("unexpected crash") + success = await adam_client.async_fetch_state() + assert success is False + assert adam_client.available is False + + +async def test_client_fetch_state_no_device(hass: HomeAssistant) -> None: + """Test fetch_state returns False when _device is None.""" + client = AdamAudioClient(hass, "192.168.1.100", 49494) + success = await client.async_fetch_state() + assert success is False + + +async def test_client_keepalive_oserror_during_fetch( + adam_client: AdamAudioClient, +) -> None: + """Test fetch_state recovers when opportunistic keepalive raises OSError.""" + adam_client._last_keepalive = 0.0 + adam_client._device.send_keepalive.side_effect = OSError("timeout") + + pdus = [] + for val in (1, 0, 1, 0, 0, 0, 0, 0): + param = MagicMock() + param.value = val + pdu = MagicMock() + pdu.params = [param] + pdus.append(pdu) + adam_client._device.get_full_state_pdus.return_value = pdus + + success = await adam_client.async_fetch_state() + assert success is True + + +async def test_client_ensure_keepalive_oserror(adam_client: AdamAudioClient) -> None: + """Test _ensure_keepalive silently handles OSError when session is stale.""" + adam_client._last_keepalive = 0.0 + adam_client._device.send_keepalive.side_effect = OSError("timeout") + adam_client._device.get_mute.return_value = True + + await adam_client.async_set_mute(True) + adam_client._device.set_mute.assert_called_with(True) + + +async def test_client_verify_mismatch_retries(adam_client: AdamAudioClient) -> None: + """Test retry exhaustion when verification never matches.""" + adam_client.RETRY_DELAY = 0 + adam_client._device.set_mute.__name__ = "set_mute" + adam_client._device.get_mute.return_value = False # Never matches True + + await adam_client.async_set_mute(True) + assert adam_client._device.set_mute.call_count == 3 + assert adam_client._device.get_mute.call_count == 3 + + +async def test_client_verify_read_failure(adam_client: AdamAudioClient) -> None: + """Test retry when verification read raises an exception.""" + adam_client.RETRY_DELAY = 0 + adam_client._device.set_mute.__name__ = "set_mute" + adam_client._device.get_mute.side_effect = RuntimeError("read failed") + + await adam_client.async_set_mute(True) + assert adam_client._device.set_mute.call_count == 3 + + +async def test_client_async_send_oserror(adam_client: AdamAudioClient) -> None: + """Test _async_send raises HomeAssistantError on OSError.""" + adam_client._last_keepalive = time.monotonic() + fn = MagicMock(side_effect=OSError("dead")) + fn.__name__ = "test_fn" + + with pytest.raises(HomeAssistantError, match="dead"): + await adam_client._async_send(fn) + assert adam_client.available is False + + +async def test_client_async_send_success(adam_client: AdamAudioClient) -> None: + """Test _async_send succeeds and marks client as available.""" + adam_client._last_keepalive = time.monotonic() + fn = MagicMock() + fn.__name__ = "test_fn" + + await adam_client._async_send(fn) + assert adam_client.available is True + fn.assert_called_once() diff --git a/tests/components/adam_audio/test_components.py b/tests/components/adam_audio/test_components.py new file mode 100644 index 00000000000000..15e89da8da388b --- /dev/null +++ b/tests/components/adam_audio/test_components.py @@ -0,0 +1,400 @@ +"""Tests for ADAM Audio platform components (switch, number, select).""" + +from __future__ import annotations + +from dataclasses import replace +from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch + +import pytest + +from homeassistant.components.adam_audio.const import DOMAIN +from homeassistant.components.adam_audio.data import AdamAudioIntegrationData +from homeassistant.components.adam_audio.entity import ( + AdamAudioEntity, + AdamAudioGroupEntity, +) +from homeassistant.components.adam_audio.number import ( + _NUMBER_DESCRIPTORS, + AdamAudioGroupNumber, + AdamAudioNumber, +) +from homeassistant.components.adam_audio.select import ( + AdamAudioGroupInputSelect, + AdamAudioGroupVoicingSelect, +) +from homeassistant.components.adam_audio.switch import ( + AdamAudioGroupMuteSwitch, + AdamAudioGroupSleepSwitch, +) +from homeassistant.components.number import ATTR_VALUE, SERVICE_SET_VALUE +from homeassistant.components.select import ATTR_OPTION, SERVICE_SELECT_OPTION +from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_switch_entities( + hass: HomeAssistant, + mock_config_entry, + mock_client: MagicMock, +) -> None: + """Test switch entities.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Mute switch + mute_entity = "switch.left_speaker_mute" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: mute_entity}, + blocking=True, + ) + mock_client.async_set_mute.assert_called_once_with(True) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: mute_entity}, + blocking=True, + ) + mock_client.async_set_mute.assert_called_with(False) + + # Standby switch + standby_entity = "switch.left_speaker_sleep" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: standby_entity}, + blocking=True, + ) + mock_client.async_set_sleep.assert_called_once_with(True) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: standby_entity}, + blocking=True, + ) + mock_client.async_set_sleep.assert_called_with(False) + + +async def test_number_entities( + hass: HomeAssistant, + mock_config_entry, + mock_client: MagicMock, +) -> None: + """Test number entities.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + bass_entity = "number.left_speaker_bass" + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: bass_entity, ATTR_VALUE: 1.0}, + blocking=True, + ) + mock_client.async_set_bass.assert_called_once_with(1) + + desk_entity = "number.left_speaker_desk" + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: desk_entity, ATTR_VALUE: -1.0}, + blocking=True, + ) + mock_client.async_set_desk.assert_called_once_with(-1) + + presence_entity = "number.left_speaker_presence" + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: presence_entity, ATTR_VALUE: 1.0}, + blocking=True, + ) + mock_client.async_set_presence.assert_called_once_with(1) + + treble_entity = "number.left_speaker_treble" + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: treble_entity, ATTR_VALUE: 0.0}, + blocking=True, + ) + mock_client.async_set_treble.assert_called_once_with(0) + + +async def test_select_entities( + hass: HomeAssistant, + mock_config_entry, + mock_client: MagicMock, +) -> None: + """Test select entities.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + voicing_entity = "select.left_speaker_voicing" + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: voicing_entity, ATTR_OPTION: "Pure"}, + blocking=True, + ) + mock_client.async_set_voicing.assert_called_once_with(0) + + input_entity = "select.left_speaker_input_source" + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: input_entity, ATTR_OPTION: "RCA"}, + blocking=True, + ) + mock_client.async_set_input.assert_called_once_with(0) + + +@pytest.mark.usefixtures("mock_config_entry", "mock_client") +async def test_group_switch_entities( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock +) -> None: + """Test group switch entities control all speakers.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mute_entity = "switch.all_speakers_mute" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: mute_entity}, + blocking=True, + ) + mock_client.async_set_mute.assert_called_with(True) + + sleep_entity = "switch.all_speakers_sleep" + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: sleep_entity}, + blocking=True, + ) + mock_client.async_set_sleep.assert_called_with(True) + + +@pytest.mark.usefixtures("mock_config_entry", "mock_client") +async def test_group_number_entities( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock +) -> None: + """Test group number entities.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + bass_entity = "number.all_speakers_bass" + await hass.services.async_call( + "number", + SERVICE_SET_VALUE, + {ATTR_ENTITY_ID: bass_entity, ATTR_VALUE: 1.0}, + blocking=True, + ) + mock_client.async_set_bass.assert_called_with(1) + + +@pytest.mark.usefixtures("mock_config_entry", "mock_client") +async def test_group_select_entities( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock +) -> None: + """Test group select entities.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + voicing_entity = "select.all_speakers_voicing" + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: voicing_entity, ATTR_OPTION: "Pure"}, + blocking=True, + ) + mock_client.async_set_voicing.assert_called_with(0) + + +@pytest.mark.usefixtures("mock_config_entry", "mock_client") +async def test_group_switch_turn_off( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock +) -> None: + """Test group switch turn_off controls all speakers.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.all_speakers_mute"}, + blocking=True, + ) + mock_client.async_set_mute.assert_called_with(False) + + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: "switch.all_speakers_sleep"}, + blocking=True, + ) + mock_client.async_set_sleep.assert_called_with(False) + + +@pytest.mark.usefixtures("mock_config_entry", "mock_client") +async def test_group_select_input( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock +) -> None: + """Test group input select controls all speakers.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await hass.services.async_call( + "select", + SERVICE_SELECT_OPTION, + {ATTR_ENTITY_ID: "select.all_speakers_input_source", ATTR_OPTION: "RCA"}, + blocking=True, + ) + mock_client.async_set_input.assert_called_with(0) + + +async def test_group_switch_no_coordinators(hass: HomeAssistant) -> None: + """Test group switch returns False when no coordinators are loaded.""" + assert AdamAudioGroupMuteSwitch(hass).is_on is False + assert AdamAudioGroupSleepSwitch(hass).is_on is False + + +async def test_group_select_no_coordinators(hass: HomeAssistant) -> None: + """Test group selects return defaults when no coordinators are loaded.""" + assert AdamAudioGroupInputSelect(hass).current_option == "RCA" + assert AdamAudioGroupVoicingSelect(hass).current_option == "Pure" + + +async def test_group_number_no_coordinators(hass: HomeAssistant) -> None: + """Test group number returns min value when no coordinators are loaded.""" + num = AdamAudioGroupNumber(hass, _NUMBER_DESCRIPTORS[0]) + assert num.native_value == _NUMBER_DESCRIPTORS[0].native_min + + +def test_number_unavailable_parent() -> None: + """Test per-device number returns unavailable when parent is unavailable.""" + entity = object.__new__(AdamAudioNumber) + entity._desc = _NUMBER_DESCRIPTORS[0] + + with patch.object( + AdamAudioEntity, "available", new_callable=PropertyMock, return_value=False + ): + assert entity.available is False + + +def test_number_available_null_voicings() -> None: + """Test per-device number available when valid_voicings is None.""" + entity = object.__new__(AdamAudioNumber) + entity._desc = replace(_NUMBER_DESCRIPTORS[0], valid_voicings=None) + + with patch.object( + AdamAudioEntity, "available", new_callable=PropertyMock, return_value=True + ): + assert entity.available is True + + +def test_group_number_unavailable_parent() -> None: + """Test group number returns unavailable when parent is unavailable.""" + entity = object.__new__(AdamAudioGroupNumber) + entity._desc = _NUMBER_DESCRIPTORS[0] + + with patch.object( + AdamAudioGroupEntity, "available", new_callable=PropertyMock, return_value=False + ): + assert entity.available is False + + +def test_group_number_available_null_voicings() -> None: + """Test group number available when valid_voicings is None.""" + entity = object.__new__(AdamAudioGroupNumber) + entity._desc = replace(_NUMBER_DESCRIPTORS[0], valid_voicings=None) + + with patch.object( + AdamAudioGroupEntity, "available", new_callable=PropertyMock, return_value=True + ): + assert entity.available is True + + +@pytest.mark.usefixtures("mock_config_entry", "mock_client") +async def test_group_resubscribes_new_coordinator( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_client: MagicMock +) -> None: + """Test group entities re-subscribe when a new coordinator appears.""" + mock_config_entry.add_to_hass(hass) + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + # Inject a second coordinator + mock_coord2 = MagicMock() + mock_coord2.client.state = mock_client.state + mock_coord2.client.available = True + mock_coord2.client.async_set_mute = AsyncMock() + mock_coord2.async_add_listener = MagicMock(return_value=lambda: None) + integration_data: AdamAudioIntegrationData = hass.data[DOMAIN] + integration_data.coordinators["fake_entry_2"] = mock_coord2 + + # Group turn_on triggers async_write_ha_state -> detects count mismatch -> re-subscribes + await hass.services.async_call( + SWITCH_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "switch.all_speakers_mute"}, + blocking=True, + ) + mock_client.async_set_mute.assert_called_with(True) + mock_coord2.client.async_set_mute.assert_called_with(True) + + del integration_data.coordinators["fake_entry_2"] diff --git a/tests/components/adam_audio/test_config_flow.py b/tests/components/adam_audio/test_config_flow.py new file mode 100644 index 00000000000000..b57e9f498e4d14 --- /dev/null +++ b/tests/components/adam_audio/test_config_flow.py @@ -0,0 +1,168 @@ +"""Tests for ADAM Audio config flow.""" + +from __future__ import annotations + +from ipaddress import IPv4Address +from unittest.mock import MagicMock + +from homeassistant import config_entries +from homeassistant.components.adam_audio.const import ( + CONF_DESCRIPTION, + CONF_DEVICE_NAME, + CONF_HOST, + CONF_PORT, + CONF_SERIAL, + DOMAIN, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .conftest import ( + MOCK_DESCRIPTION, + MOCK_DEVICE_NAME, + MOCK_HOST, + MOCK_PORT, + MOCK_SERIAL, +) + + +async def test_user_flow_success(hass: HomeAssistant, mock_client: MagicMock) -> None: + """Test the manual user flow with a successful connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DESCRIPTION + assert result["data"][CONF_HOST] == MOCK_HOST + + +async def test_user_flow_connection_error( + hass: HomeAssistant, mock_client: MagicMock +) -> None: + """Test the manual user flow when connection fails.""" + mock_client.async_setup.return_value = False + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} + + +async def test_zeroconf_flow_empty_hostname( + hass: HomeAssistant, mock_client: MagicMock +) -> None: + """Test zeroconf when hostname is empty (falls back to IP-based device_id).""" + discovery_info = ZeroconfServiceInfo( + ip_address=IPv4Address(MOCK_HOST), + ip_addresses=[IPv4Address(MOCK_HOST)], + port=MOCK_PORT, + hostname="", + type="_oca._udp.local.", + name="._oca._udp.local.", + properties={}, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["data"][CONF_HOST] == MOCK_HOST + + +async def test_zeroconf_flow_success( + hass: HomeAssistant, mock_client: MagicMock +) -> None: + """Test zeroconf discovery with a successful connection.""" + discovery_info = ZeroconfServiceInfo( + ip_address=IPv4Address(MOCK_HOST), + ip_addresses=[IPv4Address(MOCK_HOST)], + port=MOCK_PORT, + hostname="ASeries-41472b.local.", + type="_oca._udp.local.", + name="ASeries-41472b._oca._udp.local.", + properties={}, + ) + + # Step 1: zeroconf triggers discovery → shows confirm form + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + # Step 2: user confirms → entry created + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == MOCK_DESCRIPTION + assert result["data"][CONF_HOST] == MOCK_HOST + assert result["data"][CONF_PORT] == MOCK_PORT + assert result["data"][CONF_DEVICE_NAME] == MOCK_DEVICE_NAME + assert result["data"][CONF_SERIAL] == MOCK_SERIAL + + +async def test_zeroconf_flow_connection_failure( + hass: HomeAssistant, mock_client: MagicMock +) -> None: + """Test zeroconf discovery when connection fails (uses fallback metadata).""" + discovery_info = ZeroconfServiceInfo( + ip_address=IPv4Address(MOCK_HOST), + ip_addresses=[IPv4Address(MOCK_HOST)], + port=MOCK_PORT, + hostname="ASeries-41472b.local.", + type="_oca._udp.local.", + name="ASeries-41472b._oca._udp.local.", + properties={}, + ) + + mock_client.async_setup.return_value = False + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "zeroconf_confirm" + + # Confirm with fallback data + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.CREATE_ENTRY + # Falls back to device_id derived from hostname + assert result["data"][CONF_DEVICE_NAME] == "ASeries-41472b" + assert result["data"][CONF_DESCRIPTION] == "ASeries-41472b" + + +async def test_user_flow_exception(hass: HomeAssistant, mock_client: MagicMock) -> None: + """Test _async_try_connect returns None when an unexpected exception occurs.""" + mock_client.async_setup.side_effect = RuntimeError("boom") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT}, + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "cannot_connect"} diff --git a/tests/components/adam_audio/test_init.py b/tests/components/adam_audio/test_init.py new file mode 100644 index 00000000000000..1c3f8d83cbc88b --- /dev/null +++ b/tests/components/adam_audio/test_init.py @@ -0,0 +1,104 @@ +"""Tests for ADAM Audio integration __init__.py.""" + +from __future__ import annotations + +from unittest.mock import AsyncMock, MagicMock, patch + +from homeassistant.components.adam_audio import _async_reload_entry +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + + +async def test_setup_entry( + hass: HomeAssistant, + mock_config_entry, + mock_client: MagicMock, +) -> None: + """Test successful setup of a config entry.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + assert mock_config_entry.runtime_data is not None + assert mock_config_entry.runtime_data.coordinator is not None + + +async def test_setup_entry_connection_failure( + hass: HomeAssistant, + mock_config_entry, + mock_client: MagicMock, +) -> None: + """Test setup failure when device is unreachable.""" + mock_client.async_setup = AsyncMock(return_value=False) + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry, + mock_client: MagicMock, +) -> None: + """Test successful unload of a config entry.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_async_reload_entry(hass: HomeAssistant, mock_config_entry) -> None: + """Test _async_reload_entry triggers config entry reload.""" + mock_config_entry.add_to_hass(hass) + + with patch.object( + hass.config_entries, "async_reload", new_callable=AsyncMock + ) as mock_reload: + await _async_reload_entry(hass, mock_config_entry) + mock_reload.assert_called_once_with(mock_config_entry.entry_id) + + +async def test_coordinator_update_failure( + hass: HomeAssistant, mock_config_entry, mock_client: MagicMock +) -> None: + """Test coordinator raises UpdateFailed when fetch fails.""" + mock_config_entry.add_to_hass(hass) + + with patch( + "homeassistant.components.adam_audio.coordinator.AdamAudioClient", + return_value=mock_client, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + mock_client.async_fetch_state = AsyncMock(return_value=False) + + coordinator = mock_config_entry.runtime_data.coordinator + await coordinator.async_refresh() + await hass.async_block_till_done() + + assert coordinator.last_update_success is False