Skip to content

Commit

Permalink
Add Wyoming satellite (#104759)
Browse files Browse the repository at this point in the history
* First draft of Wyoming satellite

* Set up homeassistant in tests

* Move satellite

* Add devices with binary sensor and select

* Add more events

* Add satellite enabled switch

* Fix mistake

* Only set up necessary platforms for satellites

* Lots of fixes

* Add tests

* Use config entry id as satellite id

* Initial satellite test

* Add satellite pipeline test

* More tests

* More satellite tests

* Only support single device per config entry

* Address comments

* Make a copy of platforms
  • Loading branch information
synesthesiam authored and frenck committed Dec 5, 2023
1 parent db6b804 commit 5a49e1d
Show file tree
Hide file tree
Showing 28 changed files with 1,802 additions and 60 deletions.
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 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
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)

# 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):
"""Handle options update."""
await hass.config_entries.async_reload(entry.entry_id)


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 = list(item.service.platforms)
if item.satellite is not None:
platforms += SATELLITE_PLATFORMS

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}"
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,
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

0 comments on commit 5a49e1d

Please sign in to comment.