diff --git a/CODEOWNERS b/CODEOWNERS index fdf3d7fb81d3..521047e633c4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1063,6 +1063,8 @@ CLAUDE.md @home-assistant/core /tests/components/madvr/ @iloveicedgreentea /homeassistant/components/marantz_infrared/ @balloob /tests/components/marantz_infrared/ @balloob +/homeassistant/components/marantz_rs232/ @balloob +/tests/components/marantz_rs232/ @balloob /homeassistant/components/mastodon/ @fabaff @andrew-codechimp /tests/components/mastodon/ @fabaff @andrew-codechimp /homeassistant/components/matrix/ @PaarthShah diff --git a/homeassistant/components/marantz_rs232/__init__.py b/homeassistant/components/marantz_rs232/__init__.py new file mode 100644 index 000000000000..b7005e5bccb3 --- /dev/null +++ b/homeassistant/components/marantz_rs232/__init__.py @@ -0,0 +1,75 @@ +"""The Marantz RS-232 integration.""" + +from marantz_rs232 import ( + MarantzV2003Receiver, + MarantzV2007Receiver, + MarantzV2015Receiver, + V2003ReceiverState, + V2007ReceiverState, + V2015ReceiverState, +) + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_DEVICE, CONF_MODEL, Platform +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import ConfigEntryNotReady + +from .config_flow import MODEL_MODERN, V2003_MODELS, V2007_MODELS +from .const import LOGGER, MarantzReceiver, MarantzRS232ConfigEntry + +PLATFORMS = [Platform.MEDIA_PLAYER] + + +async def async_setup_entry( + hass: HomeAssistant, entry: MarantzRS232ConfigEntry +) -> bool: + """Set up Marantz RS-232 from a config entry.""" + port = entry.data[CONF_DEVICE] + model_key = entry.data[CONF_MODEL] + + receiver: MarantzReceiver + if model_key == MODEL_MODERN: + receiver = MarantzV2015Receiver(port) + elif model_key in V2003_MODELS: + receiver = MarantzV2003Receiver(port) + else: + receiver = MarantzV2007Receiver(port, model=V2007_MODELS[model_key]) + + try: + await receiver.connect() + await receiver.query_state() + except (ConnectionError, OSError, TimeoutError) as err: + LOGGER.error("Error connecting to Marantz receiver at %s: %s", port, err) + # disconnect() is idempotent across all protocol receivers. + await receiver.disconnect() + raise ConfigEntryNotReady from err + + entry.runtime_data = receiver + + @callback + def _on_disconnect( + state: V2015ReceiverState | V2007ReceiverState | V2003ReceiverState | None, + ) -> None: + # Only reload if the entry is still loaded. During entry removal, + # disconnect() fires this callback but the entry is already gone. + if state is None and entry.state is ConfigEntryState.LOADED: + LOGGER.warning("Marantz receiver disconnected, reloading config entry") + hass.config_entries.async_schedule_reload(entry.entry_id) + + entry.async_on_unload(receiver.subscribe(_on_disconnect)) + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, entry: MarantzRS232ConfigEntry +) -> bool: + """Unload a config entry.""" + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + if unload_ok: + await entry.runtime_data.disconnect() + + return unload_ok diff --git a/homeassistant/components/marantz_rs232/config_flow.py b/homeassistant/components/marantz_rs232/config_flow.py new file mode 100644 index 000000000000..ddf0cf92b71f --- /dev/null +++ b/homeassistant/components/marantz_rs232/config_flow.py @@ -0,0 +1,122 @@ +"""Config flow for the Marantz RS-232 integration.""" + +from typing import Any + +from marantz_rs232 import ( + MarantzV2003Receiver, + MarantzV2007Receiver, + MarantzV2015Receiver, + V2007Model, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_DEVICE, CONF_MODEL +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, + SerialPortSelector, +) + +from .const import DOMAIN, LOGGER + +MODEL_MODERN = "modern" +MODEL_SR7002 = "sr7002" +MODEL_SR8002 = "sr8002" +MODEL_SR9300 = "sr9300" +MODEL_SR8300 = "sr8300" + +MODEL_NAMES: dict[str, str] = { + MODEL_MODERN: "Modern", + MODEL_SR7002: "SR7002", + MODEL_SR8002: "SR8002", + MODEL_SR9300: "SR9300", + MODEL_SR8300: "SR8300", +} + +V2007_MODELS: dict[str, V2007Model] = { + MODEL_SR7002: V2007Model.SR7002, + MODEL_SR8002: V2007Model.SR8002, +} + +V2003_MODELS = frozenset({MODEL_SR9300, MODEL_SR8300}) + + +async def _async_attempt_connect(port: str, model_key: str) -> str | None: + """Attempt to connect to the receiver at the given port. + + Returns None on success, error on failure. + """ + receiver: MarantzV2015Receiver | MarantzV2007Receiver | MarantzV2003Receiver + if model_key == MODEL_MODERN: + receiver = MarantzV2015Receiver(port) + elif model_key in V2003_MODELS: + receiver = MarantzV2003Receiver(port) + else: + receiver = MarantzV2007Receiver(port, model=V2007_MODELS[model_key]) + + try: + await receiver.connect() + except ( + # When the port contains invalid connection data + ValueError, + # If it is a remote port, and we cannot connect + ConnectionError, + OSError, + TimeoutError, + ): + return "cannot_connect" + except Exception: # noqa: BLE001 + LOGGER.exception("Unexpected exception") + return "unknown" + else: + await receiver.disconnect() + return None + + +class MarantzRS232ConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Marantz RS-232.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + model_key = user_input[CONF_MODEL] + + self._async_abort_entries_match({CONF_DEVICE: user_input[CONF_DEVICE]}) + error = await _async_attempt_connect(user_input[CONF_DEVICE], model_key) + if not error: + return self.async_create_entry( + title=MODEL_NAMES[model_key], + data={ + CONF_DEVICE: user_input[CONF_DEVICE], + CONF_MODEL: model_key, + }, + ) + errors["base"] = error + + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_MODEL, default=MODEL_MODERN): SelectSelector( + SelectSelectorConfig( + options=list(MODEL_NAMES), + mode=SelectSelectorMode.DROPDOWN, + translation_key="model", + ) + ), + vol.Required(CONF_DEVICE): SerialPortSelector(), + } + ), + user_input or {}, + ), + errors=errors, + ) diff --git a/homeassistant/components/marantz_rs232/const.py b/homeassistant/components/marantz_rs232/const.py new file mode 100644 index 000000000000..6f62441b7413 --- /dev/null +++ b/homeassistant/components/marantz_rs232/const.py @@ -0,0 +1,19 @@ +"""Constants for the Marantz RS-232 integration.""" + +import logging + +from marantz_rs232 import ( + MarantzV2003Receiver, + MarantzV2007Receiver, + MarantzV2015Receiver, +) + +from homeassistant.config_entries import ConfigEntry + +LOGGER = logging.getLogger(__package__) +DOMAIN = "marantz_rs232" + +type MarantzReceiver = ( + MarantzV2015Receiver | MarantzV2007Receiver | MarantzV2003Receiver +) +type MarantzRS232ConfigEntry = ConfigEntry[MarantzReceiver] diff --git a/homeassistant/components/marantz_rs232/manifest.json b/homeassistant/components/marantz_rs232/manifest.json new file mode 100644 index 000000000000..4ee8e5e0bc80 --- /dev/null +++ b/homeassistant/components/marantz_rs232/manifest.json @@ -0,0 +1,13 @@ +{ + "domain": "marantz_rs232", + "name": "Marantz RS-232", + "codeowners": ["@balloob"], + "config_flow": true, + "dependencies": ["usb"], + "documentation": "https://www.home-assistant.io/integrations/marantz_rs232", + "integration_type": "hub", + "iot_class": "local_push", + "loggers": ["marantz_rs232"], + "quality_scale": "bronze", + "requirements": ["marantz-rs232==1.0.0"] +} diff --git a/homeassistant/components/marantz_rs232/media_player.py b/homeassistant/components/marantz_rs232/media_player.py new file mode 100644 index 000000000000..3336f7595c22 --- /dev/null +++ b/homeassistant/components/marantz_rs232/media_player.py @@ -0,0 +1,585 @@ +"""Media player platform for the Marantz RS-232 integration.""" + +import math +from typing import cast + +from marantz_rs232 import ( + V2015_MIN_VOLUME_DB, + V2015_VOLUME_DB_RANGE, + MarantzV2003Receiver, + MarantzV2007Receiver, + MarantzV2015Receiver, + V2003MainPlayer, + V2003MultiRoomPlayer, + V2003ReceiverState, + V2003Source, + V2007MainPlayer, + V2007MultiRoomPlayer, + V2007ReceiverState, + V2007Source, + V2015InputSource, + V2015MainPlayer, + V2015ReceiverState, + V2015ZonePlayer, +) + +from homeassistant.components.media_player import ( + MediaPlayerDeviceClass, + MediaPlayerEntity, + MediaPlayerEntityFeature, + MediaPlayerState, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .config_flow import MODEL_NAMES +from .const import DOMAIN, MarantzRS232ConfigEntry + +V2003_MIN_VOLUME_DB = -90.0 +V2003_VOLUME_DB_RANGE = 189.0 # -90..+99 + +INPUT_SOURCE_V2015_TO_HA: dict[V2015InputSource, str] = { + V2015InputSource.PHONO: "phono", + V2015InputSource.CD: "cd", + V2015InputSource.TUNER: "tuner", + V2015InputSource.DVD: "dvd", + V2015InputSource.BD: "bd", + V2015InputSource.TV: "tv", + V2015InputSource.SAT_CBL: "sat_cbl", + V2015InputSource.SAT: "sat", + V2015InputSource.MPLAY: "mplay", + V2015InputSource.VCR: "vcr", + V2015InputSource.GAME: "game", + V2015InputSource.V_AUX: "v_aux", + V2015InputSource.HDRADIO: "hdradio", + V2015InputSource.SIRIUS: "sirius", + V2015InputSource.SPOTIFY: "spotify", + V2015InputSource.SIRIUSXM: "siriusxm", + V2015InputSource.RHAPSODY: "rhapsody", + V2015InputSource.PANDORA: "pandora", + V2015InputSource.NAPSTER: "napster", + V2015InputSource.LASTFM: "lastfm", + V2015InputSource.FLICKR: "flickr", + V2015InputSource.IRADIO: "iradio", + V2015InputSource.SERVER: "server", + V2015InputSource.FAVORITES: "favorites", + V2015InputSource.CDR: "cdr", + V2015InputSource.AUX1: "aux1", + V2015InputSource.AUX2: "aux2", + V2015InputSource.AUX3: "aux3", + V2015InputSource.AUX4: "aux4", + V2015InputSource.AUX5: "aux5", + V2015InputSource.AUX6: "aux6", + V2015InputSource.AUX7: "aux7", + V2015InputSource.NET: "net", + V2015InputSource.NET_USB: "net_usb", + V2015InputSource.BT: "bt", + V2015InputSource.M_XPORT: "m_xport", + V2015InputSource.USB_IPOD: "usb_ipod", +} + +INPUT_SOURCE_V2007_TO_HA: dict[V2007Source, str] = { + V2007Source.TV: "tv", + V2007Source.DVD: "dvd", + V2007Source.VCR1: "vcr1", + V2007Source.DSS_VCR2: "dss_vcr2", + V2007Source.AUX1: "aux1", + V2007Source.AUX2: "aux2", + V2007Source.CD_CDR: "cd_cdr", + V2007Source.TAPE: "tape", + V2007Source.TUNER1: "tuner", + V2007Source.FM1: "fm", + V2007Source.AM1: "am", + V2007Source.XM1: "xm", +} + +INPUT_SOURCE_V2003_TO_HA: dict[V2003Source, str] = { + V2003Source.DSS: "dss", + V2003Source.TV: "tv", + V2003Source.LD: "ld", + V2003Source.DVD: "dvd", + V2003Source.VCR1: "vcr1", + V2003Source.VCR2_DVDR: "vcr2_dvdr", + V2003Source.AUX1: "aux1", + V2003Source.AUX2: "aux2", + V2003Source.DVDR: "dvdr", + V2003Source.CD: "cd", + V2003Source.TAPE: "tape", + V2003Source.CDR: "cdr", + V2003Source.FM: "fm", + V2003Source.AM: "am", + V2003Source.MW: "mw", + V2003Source.LW: "lw", + V2003Source.TUNER: "tuner", +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: MarantzRS232ConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Marantz RS-232 media player.""" + receiver = config_entry.runtime_data + + entities: list[MediaPlayerEntity] + if isinstance(receiver, MarantzV2015Receiver): + entities = [ + MarantzV2015MediaPlayer(receiver, receiver.main, config_entry, "main") + ] + if receiver.zone_2.power is not None: + entities.append( + MarantzV2015MediaPlayer( + receiver, receiver.zone_2, config_entry, "zone_2" + ) + ) + if receiver.zone_3.power is not None: + entities.append( + MarantzV2015MediaPlayer( + receiver, receiver.zone_3, config_entry, "zone_3" + ) + ) + elif isinstance(receiver, MarantzV2003Receiver): + entities = [ + MarantzV2003MediaPlayer(receiver, receiver.main, config_entry, "main") + ] + if receiver.multi_room.enabled is not None: + entities.append( + MarantzV2003MediaPlayer( + receiver, receiver.multi_room, config_entry, "multi_room" + ) + ) + else: + entities = [ + MarantzV2007MediaPlayer(receiver, receiver.main, config_entry, "main") + ] + if receiver.multi_room_a.power is not None: + entities.append( + MarantzV2007MediaPlayer( + receiver, receiver.multi_room_a, config_entry, "multi_room_a" + ) + ) + + async_add_entities(entities) + + +class MarantzV2015MediaPlayer(MediaPlayerEntity): + """Representation of a modern Marantz receiver controlled over RS-232.""" + + _attr_device_class = MediaPlayerDeviceClass.RECEIVER + _attr_has_entity_name = True + _attr_translation_key = "receiver" + _attr_should_poll = False + + _volume_min = V2015_MIN_VOLUME_DB + _volume_range = V2015_VOLUME_DB_RANGE + + def __init__( + self, + receiver: MarantzV2015Receiver, + player: V2015MainPlayer | V2015ZonePlayer, + config_entry: MarantzRS232ConfigEntry, + zone: str, + ) -> None: + """Initialize the media player.""" + self._receiver = receiver + self._player = player + self._is_main = zone == "main" + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="Marantz", + model_id=MODEL_NAMES.get(config_entry.data["model"]), + ) + self._attr_unique_id = f"{config_entry.entry_id}_{zone}" + + self._attr_source_list = sorted(INPUT_SOURCE_V2015_TO_HA.values()) + self._attr_supported_features = ( + MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.SELECT_SOURCE + ) + + if zone == "main": + self._attr_name = None + self._attr_supported_features |= MediaPlayerEntityFeature.VOLUME_MUTE + elif zone == "zone_2": + self._attr_name = "Zone 2" + else: + self._attr_name = "Zone 3" + + self._async_update_from_player() + + async def async_added_to_hass(self) -> None: + """Subscribe to receiver state updates.""" + self.async_on_remove(self._receiver.subscribe(self._async_on_state_update)) + + @callback + def _async_on_state_update(self, state: V2015ReceiverState | None) -> None: + if state is None: + self._attr_available = False + else: + self._attr_available = True + self._async_update_from_player() + self.async_write_ha_state() + + @callback + def _async_update_from_player(self) -> None: + if self._player.power is None: + self._attr_state = None + else: + self._attr_state = ( + MediaPlayerState.ON if self._player.power else MediaPlayerState.OFF + ) + + source = self._player.input_source + self._attr_source = INPUT_SOURCE_V2015_TO_HA.get(source) if source else None + + volume_min = self._player.volume_min + volume_max = self._player.volume_max + if volume_min is not None: + self._volume_min = volume_min + if volume_max is not None and volume_max > volume_min: + self._volume_range = volume_max - volume_min + + volume = self._player.volume + if volume is not None: + self._attr_volume_level = (volume - self._volume_min) / self._volume_range + else: + self._attr_volume_level = None + + if self._is_main: + self._attr_is_volume_muted = cast(V2015MainPlayer, self._player).mute + + async def async_turn_on(self) -> None: + """Turn the receiver on.""" + await self._player.power_on() + + async def async_turn_off(self) -> None: + """Turn the receiver off.""" + await self._player.power_standby() + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + db = volume * self._volume_range + self._volume_min + await self._player.set_volume(db) + + async def async_volume_up(self) -> None: + """Volume up.""" + await self._player.volume_up() + + async def async_volume_down(self) -> None: + """Volume down.""" + await self._player.volume_down() + + async def async_mute_volume(self, mute: bool) -> None: + """Mute or unmute.""" + player = cast(V2015MainPlayer, self._player) + if mute: + await player.mute_on() + else: + await player.mute_off() + + async def async_select_source(self, source: str) -> None: + """Select input source.""" + input_source = next( + ( + input_source + for input_source, ha_source in INPUT_SOURCE_V2015_TO_HA.items() + if ha_source == source + ), + None, + ) + if input_source is None: + raise HomeAssistantError("Invalid source") + + await self._player.select_input_source(input_source) + + +class MarantzV2007MediaPlayer(MediaPlayerEntity): + """Representation of a 2007-era Marantz receiver controlled over RS-232.""" + + _attr_device_class = MediaPlayerDeviceClass.RECEIVER + _attr_has_entity_name = True + _attr_translation_key = "receiver" + _attr_should_poll = False + + _volume_min = V2015_MIN_VOLUME_DB + _volume_range = V2015_VOLUME_DB_RANGE + + def __init__( + self, + receiver: MarantzV2007Receiver, + player: V2007MainPlayer | V2007MultiRoomPlayer, + config_entry: MarantzRS232ConfigEntry, + zone: str, + ) -> None: + """Initialize the v2007 media player.""" + self._receiver = receiver + self._player = player + + if isinstance(player, V2007MainPlayer): + self._set_volume = player.set_volume + self._volume_up = player.volume_up + self._volume_down = player.volume_down + else: + self._set_volume = player.set_line_volume + self._volume_up = player.line_volume_up + self._volume_down = player.line_volume_down + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="Marantz", + model_id=MODEL_NAMES.get(config_entry.data["model"]), + ) + self._attr_unique_id = f"{config_entry.entry_id}_{zone}" + + self._attr_source_list = sorted(INPUT_SOURCE_V2007_TO_HA.values()) + self._attr_supported_features = ( + MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.SELECT_SOURCE + ) + + if zone == "main": + self._attr_name = None + else: + self._attr_name = "Multi Room" + + self._async_update_from_player() + + async def async_added_to_hass(self) -> None: + """Subscribe to receiver state updates.""" + self.async_on_remove(self._receiver.subscribe(self._async_on_state_update)) + + @callback + def _async_on_state_update(self, state: V2007ReceiverState | None) -> None: + if state is None: + self._attr_available = False + else: + self._attr_available = True + self._async_update_from_player() + self.async_write_ha_state() + + @callback + def _async_update_from_player(self) -> None: + if self._player.power is None: + self._attr_state = None + else: + self._attr_state = ( + MediaPlayerState.ON if self._player.power else MediaPlayerState.OFF + ) + + source_audio = self._player.source_audio + if source_audio is not None: + try: + v2007_source = V2007Source(source_audio) + self._attr_source = INPUT_SOURCE_V2007_TO_HA.get(v2007_source) + except ValueError: + self._attr_source = None + else: + self._attr_source = None + + if isinstance(self._player, V2007MainPlayer): + volume = self._player.volume + else: + volume = self._player.line_volume + + if volume is not None: + self._attr_volume_level = (volume - self._volume_min) / self._volume_range + else: + self._attr_volume_level = None + + self._attr_is_volume_muted = self._player.mute + + async def async_turn_on(self) -> None: + """Turn the receiver on.""" + await self._player.power_on() + + async def async_turn_off(self) -> None: + """Turn the receiver off.""" + await self._player.power_off() + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1.""" + db = volume * self._volume_range + self._volume_min + await self._set_volume(db) + + async def async_volume_up(self) -> None: + """Volume up.""" + await self._volume_up() + + async def async_volume_down(self) -> None: + """Volume down.""" + await self._volume_down() + + async def async_mute_volume(self, mute: bool) -> None: + """Mute or unmute.""" + if mute: + await self._player.mute_on() + else: + await self._player.mute_off() + + async def async_select_source(self, source: str) -> None: + """Select input source.""" + v2007_source = next( + ( + ls + for ls, ha_source in INPUT_SOURCE_V2007_TO_HA.items() + if ha_source == source + ), + None, + ) + if v2007_source is None: + raise HomeAssistantError("Invalid source") + + await self._player.select_source(v2007_source) + + +class MarantzV2003MediaPlayer(MediaPlayerEntity): + """Representation of a 2003-era Marantz receiver controlled over RS-232.""" + + _attr_device_class = MediaPlayerDeviceClass.RECEIVER + _attr_has_entity_name = True + _attr_translation_key = "receiver" + _attr_should_poll = False + + _volume_min = V2003_MIN_VOLUME_DB + _volume_range = V2003_VOLUME_DB_RANGE + + def __init__( + self, + receiver: MarantzV2003Receiver, + player: V2003MainPlayer | V2003MultiRoomPlayer, + config_entry: MarantzRS232ConfigEntry, + zone: str, + ) -> None: + """Initialize the v2003 media player.""" + self._receiver = receiver + self._player = player + + if isinstance(player, V2003MainPlayer): + self._power_on = player.power_on + self._power_off = player.power_off + else: + self._power_on = player.on + self._power_off = player.off + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, config_entry.entry_id)}, + manufacturer="Marantz", + model_id=MODEL_NAMES.get(config_entry.data["model"]), + ) + self._attr_unique_id = f"{config_entry.entry_id}_{zone}" + + self._attr_source_list = sorted(INPUT_SOURCE_V2003_TO_HA.values()) + if zone == "main": + self._attr_name = None + self._attr_supported_features = ( + MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_SET + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.VOLUME_MUTE + | MediaPlayerEntityFeature.SELECT_SOURCE + ) + else: + self._attr_name = "Multi Room" + self._attr_supported_features = ( + MediaPlayerEntityFeature.TURN_ON + | MediaPlayerEntityFeature.TURN_OFF + | MediaPlayerEntityFeature.VOLUME_STEP + | MediaPlayerEntityFeature.SELECT_SOURCE + ) + + self._async_update_from_player() + + async def async_added_to_hass(self) -> None: + """Subscribe to receiver state updates.""" + self.async_on_remove(self._receiver.subscribe(self._async_on_state_update)) + + @callback + def _async_on_state_update(self, state: V2003ReceiverState | None) -> None: + if state is None: + self._attr_available = False + else: + self._attr_available = True + self._async_update_from_player() + self.async_write_ha_state() + + @callback + def _async_update_from_player(self) -> None: + if isinstance(self._player, V2003MainPlayer): + power = self._player.power + else: + power = self._player.enabled + + if power is None: + self._attr_state = None + else: + self._attr_state = MediaPlayerState.ON if power else MediaPlayerState.OFF + + source = self._player.audio_input + self._attr_source = ( + INPUT_SOURCE_V2003_TO_HA.get(source) if source is not None else None + ) + + volume = self._player.volume + if volume is not None and volume != -math.inf: + self._attr_volume_level = (volume - self._volume_min) / self._volume_range + else: + self._attr_volume_level = None + + if isinstance(self._player, V2003MainPlayer): + self._attr_is_volume_muted = self._player.mute + + async def async_turn_on(self) -> None: + """Turn the receiver on.""" + await self._power_on() + + async def async_turn_off(self) -> None: + """Turn the receiver off.""" + await self._power_off() + + async def async_set_volume_level(self, volume: float) -> None: + """Set volume level, range 0..1. Main zone only.""" + db = round(volume * self._volume_range + self._volume_min) + await cast(V2003MainPlayer, self._player).set_volume(db) + + async def async_volume_up(self) -> None: + """Volume up.""" + await self._player.volume_up() + + async def async_volume_down(self) -> None: + """Volume down.""" + await self._player.volume_down() + + async def async_mute_volume(self, mute: bool) -> None: + """Mute or unmute. Main zone only.""" + player = cast(V2003MainPlayer, self._player) + if mute: + await player.mute_on() + else: + await player.mute_off() + + async def async_select_source(self, source: str) -> None: + """Select input source.""" + v2003_source = next( + ( + vs + for vs, ha_source in INPUT_SOURCE_V2003_TO_HA.items() + if ha_source == source + ), + None, + ) + if v2003_source is None: + raise HomeAssistantError("Invalid source") + + await self._player.select_source(v2003_source) diff --git a/homeassistant/components/marantz_rs232/quality_scale.yaml b/homeassistant/components/marantz_rs232/quality_scale.yaml new file mode 100644 index 000000000000..e7b4993cd673 --- /dev/null +++ b/homeassistant/components/marantz_rs232/quality_scale.yaml @@ -0,0 +1,64 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: todo + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: "The integration does not create dynamic devices." + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: "The integration does not create devices that can become stale." + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/marantz_rs232/strings.json b/homeassistant/components/marantz_rs232/strings.json new file mode 100644 index 000000000000..0e5c4e07cf5e --- /dev/null +++ b/homeassistant/components/marantz_rs232/strings.json @@ -0,0 +1,96 @@ +{ + "config": { + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "step": { + "user": { + "data": { + "device": "[%key:common::config_flow::data::port%]", + "model": "Receiver model" + }, + "data_description": { + "device": "Serial port path to connect to", + "model": "Determines the protocol used to communicate with the receiver" + } + } + } + }, + "entity": { + "media_player": { + "receiver": { + "state_attributes": { + "source": { + "state": { + "am": "AM", + "aux1": "Aux 1", + "aux2": "Aux 2", + "aux3": "Aux 3", + "aux4": "Aux 4", + "aux5": "Aux 5", + "aux6": "Aux 6", + "aux7": "Aux 7", + "bd": "BD Player", + "bt": "Bluetooth", + "cd": "CD", + "cd_cdr": "CD/CDR", + "cdr": "CDR", + "dss": "DSS", + "dss_vcr2": "DSS/VCR 2", + "dvd": "DVD", + "dvdr": "DVDR", + "favorites": "Favorites", + "flickr": "Flickr", + "fm": "FM", + "game": "Game", + "hdradio": "HD Radio", + "iradio": "Internet Radio", + "lastfm": "Last.fm", + "ld": "LaserDisc", + "lw": "LW", + "m_xport": "M-XPort", + "mplay": "Media Player", + "mw": "MW", + "napster": "Napster", + "net": "Network", + "net_usb": "Network/USB", + "pandora": "Pandora", + "phono": "Phono", + "rhapsody": "Rhapsody", + "sat": "Sat", + "sat_cbl": "Satellite/Cable", + "server": "Server", + "sirius": "Sirius", + "siriusxm": "SiriusXM", + "spotify": "Spotify", + "tape": "Tape", + "tuner": "Tuner", + "tv": "TV Audio", + "usb_ipod": "USB/iPod", + "v_aux": "V. Aux", + "vcr": "VCR", + "vcr1": "VCR 1", + "vcr2_dvdr": "VCR 2/DVDR", + "xm": "XM" + } + } + } + } + } + }, + "selector": { + "model": { + "options": { + "modern": "Modern", + "sr7002": "SR7002", + "sr8002": "SR8002", + "sr8300": "SR8300", + "sr9300": "SR9300" + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 3b7a49e7539e..62ded9d649e4 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -440,6 +440,7 @@ FLOWS = { "madvr", "mailgun", "marantz_infrared", + "marantz_rs232", "mastodon", "matter", "mcp", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index ed9a4162b749..d8f640e80000 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -4100,6 +4100,12 @@ } } }, + "marantz_rs232": { + "name": "Marantz RS-232", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "martec": { "name": "Martec", "integration_type": "virtual", diff --git a/requirements_all.txt b/requirements_all.txt index 5666d2481f6f..155c9e6cfc95 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1527,6 +1527,9 @@ lw12==0.9.2 # homeassistant.components.scrape lxml==6.1.1 +# homeassistant.components.marantz_rs232 +marantz-rs232==1.0.0 + # homeassistant.components.matrix matrix-nio==0.25.2 diff --git a/tests/components/marantz_rs232/__init__.py b/tests/components/marantz_rs232/__init__.py new file mode 100644 index 000000000000..6d0667d0231f --- /dev/null +++ b/tests/components/marantz_rs232/__init__.py @@ -0,0 +1,3 @@ +"""Tests for the Marantz RS-232 integration.""" + +MOCK_DEVICE = "/dev/ttyUSB0" diff --git a/tests/components/marantz_rs232/conftest.py b/tests/components/marantz_rs232/conftest.py new file mode 100644 index 000000000000..ca64ef75805e --- /dev/null +++ b/tests/components/marantz_rs232/conftest.py @@ -0,0 +1,144 @@ +"""Test fixtures for the Marantz RS-232 integration.""" + +from unittest.mock import AsyncMock, patch + +from marantz_rs232 import ( + MarantzV2003Receiver, + MarantzV2007Receiver, + MarantzV2015Receiver, + V2003Source, + V2007Model, + V2007Source, + V2015InputSource, +) +import pytest + +from homeassistant.components.marantz_rs232.const import DOMAIN +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_DEVICE, CONF_MODEL +from homeassistant.core import HomeAssistant + +from . import MOCK_DEVICE + +from tests.common import MockConfigEntry + + +def push_state(receiver: object) -> None: + """Notify subscribers using whichever notify API the protocol exposes.""" + # The v2003 protocol takes the state explicitly; the others read self._state. + if hasattr(receiver, "_notify_subscribers"): + receiver._notify_subscribers() + else: + receiver._notify(receiver._state if receiver._connected else None) + + +def _install_async_mocks(receiver: object) -> object: + """Replace the serial I/O surface with awaitable mocks.""" + receiver._connected = True + receiver.connect = AsyncMock() + receiver.query_state = AsyncMock() + receiver._send_command = AsyncMock() + + async def _disconnect() -> None: + receiver._connected = False + push_state(receiver) + + receiver.disconnect = AsyncMock(side_effect=_disconnect) + return receiver + + +@pytest.fixture +def mock_v2015_receiver() -> MarantzV2015Receiver: + """Create a modern (2015+) Marantz receiver test double.""" + receiver = MarantzV2015Receiver(MOCK_DEVICE) + + main = receiver._state.main_zone + main.power = True + main.volume = -40.0 + main.volume_min = -80.0 + main.volume_max = 18.0 + main.mute = False + main.input_source = V2015InputSource.CD + + zone_2 = receiver._state.zone_2 + zone_2.power = True + zone_2.volume = -30.0 + zone_2.input_source = V2015InputSource.TUNER + + zone_3 = receiver._state.zone_3 + zone_3.power = False + zone_3.input_source = V2015InputSource.NET + + return _install_async_mocks(receiver) + + +@pytest.fixture +def mock_v2007_receiver() -> MarantzV2007Receiver: + """Create a 2007-era Marantz receiver test double.""" + receiver = MarantzV2007Receiver(MOCK_DEVICE, model=V2007Model.SR7002) + + main = receiver._state.main + main.power = True + main.volume = -40.0 + main.mute = False + main.source_audio = V2007Source.DVD.value + + multi_room = receiver._state.multi_room_a + multi_room.power = True + multi_room.line_volume = -30.0 + multi_room.mute = False + multi_room.source_audio = V2007Source.TV.value + + return _install_async_mocks(receiver) + + +@pytest.fixture +def mock_v2003_receiver() -> MarantzV2003Receiver: + """Create a 2003-era Marantz receiver test double.""" + receiver = MarantzV2003Receiver(MOCK_DEVICE) + + main = receiver._state.main + main.power = True + main.volume = -40.0 + main.mute = False + main.audio_input = V2003Source.CD + + multi_room = receiver._state.multi_room + multi_room.enabled = True + multi_room.volume = -30.0 + multi_room.audio_input = V2003Source.TUNER + + return _install_async_mocks(receiver) + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Create a mock config entry for the modern receiver.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "modern"}, + title="Modern", + entry_id="01KPBBPM6WCQ8148EFR0TCG1WW", + ) + + +@pytest.fixture(autouse=True) +def mock_usb_component(hass: HomeAssistant) -> None: + """Mock the USB component to prevent setup failures.""" + hass.config.components.add("usb") + + +async def setup_integration( + hass: HomeAssistant, + entry: ConfigEntry, + receiver: object, + receiver_class: str, +) -> None: + """Set up the integration with a mocked receiver class.""" + entry.add_to_hass(hass) + with patch( + f"homeassistant.components.marantz_rs232.{receiver_class}", + return_value=receiver, + ): + await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/marantz_rs232/snapshots/test_media_player.ambr b/tests/components/marantz_rs232/snapshots/test_media_player.ambr new file mode 100644 index 000000000000..316bf423e731 --- /dev/null +++ b/tests/components/marantz_rs232/snapshots/test_media_player.ambr @@ -0,0 +1,399 @@ +# serializer version: 1 +# name: test_entities_created[media_player.modern-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'aux1', + 'aux2', + 'aux3', + 'aux4', + 'aux5', + 'aux6', + 'aux7', + 'bd', + 'bt', + 'cd', + 'cdr', + 'dvd', + 'favorites', + 'flickr', + 'game', + 'hdradio', + 'iradio', + 'lastfm', + 'm_xport', + 'mplay', + 'napster', + 'net', + 'net_usb', + 'pandora', + 'phono', + 'rhapsody', + 'sat', + 'sat_cbl', + 'server', + 'sirius', + 'siriusxm', + 'spotify', + 'tuner', + 'tv', + 'usb_ipod', + 'v_aux', + 'vcr', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.modern', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': None, + 'platform': 'marantz_rs232', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'receiver', + 'unique_id': '01KPBBPM6WCQ8148EFR0TCG1WW_main', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities_created[media_player.modern-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'friendly_name': 'Modern', + 'is_volume_muted': False, + 'source': 'cd', + 'source_list': list([ + 'aux1', + 'aux2', + 'aux3', + 'aux4', + 'aux5', + 'aux6', + 'aux7', + 'bd', + 'bt', + 'cd', + 'cdr', + 'dvd', + 'favorites', + 'flickr', + 'game', + 'hdradio', + 'iradio', + 'lastfm', + 'm_xport', + 'mplay', + 'napster', + 'net', + 'net_usb', + 'pandora', + 'phono', + 'rhapsody', + 'sat', + 'sat_cbl', + 'server', + 'sirius', + 'siriusxm', + 'spotify', + 'tuner', + 'tv', + 'usb_ipod', + 'v_aux', + 'vcr', + ]), + 'supported_features': , + 'volume_level': 0.40816326530612246, + }), + 'context': , + 'entity_id': 'media_player.modern', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities_created[media_player.modern_zone_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'aux1', + 'aux2', + 'aux3', + 'aux4', + 'aux5', + 'aux6', + 'aux7', + 'bd', + 'bt', + 'cd', + 'cdr', + 'dvd', + 'favorites', + 'flickr', + 'game', + 'hdradio', + 'iradio', + 'lastfm', + 'm_xport', + 'mplay', + 'napster', + 'net', + 'net_usb', + 'pandora', + 'phono', + 'rhapsody', + 'sat', + 'sat_cbl', + 'server', + 'sirius', + 'siriusxm', + 'spotify', + 'tuner', + 'tv', + 'usb_ipod', + 'v_aux', + 'vcr', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.modern_zone_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Zone 2', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone 2', + 'platform': 'marantz_rs232', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'receiver', + 'unique_id': '01KPBBPM6WCQ8148EFR0TCG1WW_zone_2', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities_created[media_player.modern_zone_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'friendly_name': 'Modern Zone 2', + 'source': 'tuner', + 'source_list': list([ + 'aux1', + 'aux2', + 'aux3', + 'aux4', + 'aux5', + 'aux6', + 'aux7', + 'bd', + 'bt', + 'cd', + 'cdr', + 'dvd', + 'favorites', + 'flickr', + 'game', + 'hdradio', + 'iradio', + 'lastfm', + 'm_xport', + 'mplay', + 'napster', + 'net', + 'net_usb', + 'pandora', + 'phono', + 'rhapsody', + 'sat', + 'sat_cbl', + 'server', + 'sirius', + 'siriusxm', + 'spotify', + 'tuner', + 'tv', + 'usb_ipod', + 'v_aux', + 'vcr', + ]), + 'supported_features': , + 'volume_level': 0.5102040816326531, + }), + 'context': , + 'entity_id': 'media_player.modern_zone_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_entities_created[media_player.modern_zone_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': list([ + None, + ]), + 'area_id': None, + 'capabilities': dict({ + 'source_list': list([ + 'aux1', + 'aux2', + 'aux3', + 'aux4', + 'aux5', + 'aux6', + 'aux7', + 'bd', + 'bt', + 'cd', + 'cdr', + 'dvd', + 'favorites', + 'flickr', + 'game', + 'hdradio', + 'iradio', + 'lastfm', + 'm_xport', + 'mplay', + 'napster', + 'net', + 'net_usb', + 'pandora', + 'phono', + 'rhapsody', + 'sat', + 'sat_cbl', + 'server', + 'sirius', + 'siriusxm', + 'spotify', + 'tuner', + 'tv', + 'usb_ipod', + 'v_aux', + 'vcr', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.modern_zone_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'object_id_base': 'Zone 3', + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Zone 3', + 'platform': 'marantz_rs232', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': 'receiver', + 'unique_id': '01KPBBPM6WCQ8148EFR0TCG1WW_zone_3', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities_created[media_player.modern_zone_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'receiver', + 'friendly_name': 'Modern Zone 3', + 'source_list': list([ + 'aux1', + 'aux2', + 'aux3', + 'aux4', + 'aux5', + 'aux6', + 'aux7', + 'bd', + 'bt', + 'cd', + 'cdr', + 'dvd', + 'favorites', + 'flickr', + 'game', + 'hdradio', + 'iradio', + 'lastfm', + 'm_xport', + 'mplay', + 'napster', + 'net', + 'net_usb', + 'pandora', + 'phono', + 'rhapsody', + 'sat', + 'sat_cbl', + 'server', + 'sirius', + 'siriusxm', + 'spotify', + 'tuner', + 'tv', + 'usb_ipod', + 'v_aux', + 'vcr', + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.modern_zone_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/marantz_rs232/test_config_flow.py b/tests/components/marantz_rs232/test_config_flow.py new file mode 100644 index 000000000000..e64f1c26a309 --- /dev/null +++ b/tests/components/marantz_rs232/test_config_flow.py @@ -0,0 +1,142 @@ +"""Tests for the Marantz RS-232 config flow.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.marantz_rs232.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_DEVICE, CONF_MODEL +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import MOCK_DEVICE + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Prevent config-entry creation tests from setting up the integration.""" + with patch( + "homeassistant.components.marantz_rs232.async_setup_entry", + return_value=True, + ) as mock_setup: + yield mock_setup + + +@pytest.mark.parametrize( + ("model_key", "title", "receiver_class", "receiver_fixture"), + [ + ("modern", "Modern", "MarantzV2015Receiver", "mock_v2015_receiver"), + ("sr7002", "SR7002", "MarantzV2007Receiver", "mock_v2007_receiver"), + ("sr9300", "SR9300", "MarantzV2003Receiver", "mock_v2003_receiver"), + ], +) +async def test_user_form_creates_entry( + hass: HomeAssistant, + request: pytest.FixtureRequest, + mock_setup_entry: AsyncMock, + model_key: str, + title: str, + receiver_class: str, + receiver_fixture: str, +) -> None: + """Test a successful config flow creates an entry for each protocol.""" + receiver = request.getfixturevalue(receiver_fixture) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + with patch( + f"homeassistant.components.marantz_rs232.config_flow.{receiver_class}", + return_value=receiver, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: model_key}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == title + assert result["data"] == {CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: model_key} + mock_setup_entry.assert_awaited_once() + receiver.connect.assert_awaited_once() + receiver.disconnect.assert_awaited_once() + + +@pytest.mark.parametrize( + ("exception", "error"), + [ + (ValueError("Invalid port"), "cannot_connect"), + (ConnectionError("No response"), "cannot_connect"), + (OSError("No such device"), "cannot_connect"), + (RuntimeError("boom"), "unknown"), + ], +) +async def test_user_form_error_recovers( + hass: HomeAssistant, + mock_v2015_receiver: AsyncMock, + exception: Exception, + error: str, +) -> None: + """Test the user step reports errors and recovers on retry.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + mock_v2015_receiver.connect.side_effect = exception + + with patch( + "homeassistant.components.marantz_rs232.config_flow.MarantzV2015Receiver", + return_value=mock_v2015_receiver, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "modern"}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {"base": error} + + mock_v2015_receiver.connect.side_effect = None + + with ( + patch( + "homeassistant.components.marantz_rs232.config_flow.MarantzV2015Receiver", + return_value=mock_v2015_receiver, + ), + patch( + "homeassistant.components.marantz_rs232.async_setup_entry", + return_value=True, + ), + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "modern"}, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_user_duplicate_port_aborts(hass: HomeAssistant) -> None: + """Test we abort if the same port is already configured.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "modern"}, + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "modern"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/marantz_rs232/test_init.py b/tests/components/marantz_rs232/test_init.py new file mode 100644 index 000000000000..921f6e1633b9 --- /dev/null +++ b/tests/components/marantz_rs232/test_init.py @@ -0,0 +1,70 @@ +"""Tests for the Marantz RS-232 integration setup and teardown.""" + +from marantz_rs232 import MarantzV2015Receiver + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from .conftest import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_and_unload( + hass: HomeAssistant, + mock_v2015_receiver: MarantzV2015Receiver, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a config entry sets up and unloads cleanly.""" + await setup_integration( + hass, mock_config_entry, mock_v2015_receiver, "MarantzV2015Receiver" + ) + + assert mock_config_entry.state is ConfigEntryState.LOADED + mock_v2015_receiver.connect.assert_awaited_once() + mock_v2015_receiver.query_state.assert_awaited_once() + + assert 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 + mock_v2015_receiver.disconnect.assert_awaited_once() + + +async def test_setup_connect_failure_raises_not_ready( + hass: HomeAssistant, + mock_v2015_receiver: MarantzV2015Receiver, + mock_config_entry: MockConfigEntry, +) -> None: + """Test a connection failure puts the entry in retry state.""" + mock_v2015_receiver.connect.side_effect = ConnectionError("No response") + + await setup_integration( + hass, mock_config_entry, mock_v2015_receiver, "MarantzV2015Receiver" + ) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_v2015_receiver.disconnect.assert_awaited_once() + + +async def test_remove_entry_while_loaded( + hass: HomeAssistant, + mock_v2015_receiver: MarantzV2015Receiver, + mock_config_entry: MockConfigEntry, +) -> None: + """Test removing a loaded entry does not schedule a reload. + + When removing a loaded entry, disconnect() fires the subscriber callback + with state=None. The callback must not schedule a reload because the entry + is already being removed (state is no longer LOADED). + """ + await setup_integration( + hass, mock_config_entry, mock_v2015_receiver, "MarantzV2015Receiver" + ) + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + mock_v2015_receiver.disconnect.assert_awaited_once() diff --git a/tests/components/marantz_rs232/test_media_player.py b/tests/components/marantz_rs232/test_media_player.py new file mode 100644 index 000000000000..f913bdca564b --- /dev/null +++ b/tests/components/marantz_rs232/test_media_player.py @@ -0,0 +1,376 @@ +"""Tests for the Marantz RS-232 media player platform.""" + +from pathlib import Path + +from marantz_rs232 import ( + MarantzV2003Receiver, + MarantzV2007Receiver, + MarantzV2015Receiver, + V2015InputSource, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.marantz_rs232.const import DOMAIN +from homeassistant.components.marantz_rs232.media_player import ( + INPUT_SOURCE_V2003_TO_HA, + INPUT_SOURCE_V2007_TO_HA, + INPUT_SOURCE_V2015_TO_HA, +) +from homeassistant.components.media_player import ( + ATTR_INPUT_SOURCE, + ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, + DOMAIN as MP_DOMAIN, + SERVICE_SELECT_SOURCE, +) +from homeassistant.const import ( + ATTR_ENTITY_ID, + CONF_DEVICE, + CONF_MODEL, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, + SERVICE_VOLUME_DOWN, + SERVICE_VOLUME_MUTE, + SERVICE_VOLUME_SET, + SERVICE_VOLUME_UP, + STATE_OFF, + STATE_ON, + STATE_UNAVAILABLE, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.util.json import load_json + +from . import MOCK_DEVICE +from .conftest import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + +MAIN_ENTITY_ID = "media_player.modern" +ZONE_2_ENTITY_ID = "media_player.modern_zone_2" +ZONE_3_ENTITY_ID = "media_player.modern_zone_3" + +STRINGS_PATH = Path("homeassistant/components/marantz_rs232/strings.json") + + +@pytest.fixture +async def init_v2015( + hass: HomeAssistant, + mock_v2015_receiver: MarantzV2015Receiver, + mock_config_entry: MockConfigEntry, +) -> None: + """Set up the modern receiver.""" + await setup_integration( + hass, mock_config_entry, mock_v2015_receiver, "MarantzV2015Receiver" + ) + + +async def test_entities_created( + hass: HomeAssistant, + mock_v2015_receiver: MarantzV2015Receiver, + mock_config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + init_v2015: None, +) -> None: + """Test media player entities are created through config entry setup.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + mock_v2015_receiver.query_state.assert_awaited_once() + + +async def test_inactive_zone_not_created( + hass: HomeAssistant, + mock_v2015_receiver: MarantzV2015Receiver, + mock_config_entry: MockConfigEntry, +) -> None: + """Test zones without a queried power state are not created.""" + mock_v2015_receiver._state.zone_2.power = None + mock_v2015_receiver._state.zone_3.power = None + + await setup_integration( + hass, mock_config_entry, mock_v2015_receiver, "MarantzV2015Receiver" + ) + + assert hass.states.get(MAIN_ENTITY_ID) is not None + assert hass.states.get(ZONE_2_ENTITY_ID) is None + assert hass.states.get(ZONE_3_ENTITY_ID) is None + + +async def test_state_update_and_unavailable( + hass: HomeAssistant, + mock_v2015_receiver: MarantzV2015Receiver, + init_v2015: None, +) -> None: + """Test the entity follows pushed state and goes unavailable on disconnect.""" + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_ON + + mock_v2015_receiver._state.main_zone.power = False + mock_v2015_receiver._notify_subscribers() + await hass.async_block_till_done() + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_OFF + + mock_v2015_receiver._connected = False + mock_v2015_receiver._notify_subscribers() + await hass.async_block_till_done() + assert hass.states.get(MAIN_ENTITY_ID).state == STATE_UNAVAILABLE + + +@pytest.mark.parametrize( + ("entity_id", "service", "data", "expected"), + [ + (MAIN_ENTITY_ID, SERVICE_TURN_ON, {}, ("ZM", "ON")), + (MAIN_ENTITY_ID, SERVICE_TURN_OFF, {}, ("ZM", "OFF")), + (MAIN_ENTITY_ID, SERVICE_VOLUME_UP, {}, ("MV", "UP")), + (MAIN_ENTITY_ID, SERVICE_VOLUME_DOWN, {}, ("MV", "DOWN")), + ( + MAIN_ENTITY_ID, + SERVICE_VOLUME_MUTE, + {ATTR_MEDIA_VOLUME_MUTED: True}, + ("MU", "ON"), + ), + ( + MAIN_ENTITY_ID, + SERVICE_VOLUME_MUTE, + {ATTR_MEDIA_VOLUME_MUTED: False}, + ("MU", "OFF"), + ), + ( + MAIN_ENTITY_ID, + SERVICE_SELECT_SOURCE, + {ATTR_INPUT_SOURCE: "net"}, + ("SI", V2015InputSource.NET.value), + ), + (ZONE_2_ENTITY_ID, SERVICE_TURN_ON, {}, ("Z2", "ON")), + ( + ZONE_2_ENTITY_ID, + SERVICE_SELECT_SOURCE, + {ATTR_INPUT_SOURCE: "cd"}, + ("Z2", V2015InputSource.CD.value), + ), + ], +) +async def test_v2015_commands( + hass: HomeAssistant, + mock_v2015_receiver: MarantzV2015Receiver, + init_v2015: None, + entity_id: str, + service: str, + data: dict[str, str | bool], + expected: tuple[str, str], +) -> None: + """Test media player services send the expected serial commands.""" + await hass.services.async_call( + MP_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **data}, + blocking=True, + ) + mock_v2015_receiver._send_command.assert_awaited_with(*expected) + + +async def test_v2015_volume_set( + hass: HomeAssistant, + mock_v2015_receiver: MarantzV2015Receiver, + init_v2015: None, +) -> None: + """Test setting the volume level sends a volume command.""" + state = hass.states.get(MAIN_ENTITY_ID) + # volume -40 in range [-80, 18] -> (-40 - -80) / 98 + assert abs(state.attributes[ATTR_MEDIA_VOLUME_LEVEL] - (40 / 98)) < 0.001 + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_VOLUME_SET, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_MEDIA_VOLUME_LEVEL: 0.5}, + blocking=True, + ) + assert mock_v2015_receiver._send_command.await_args.args[0] == "MV" + + +async def test_invalid_source_raises( + hass: HomeAssistant, + init_v2015: None, +) -> None: + """Test selecting an unknown source raises an error.""" + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: MAIN_ENTITY_ID, ATTR_INPUT_SOURCE: "nonexistent"}, + blocking=True, + ) + + +async def test_v2007_entities( + hass: HomeAssistant, + mock_v2007_receiver: MarantzV2007Receiver, +) -> None: + """Test a 2007-era receiver creates main and multi-room entities.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "sr7002"}, + title="SR7002", + ) + await setup_integration(hass, entry, mock_v2007_receiver, "MarantzV2007Receiver") + + main = hass.states.get("media_player.sr7002") + assert main.state == STATE_ON + assert main.attributes[ATTR_INPUT_SOURCE] == "dvd" + assert hass.states.get("media_player.sr7002_multi_room") is not None + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: "media_player.sr7002"}, + blocking=True, + ) + assert mock_v2007_receiver._send_command.await_args.args[0] == "PWR" + + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.sr7002", ATTR_INPUT_SOURCE: "tv"}, + blocking=True, + ) + assert mock_v2007_receiver._send_command.await_args.args[0] == "SRC" + + for service, data in ( + (SERVICE_TURN_OFF, {}), + (SERVICE_VOLUME_UP, {}), + (SERVICE_VOLUME_DOWN, {}), + (SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 0.5}), + (SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: True}), + (SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: False}), + ): + await hass.services.async_call( + MP_DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.sr7002", **data}, + blocking=True, + ) + + # Exercise the multi-room volume/mute/source paths. + for service, data in ( + (SERVICE_TURN_ON, {}), + (SERVICE_VOLUME_UP, {}), + (SERVICE_VOLUME_DOWN, {}), + (SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 0.4}), + (SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: True}), + (SERVICE_SELECT_SOURCE, {ATTR_INPUT_SOURCE: "dvd"}), + ): + await hass.services.async_call( + MP_DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.sr7002_multi_room", **data}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.sr7002", ATTR_INPUT_SOURCE: "nope"}, + blocking=True, + ) + + mock_v2007_receiver._state.main.power = False + mock_v2007_receiver._notify_subscribers() + await hass.async_block_till_done() + assert hass.states.get("media_player.sr7002").state == STATE_OFF + + mock_v2007_receiver._connected = False + mock_v2007_receiver._notify_subscribers() + await hass.async_block_till_done() + assert hass.states.get("media_player.sr7002").state == STATE_UNAVAILABLE + + +async def test_v2003_entities( + hass: HomeAssistant, + mock_v2003_receiver: MarantzV2003Receiver, +) -> None: + """Test a 2003-era receiver creates main and multi-room entities.""" + entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_DEVICE: MOCK_DEVICE, CONF_MODEL: "sr9300"}, + title="SR9300", + ) + await setup_integration(hass, entry, mock_v2003_receiver, "MarantzV2003Receiver") + + main = hass.states.get("media_player.sr9300") + assert main.state == STATE_ON + assert main.attributes[ATTR_INPUT_SOURCE] == "cd" + + multi_room = hass.states.get("media_player.sr9300_multi_room") + assert multi_room is not None + assert ATTR_INPUT_SOURCE_LIST in multi_room.attributes + + for service, data in ( + (SERVICE_TURN_ON, {}), + (SERVICE_TURN_OFF, {}), + (SERVICE_VOLUME_UP, {}), + (SERVICE_VOLUME_DOWN, {}), + (SERVICE_VOLUME_SET, {ATTR_MEDIA_VOLUME_LEVEL: 0.5}), + (SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: True}), + (SERVICE_VOLUME_MUTE, {ATTR_MEDIA_VOLUME_MUTED: False}), + (SERVICE_SELECT_SOURCE, {ATTR_INPUT_SOURCE: "dvd"}), + ): + await hass.services.async_call( + MP_DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.sr9300", **data}, + blocking=True, + ) + mock_v2003_receiver._send_command.assert_awaited() + + # Exercise multi-room power/volume/source paths. + for service, data in ( + (SERVICE_TURN_ON, {}), + (SERVICE_TURN_OFF, {}), + (SERVICE_VOLUME_UP, {}), + (SERVICE_VOLUME_DOWN, {}), + (SERVICE_SELECT_SOURCE, {ATTR_INPUT_SOURCE: "tuner"}), + ): + await hass.services.async_call( + MP_DOMAIN, + service, + {ATTR_ENTITY_ID: "media_player.sr9300_multi_room", **data}, + blocking=True, + ) + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + MP_DOMAIN, + SERVICE_SELECT_SOURCE, + {ATTR_ENTITY_ID: "media_player.sr9300", ATTR_INPUT_SOURCE: "nope"}, + blocking=True, + ) + + mock_v2003_receiver._state.main.power = False + mock_v2003_receiver._notify(mock_v2003_receiver._state) + await hass.async_block_till_done() + assert hass.states.get("media_player.sr9300").state == STATE_OFF + + mock_v2003_receiver._connected = False + mock_v2003_receiver._notify(None) + await hass.async_block_till_done() + assert hass.states.get("media_player.sr9300").state == STATE_UNAVAILABLE + + +def test_translation_keys_cover_all_sources() -> None: + """Test every mapped source has a matching translation key and vice versa.""" + mapped = ( + set(INPUT_SOURCE_V2015_TO_HA.values()) + | set(INPUT_SOURCE_V2007_TO_HA.values()) + | set(INPUT_SOURCE_V2003_TO_HA.values()) + ) + + strings = load_json(STRINGS_PATH) + declared = set( + strings["entity"]["media_player"]["receiver"]["state_attributes"]["source"][ + "state" + ] + ) + assert mapped == declared