-
-
Notifications
You must be signed in to change notification settings - Fork 37.1k
Add ws66i core integration #56094
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Add ws66i core integration #56094
Changes from all commits
f71c19a
6d4dae3
3acadcc
d11f907
ae39508
3e33198
e28040a
0b3005b
739b3e4
94bd3a2
36a9020
a7586fc
33e1475
b61888c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| """The Soundavo WS66i 6-Zone Amplifier integration.""" | ||
| from __future__ import annotations | ||
|
|
||
| import logging | ||
|
|
||
| from pyws66i import WS66i, get_ws66i | ||
|
|
||
| from homeassistant.config_entries import ConfigEntry | ||
| from homeassistant.const import CONF_IP_ADDRESS, EVENT_HOMEASSISTANT_STOP | ||
| from homeassistant.core import HomeAssistant, callback | ||
| from homeassistant.exceptions import ConfigEntryNotReady | ||
|
|
||
| from .const import CONF_SOURCES, DOMAIN | ||
| from .coordinator import Ws66iDataUpdateCoordinator | ||
| from .models import SourceRep, Ws66iData | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| PLATFORMS = ["media_player"] | ||
|
|
||
|
|
||
| @callback | ||
| def _get_sources_from_dict(data) -> SourceRep: | ||
| sources_config = data[CONF_SOURCES] | ||
|
|
||
| # Dict index to custom name | ||
| source_id_name = {int(index): name for index, name in sources_config.items()} | ||
|
|
||
| # Dict custom name to index | ||
| source_name_id = {v: k for k, v in source_id_name.items()} | ||
|
|
||
| # List of custom names | ||
| source_names = sorted(source_name_id.keys(), key=lambda v: source_name_id[v]) | ||
|
|
||
| return SourceRep(source_id_name, source_name_id, source_names) | ||
|
|
||
|
|
||
| def _find_zones(hass: HomeAssistant, ws66i: WS66i) -> list[int]: | ||
| """Generate zones list by searching for presence of zones.""" | ||
| # Zones 11 - 16 are the master amp | ||
| # Zones 21,31 - 26,36 are the daisy-chained amps | ||
| zone_list = [] | ||
| for amp_num in range(1, 4): | ||
|
|
||
| if amp_num > 1: | ||
| # Don't add entities that aren't present | ||
| status = ws66i.zone_status(amp_num * 10 + 1) | ||
| if status is None: | ||
| break | ||
|
|
||
| for zone_num in range(1, 7): | ||
| zone_id = (amp_num * 10) + zone_num | ||
| zone_list.append(zone_id) | ||
|
|
||
| _LOGGER.info("Detected %d amp(s)", amp_num - 1) | ||
| return zone_list | ||
|
|
||
|
|
||
| async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Set up Soundavo WS66i 6-Zone Amplifier from a config entry.""" | ||
| # Get the source names from the options flow | ||
| options: dict[str, dict[str, str]] | ||
| options = {CONF_SOURCES: entry.options[CONF_SOURCES]} | ||
| # Get the WS66i object and open up a connection to it | ||
| ws66i = get_ws66i(entry.data[CONF_IP_ADDRESS]) | ||
| try: | ||
| await hass.async_add_executor_job(ws66i.open) | ||
| except ConnectionError as err: | ||
| # Amplifier is probably turned off | ||
| raise ConfigEntryNotReady("Could not connect to WS66i Amp. Is it off?") from err | ||
|
|
||
| # Create the zone Representation dataclass | ||
| source_rep: SourceRep = _get_sources_from_dict(options) | ||
|
|
||
| # Create a list of discovered zones | ||
| zones = await hass.async_add_executor_job(_find_zones, hass, ws66i) | ||
|
|
||
| # Create the coordinator for the WS66i | ||
| coordinator: Ws66iDataUpdateCoordinator = Ws66iDataUpdateCoordinator( | ||
| hass, | ||
| ws66i, | ||
| zones, | ||
| ) | ||
|
|
||
| # Fetch initial data, retry on failed poll | ||
| await coordinator.async_config_entry_first_refresh() | ||
|
|
||
| # Create the Ws66iData data class save it to hass | ||
| hass.data.setdefault(DOMAIN, {})[entry.entry_id] = Ws66iData( | ||
| host_ip=entry.data[CONF_IP_ADDRESS], | ||
| device=ws66i, | ||
| sources=source_rep, | ||
| coordinator=coordinator, | ||
| zones=zones, | ||
| ) | ||
|
|
||
| def shutdown(event): | ||
| """Close the WS66i connection to the amplifier and save snapshots.""" | ||
| ws66i.close() | ||
|
|
||
| entry.async_on_unload(entry.add_update_listener(_update_listener)) | ||
| entry.async_on_unload( | ||
| hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) | ||
| ) | ||
|
|
||
| hass.config_entries.async_setup_platforms(entry, PLATFORMS) | ||
|
|
||
| return True | ||
|
|
||
|
|
||
| async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: | ||
| """Unload a config entry.""" | ||
| unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) | ||
| if unload_ok: | ||
| ws66i: WS66i = hass.data[DOMAIN][entry.entry_id].device | ||
| ws66i.close() | ||
| hass.data[DOMAIN].pop(entry.entry_id) | ||
|
|
||
| return unload_ok | ||
|
|
||
|
|
||
| async def _update_listener(hass: HomeAssistant, entry: ConfigEntry): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing return value typing. Please type the whole signature when adding type annotations. |
||
| """Handle options update.""" | ||
| await hass.config_entries.async_reload(entry.entry_id) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,146 @@ | ||
| """Config flow for WS66i 6-Zone Amplifier integration.""" | ||
| import logging | ||
|
|
||
| from pyws66i import WS66i, get_ws66i | ||
| import voluptuous as vol | ||
|
|
||
| from homeassistant import config_entries, core, exceptions | ||
| from homeassistant.const import CONF_IP_ADDRESS | ||
|
|
||
| from .const import ( | ||
| CONF_SOURCE_1, | ||
| CONF_SOURCE_2, | ||
| CONF_SOURCE_3, | ||
| CONF_SOURCE_4, | ||
| CONF_SOURCE_5, | ||
| CONF_SOURCE_6, | ||
| CONF_SOURCES, | ||
| DOMAIN, | ||
| INIT_OPTIONS_DEFAULT, | ||
| ) | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| SOURCES = [ | ||
| CONF_SOURCE_1, | ||
| CONF_SOURCE_2, | ||
| CONF_SOURCE_3, | ||
| CONF_SOURCE_4, | ||
| CONF_SOURCE_5, | ||
| CONF_SOURCE_6, | ||
| ] | ||
|
|
||
| OPTIONS_SCHEMA = {vol.Optional(source): str for source in SOURCES} | ||
|
|
||
| DATA_SCHEMA = vol.Schema({vol.Required(CONF_IP_ADDRESS): str}) | ||
|
|
||
| FIRST_ZONE = 11 | ||
|
|
||
|
|
||
| @core.callback | ||
| def _sources_from_config(data): | ||
| sources_config = { | ||
| str(idx + 1): data.get(source) for idx, source in enumerate(SOURCES) | ||
| } | ||
|
|
||
| return { | ||
| index: name.strip() | ||
| for index, name in sources_config.items() | ||
| if (name is not None and name.strip() != "") | ||
| } | ||
|
|
||
|
|
||
| async def validate_input(hass: core.HomeAssistant, input_data): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please add type annotations to the whole signature. |
||
| """Validate the user input allows us to connect. | ||
|
|
||
| Data has the keys from DATA_SCHEMA with values provided by the user. | ||
| """ | ||
| ws66i: WS66i = get_ws66i(input_data[CONF_IP_ADDRESS]) | ||
| await hass.async_add_executor_job(ws66i.open) | ||
| # No exception. run a simple test to make sure we opened correct port | ||
| # Test on FIRST_ZONE because this zone will always be valid | ||
| ret_val = await hass.async_add_executor_job(ws66i.zone_status, FIRST_ZONE) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please combine multiple calls that need to run in the executor into one function that we schedule once on the executor. Switching context is expensive. |
||
| if ret_val is None: | ||
| ws66i.close() | ||
| raise ConnectionError("Not a valid WS66i connection") | ||
|
|
||
| # Validation done. No issues. Close the connection | ||
| ws66i.close() | ||
bdraco marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| # Return info that you want to store in the config entry. | ||
| return {CONF_IP_ADDRESS: input_data[CONF_IP_ADDRESS]} | ||
|
|
||
|
|
||
| class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): | ||
| """Handle a config flow for WS66i 6-Zone Amplifier.""" | ||
|
|
||
| VERSION = 1 | ||
|
|
||
| async def async_step_user(self, user_input=None): | ||
| """Handle the initial step.""" | ||
| errors = {} | ||
| if user_input is not None: | ||
| try: | ||
| info = await validate_input(self.hass, user_input) | ||
| # Data is valid. Add default values for options flow. | ||
| return self.async_create_entry( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please only wrap the line that can raise in the try... except block. We can use an |
||
| title="WS66i Amp", | ||
| data=info, | ||
| options={CONF_SOURCES: INIT_OPTIONS_DEFAULT}, | ||
| ) | ||
| except ConnectionError: | ||
| errors["base"] = "cannot_connect" | ||
| except Exception: # pylint: disable=broad-except | ||
| _LOGGER.exception("Unexpected exception") | ||
| errors["base"] = "unknown" | ||
|
|
||
| return self.async_show_form( | ||
| step_id="user", data_schema=DATA_SCHEMA, errors=errors | ||
| ) | ||
|
|
||
| @staticmethod | ||
| @core.callback | ||
| def async_get_options_flow(config_entry): | ||
| """Define the config flow to handle options.""" | ||
| return Ws66iOptionsFlowHandler(config_entry) | ||
|
|
||
|
|
||
| @core.callback | ||
| def _key_for_source(index, source, previous_sources): | ||
| key = vol.Required( | ||
| source, description={"suggested_value": previous_sources[str(index)]} | ||
| ) | ||
|
|
||
| return key | ||
|
|
||
|
|
||
| class Ws66iOptionsFlowHandler(config_entries.OptionsFlow): | ||
| """Handle a WS66i options flow.""" | ||
|
|
||
| def __init__(self, config_entry): | ||
| """Initialize.""" | ||
| self.config_entry = config_entry | ||
|
|
||
| async def async_step_init(self, user_input=None): | ||
| """Manage the options.""" | ||
| if user_input is not None: | ||
| return self.async_create_entry( | ||
| title="Source Names", | ||
| data={CONF_SOURCES: _sources_from_config(user_input)}, | ||
| ) | ||
|
|
||
| # Fill form with previous source names | ||
| previous_sources = self.config_entry.options[CONF_SOURCES] | ||
| options = { | ||
| _key_for_source(idx + 1, source, previous_sources): str | ||
| for idx, source in enumerate(SOURCES) | ||
| } | ||
|
|
||
| return self.async_show_form( | ||
| step_id="init", | ||
| data_schema=vol.Schema(options), | ||
| ) | ||
bdraco marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
|
|
||
| class CannotConnect(exceptions.HomeAssistantError): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This exception isn't used. |
||
| """Error to indicate we cannot connect.""" | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,24 @@ | ||
| """Constants for the Soundavo WS66i 6-Zone Amplifier Media Player component.""" | ||
|
|
||
| DOMAIN = "ws66i" | ||
|
|
||
| CONF_SOURCES = "sources" | ||
|
|
||
| CONF_SOURCE_1 = "source_1" | ||
| CONF_SOURCE_2 = "source_2" | ||
| CONF_SOURCE_3 = "source_3" | ||
| CONF_SOURCE_4 = "source_4" | ||
| CONF_SOURCE_5 = "source_5" | ||
| CONF_SOURCE_6 = "source_6" | ||
|
|
||
| INIT_OPTIONS_DEFAULT = { | ||
| "1": "Source 1", | ||
| "2": "Source 2", | ||
| "3": "Source 3", | ||
| "4": "Source 4", | ||
| "5": "Source 5", | ||
| "6": "Source 6", | ||
| } | ||
|
|
||
| SERVICE_SNAPSHOT = "snapshot" | ||
| SERVICE_RESTORE = "restore" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| """Coordinator for WS66i.""" | ||
| from __future__ import annotations | ||
bdraco marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| from datetime import timedelta | ||
| import logging | ||
|
|
||
| from pyws66i import WS66i, ZoneStatus | ||
|
|
||
| from homeassistant.core import HomeAssistant | ||
| from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed | ||
|
|
||
| _LOGGER = logging.getLogger(__name__) | ||
|
|
||
| POLL_INTERVAL = timedelta(seconds=30) | ||
|
|
||
|
|
||
| class Ws66iDataUpdateCoordinator(DataUpdateCoordinator): | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. class Ws66iDataUpdateCoordinator(DataUpdateCoordinator[list[ZoneStatus]]): |
||
| """DataUpdateCoordinator to gather data for WS66i Zones.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| hass: HomeAssistant, | ||
| my_api: WS66i, | ||
| zones: list[int], | ||
| ) -> None: | ||
| """Initialize DataUpdateCoordinator to gather data for specific zones.""" | ||
| super().__init__( | ||
| hass, | ||
| _LOGGER, | ||
| name="WS66i", | ||
| update_interval=POLL_INTERVAL, | ||
| ) | ||
| self._ws66i = my_api | ||
| self._zones = zones | ||
|
|
||
| def _update_all_zones(self) -> list[ZoneStatus]: | ||
| """Fetch data for each of the zones.""" | ||
| data = [] | ||
| for zone_id in self._zones: | ||
| data_zone = self._ws66i.zone_status(zone_id) | ||
| if data_zone is None: | ||
| raise UpdateFailed(f"Failed to update zone {zone_id}") | ||
|
|
||
| data.append(data_zone) | ||
|
|
||
| # HA will call my entity's _handle_coordinator_update() | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This comment seems not needed. Our coordinator helper is described in our dev docs.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Coordinators was a concept I struggled immensely with. I did not understand the flow of coordinators, such as, "after this method is called, what will it call next?" I added this comment to remind me what HA will do next. I'll remove the comment though. |
||
| return data | ||
|
|
||
| async def _async_update_data(self) -> list[ZoneStatus]: | ||
| """Fetch data for each of the zones.""" | ||
| # HA will call my entity's _handle_coordinator_update() | ||
| # The data I pass back here can be accessed through coordinator.data. | ||
| return await self.hass.async_add_executor_job(self._update_all_zones) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| { | ||
| "domain": "ws66i", | ||
| "name": "Soundavo WS66i 6-Zone Amplifier", | ||
| "documentation": "https://www.home-assistant.io/integrations/ws66i", | ||
| "requirements": ["pyws66i==1.1"], | ||
| "codeowners": ["@ssaenger"], | ||
| "config_flow": true, | ||
| "quality_scale": "silver", | ||
| "iot_class": "local_polling" | ||
bdraco marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If close is async safe we should decorate the callback with
@callback.