Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -1166,6 +1166,8 @@ build.json @home-assistant/supervisor
/tests/components/workday/ @fabaff
/homeassistant/components/worldclock/ @fabaff
/tests/components/worldclock/ @fabaff
/homeassistant/components/ws66i/ @ssaenger
/tests/components/ws66i/ @ssaenger
/homeassistant/components/xbox/ @hunterjm
/tests/components/xbox/ @hunterjm
/homeassistant/components/xbox_live/ @MartinHjelmare
Expand Down
124 changes: 124 additions & 0 deletions homeassistant/components/ws66i/__init__.py
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):
Copy link
Copy Markdown
Member

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.

"""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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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)
146 changes: 146 additions & 0 deletions homeassistant/components/ws66i/config_flow.py
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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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()

# 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(
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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 else: block if needed.

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),
)


class CannotConnect(exceptions.HomeAssistantError):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This exception isn't used.

"""Error to indicate we cannot connect."""
24 changes: 24 additions & 0 deletions homeassistant/components/ws66i/const.py
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"
53 changes: 53 additions & 0 deletions homeassistant/components/ws66i/coordinator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
"""Coordinator for WS66i."""
from __future__ import annotations

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):
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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()
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The 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.
https://developers.home-assistant.io/docs/integration_fetching_data#coordinated-single-api-poll-for-data-for-all-entities

Copy link
Copy Markdown
Contributor Author

@ssaenger ssaenger May 10, 2022

Choose a reason for hiding this comment

The 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)
10 changes: 10 additions & 0 deletions homeassistant/components/ws66i/manifest.json
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"
}
Loading