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.
Jump to
Jump to file
Failed to load files.
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 @@
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(

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

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/__init__.py#L50-L52

Added lines #L50 - L52 were not covered by tests
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

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

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/__init__.py#L56-L57

Added lines #L56 - L57 were not covered by tests
synesthesiam marked this conversation as resolved.
Show resolved Hide resolved

entry.async_on_unload(stop_satellite)

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

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/__init__.py#L59

Added line #L59 was not covered by tests

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)

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

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/__init__.py#L66

Added line #L66 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,
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)])

Check warning on line 34 in homeassistant/components/wyoming/binary_sensor.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/binary_sensor.py#L34

Added line #L34 was not covered by tests

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

Check warning on line 57 in homeassistant/components/wyoming/binary_sensor.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/binary_sensor.py#L57

Added line #L57 was not covered by tests

self.async_on_remove(self._device.async_listen_update(self._is_active_changed))

Check warning on line 59 in homeassistant/components/wyoming/binary_sensor.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/binary_sensor.py#L59

Added line #L59 was not covered by tests

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

Check warning on line 65 in homeassistant/components/wyoming/binary_sensor.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/binary_sensor.py#L64-L65

Added lines #L64 - L65 were not covered by tests
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 @@

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 @@
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 @@
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 @@
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")

Check warning on line 109 in homeassistant/components/wyoming/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/config_flow.py#L107-L109

Added lines #L107 - L109 were not covered by tests

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

Check warning on line 113 in homeassistant/components/wyoming/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/config_flow.py#L111-L113

Added lines #L111 - L113 were not covered by tests

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

Check warning on line 116 in homeassistant/components/wyoming/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/config_flow.py#L115-L116

Added lines #L115 - L116 were not covered by tests

uuid = f"wyoming_{service.host}_{service.port}"

Check warning on line 118 in homeassistant/components/wyoming/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/config_flow.py#L118

Added line #L118 was not covered by tests
synesthesiam marked this conversation as resolved.
Show resolved Hide resolved

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

Check warning on line 121 in homeassistant/components/wyoming/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/config_flow.py#L120-L121

Added lines #L120 - L121 were not covered by tests

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

Check warning on line 124 in homeassistant/components/wyoming/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/config_flow.py#L123-L124

Added lines #L123 - L124 were not covered by tests

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

Check warning on line 130 in homeassistant/components/wyoming/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/config_flow.py#L130

Added line #L130 was not covered by tests
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")

Check warning on line 135 in homeassistant/components/wyoming/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/config_flow.py#L135

Added line #L135 was not covered by tests

if user_input is None:
return self.async_show_form(

Check warning on line 138 in homeassistant/components/wyoming/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/config_flow.py#L137-L138

Added lines #L137 - L138 were not covered by tests
step_id="zeroconf_confirm",
description_placeholders={"name": name},
errors={},
)

return self.async_create_entry(

Check warning on line 144 in homeassistant/components/wyoming/config_flow.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/config_flow.py#L144

Added line #L144 was not covered by tests
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 @@
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

Check warning on line 59 in homeassistant/components/wyoming/data.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/data.py#L59

Added line #L59 was not covered by tests

# satellite
satellite_installed: Satellite | None = None

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

Check warning on line 65 in homeassistant/components/wyoming/data.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/data.py#L65

Added line #L65 was not covered by tests

if satellite_installed:
return satellite_installed.name

Check warning on line 68 in homeassistant/components/wyoming/data.py

View check run for this annotation

Codecov / codecov/patch

homeassistant/components/wyoming/data.py#L68

Added line #L68 was not covered by tests

return None

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