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 7 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
39 changes: 35 additions & 4 deletions homeassistant/components/wyoming/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,17 +4,25 @@
import logging

from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady

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

_LOGGER = logging.getLogger(__name__)

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

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


Expand All @@ -25,23 +33,46 @@ 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
satellite_devices = SatelliteDevices(hass, entry)
satellite_devices.async_setup()

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

await hass.config_entries.async_forward_entry_setups(
entry,
service.platforms,
service.platforms + PLATFORMS,
)

entry.async_on_unload(entry.add_update_listener(update_listener))

if service.info.satellite is not None:
satellite_device = satellite_devices.async_get_or_create(item.service)
wyoming_satellite = WyomingSatellite(hass, service, satellite_device)
hass.async_create_background_task(
synesthesiam marked this conversation as resolved.
Show resolved Hide resolved
wyoming_satellite.run(), f"Satellite {item.service.info.satellite.name}"
)

def stop_satellite():
wyoming_satellite.is_running = False
synesthesiam marked this conversation as resolved.
Show resolved Hide resolved

entry.async_on_unload(stop_satellite)

return True


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)


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,
item.service.platforms,
synesthesiam marked this conversation as resolved.
Show resolved Hide resolved
)
if unload_ok:
del hass.data[DOMAIN][entry.entry_id]
Expand Down
65 changes: 65 additions & 0 deletions homeassistant/components/wyoming/binary_sensor.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""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
from .satellite import SatelliteDevice

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."""
domain_data: DomainDataItem = hass.data[DOMAIN][config_entry.entry_id]

@callback
def async_add_device(device: SatelliteDevice) -> None:
"""Add device."""
async_add_entities([WyomingSatelliteAssistInProgress(device)])

domain_data.satellite_devices.async_add_new_device_listener(async_add_device)

async_add_entities(
[
WyomingSatelliteAssistInProgress(device)
for device in domain_data.satellite_devices
]
)


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.async_on_remove(self._device.async_listen_update(self._is_active_changed))

@callback
def _is_active_changed(self, device: SatelliteDevice) -> None:
"""Call when active state changed."""
self._attr_is_on = self._device.is_active
self.async_write_ha_state()
90 changes: 63 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,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):

VERSION = 1

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

async def async_step_user(
self, user_input: dict[str, Any] | None = None
Expand All @@ -50,27 +54,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]
if name := service.get_name():
return self.async_create_entry(title=name, data=user_input)

# wake-word-detection
wake_installed = [wake for wake in service.info.wake if wake.installed]

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_abort(reason="no_services")

return self.async_create_entry(title=name, data=user_input)

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 +84,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 +99,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())):
return self.async_abort(reason="no_services")

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

uuid = f"wyoming_{service.host}_{service.port}"
synesthesiam marked this conversation as resolved.
Show resolved Hide resolved

await self.async_set_unique_id(uuid)
self._abort_if_unique_id_configured()

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."""
if (
synesthesiam marked this conversation as resolved.
Show resolved Hide resolved
(self._service is None)
or (not self._service.has_services())
or (not (name := self._service.get_name()))
):
return self.async_abort(reason="no_services")

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

return self.async_create_entry(
title=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
Loading