Skip to content
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 Wyoming satellite #104759

Merged
merged 18 commits into from
Dec 4, 2023
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
77 changes: 67 additions & 10 deletions homeassistant/components/wyoming/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,26 @@
import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr

from .const import ATTR_SPEAKER, DOMAIN
from .data import WyomingService
from .devices import SatelliteDevice
from .models import DomainDataItem
from .satellite import WyomingSatellite

_LOGGER = logging.getLogger(__name__)

SATELLITE_PLATFORMS = [Platform.BINARY_SENSOR, Platform.SELECT, Platform.SWITCH]

__all__ = [
"ATTR_SPEAKER",
"DOMAIN",
"async_setup_entry",
"async_unload_entry",
]


Expand All @@ -25,24 +34,72 @@
if service is None:
raise ConfigEntryNotReady("Unable to connect")

hass.data.setdefault(DOMAIN, {})[entry.entry_id] = service
item = DomainDataItem(service=service)
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = item

await hass.config_entries.async_forward_entry_setups(
entry,
service.platforms,
)
await hass.config_entries.async_forward_entry_setups(entry, service.platforms)
entry.async_on_unload(entry.add_update_listener(update_listener))

if (satellite_info := service.info.satellite) is not None:
# Create satellite device, etc.
item.satellite = _make_satellite(hass, entry, service)

# Set up satellite sensors, switches, etc.
await hass.config_entries.async_forward_entry_setups(entry, SATELLITE_PLATFORMS)
Copy link
Member

Choose a reason for hiding this comment

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

Note that it would be an error if we tried to forward the config entry more than once to a platform. There's no overlap at the moment, and I guess it would be caught in tests.


# Start satellite communication
entry.async_create_background_task(
hass,
item.satellite.run(),
f"Satellite {satellite_info.name}",
)

entry.async_on_unload(item.satellite.stop)

return True


def _make_satellite(
hass: HomeAssistant, config_entry: ConfigEntry, service: WyomingService
) -> WyomingSatellite:
"""Create Wyoming satellite/device from config entry and Wyoming service."""
satellite_info = service.info.satellite
assert satellite_info is not None

dev_reg = dr.async_get(hass)

# Use config entry id since only one satellite per entry is supported
satellite_id = config_entry.entry_id

device = dev_reg.async_get_or_create(
config_entry_id=config_entry.entry_id,
identifiers={(DOMAIN, satellite_id)},
name=satellite_info.name,
suggested_area=satellite_info.area,
)

satellite_device = SatelliteDevice(
satellite_id=satellite_id,
device_id=device.id,
)

return WyomingSatellite(hass, service, satellite_device)


async def update_listener(hass: HomeAssistant, entry: ConfigEntry):
Copy link
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.

"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)

Check warning on line 91 in homeassistant/components/wyoming/__init__.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/__init__.py#L91

Added line #L91 was not covered by tests


async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload Wyoming."""
service: WyomingService = hass.data[DOMAIN][entry.entry_id]
item: DomainDataItem = hass.data[DOMAIN][entry.entry_id]

unload_ok = await hass.config_entries.async_unload_platforms(
entry,
service.platforms,
)
platforms = item.service.platforms
if item.satellite is not None:
platforms += SATELLITE_PLATFORMS
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
platforms += SATELLITE_PLATFORMS
platforms = platforms + SATELLITE_PLATFORMS

Copy link
Member

Choose a reason for hiding this comment

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

Honestly, I think this suggestion isn't an improvement...

Copy link
Member

Choose a reason for hiding this comment

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

The intent is to not change the original variable anymore


unload_ok = await hass.config_entries.async_unload_platforms(entry, platforms)
if unload_ok:
del hass.data[DOMAIN][entry.entry_id]

Expand Down
55 changes: 55 additions & 0 deletions homeassistant/components/wyoming/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"""Binary sensor for Wyoming."""

from __future__ import annotations

from typing import TYPE_CHECKING

from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddEntitiesCallback

from .const import DOMAIN
from .entity import WyomingSatelliteEntity

if TYPE_CHECKING:
from .models import DomainDataItem


async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up binary sensor entities."""
item: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id]

# Setup is only forwarded for satellites
assert item.satellite is not None

async_add_entities([WyomingSatelliteAssistInProgress(item.satellite.device)])


class WyomingSatelliteAssistInProgress(WyomingSatelliteEntity, BinarySensorEntity):
"""Entity to represent Assist is in progress for satellite."""

entity_description = BinarySensorEntityDescription(
key="assist_in_progress",
translation_key="assist_in_progress",
)
_attr_is_on = False

async def async_added_to_hass(self) -> None:
"""Call when entity about to be added to hass."""
await super().async_added_to_hass()

self._device.set_is_active_listener(self._is_active_changed)

@callback
def _is_active_changed(self) -> None:
"""Call when active state changed."""
self._attr_is_on = self._device.is_active
self.async_write_ha_state()
91 changes: 64 additions & 27 deletions homeassistant/components/wyoming/config_flow.py
Original file line number Diff line number Diff line change
@@ -1,19 +1,22 @@
"""Config flow for Wyoming integration."""
from __future__ import annotations

import logging
from typing import Any
from urllib.parse import urlparse

import voluptuous as vol

from homeassistant import config_entries
from homeassistant.components.hassio import HassioServiceInfo
from homeassistant.const import CONF_HOST, CONF_PORT
from homeassistant.components import hassio, zeroconf
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT
from homeassistant.data_entry_flow import FlowResult

from .const import DOMAIN
from .data import WyomingService

_LOGGER = logging.getLogger()

STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
Expand All @@ -27,7 +30,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):

VERSION = 1

_hassio_discovery: HassioServiceInfo
_hassio_discovery: hassio.HassioServiceInfo
_service: WyomingService | None = None
_name: str | None = None

async def async_step_user(
self, user_input: dict[str, Any] | None = None
Expand All @@ -50,27 +55,14 @@ async def async_step_user(
errors={"base": "cannot_connect"},
)

# ASR = automated speech recognition (speech-to-text)
asr_installed = [asr for asr in service.info.asr if asr.installed]

# TTS = text-to-speech
tts_installed = [tts for tts in service.info.tts if tts.installed]

# wake-word-detection
wake_installed = [wake for wake in service.info.wake if wake.installed]
if name := service.get_name():
return self.async_create_entry(title=name, data=user_input)

if asr_installed:
name = asr_installed[0].name
elif tts_installed:
name = tts_installed[0].name
elif wake_installed:
name = wake_installed[0].name
else:
return self.async_abort(reason="no_services")

return self.async_create_entry(title=name, data=user_input)
return self.async_abort(reason="no_services")

async def async_step_hassio(self, discovery_info: HassioServiceInfo) -> FlowResult:
async def async_step_hassio(
self, discovery_info: hassio.HassioServiceInfo
) -> FlowResult:
"""Handle Supervisor add-on discovery."""
await self.async_set_unique_id(discovery_info.uuid)
self._abort_if_unique_id_configured()
Expand All @@ -93,11 +85,7 @@ async def async_step_hassio_confirm(
if user_input is not None:
uri = urlparse(self._hassio_discovery.config["uri"])
if service := await WyomingService.create(uri.hostname, uri.port):
if (
not any(asr for asr in service.info.asr if asr.installed)
and not any(tts for tts in service.info.tts if tts.installed)
and not any(wake for wake in service.info.wake if wake.installed)
):
if not service.has_services():
return self.async_abort(reason="no_services")

return self.async_create_entry(
Expand All @@ -112,3 +100,52 @@ async def async_step_hassio_confirm(
description_placeholders={"addon": self._hassio_discovery.name},
errors=errors,
)

async def async_step_zeroconf(
self, discovery_info: zeroconf.ZeroconfServiceInfo
) -> FlowResult:
"""Handle zeroconf discovery."""
_LOGGER.debug("Discovery info: %s", discovery_info)
if discovery_info.port is None:
return self.async_abort(reason="no_port")

service = await WyomingService.create(discovery_info.host, discovery_info.port)
if (service is None) or (not (name := service.get_name())):
# No supported services
return self.async_abort(reason="no_services")

self._name = name

# Use zeroconf name + service name as unique id.
# The satellite will use its own MAC as the zeroconf name by default.
unique_id = f"{discovery_info.name}_{self._name}"
Copy link
Member

Choose a reason for hiding this comment

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

Use the local variable name instead of the attribute.

await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()

self.context[CONF_NAME] = self._name
self.context["title_placeholders"] = {"name": self._name}

self._service = service
return await self.async_step_zeroconf_confirm()

async def async_step_zeroconf_confirm(
self, user_input: dict[str, Any] | None = None
) -> FlowResult:
"""Handle a flow initiated by zeroconf."""
assert self._service is not None
assert self._name is not None

if user_input is None:
return self.async_show_form(
step_id="zeroconf_confirm",
description_placeholders={"name": self._name},
errors={},
)

return self.async_create_entry(
title=self._name,
Copy link
Member

Choose a reason for hiding this comment

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

Define a local variable when accessing an attribute more than once.

data={
CONF_HOST: self._service.host,
CONF_PORT: self._service.port,
},
)
39 changes: 38 additions & 1 deletion homeassistant/components/wyoming/data.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import asyncio

from wyoming.client import AsyncTcpClient
from wyoming.info import Describe, Info
from wyoming.info import Describe, Info, Satellite

from homeassistant.const import Platform

Expand Down Expand Up @@ -32,6 +32,43 @@ def __init__(self, host: str, port: int, info: Info) -> None:
platforms.append(Platform.WAKE_WORD)
self.platforms = platforms

def has_services(self) -> bool:
"""Return True if services are installed that Home Assistant can use."""
return (
any(asr for asr in self.info.asr if asr.installed)
or any(tts for tts in self.info.tts if tts.installed)
or any(wake for wake in self.info.wake if wake.installed)
or ((self.info.satellite is not None) and self.info.satellite.installed)
)

def get_name(self) -> str | None:
"""Return name of first installed usable service."""
# ASR = automated speech recognition (speech-to-text)
asr_installed = [asr for asr in self.info.asr if asr.installed]
if asr_installed:
return asr_installed[0].name

# TTS = text-to-speech
tts_installed = [tts for tts in self.info.tts if tts.installed]
if tts_installed:
return tts_installed[0].name

# wake-word-detection
wake_installed = [wake for wake in self.info.wake if wake.installed]
if wake_installed:
return wake_installed[0].name

# satellite
satellite_installed: Satellite | None = None

if (self.info.satellite is not None) and self.info.satellite.installed:
satellite_installed = self.info.satellite

if satellite_installed:
return satellite_installed.name

return None

@classmethod
async def create(cls, host: str, port: int) -> WyomingService | None:
"""Create a Wyoming service."""
Expand Down