Compare commits

...
Sign in to create a new pull request.

30 commits
mill ... dev

Author SHA1 Message Date
Josef Zweck
1ce8bfdaa4
Use test helpers for acaia buttons (#130626) 2024-11-14 16:34:17 +01:00
Robert Resch
cd12720085
Add Python version to issue ID (#130611) 2024-11-14 16:31:33 +01:00
epenet
c7ee7dc880
Refactor translation checks (#130585)
* Refactor translation checks

* Adjust

* Improve

* Restore await

* Delay pytest.fail until the end of the test
2024-11-14 16:26:05 +01:00
epenet
472414a8d6
Add missing translation string to smarty (#130624) 2024-11-14 16:17:08 +01:00
Lennard Beers
0c44c632d4
Add number platform to eq3btsmart (#130429) 2024-11-14 15:38:38 +01:00
Álvaro Fernández Rojas
61d0de3042
Bump aioairzone to 0.9.6 (#130559)
* Update aioairzone to v0.9.6

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Remove _async_migrator_mac_empty and improve tests

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

* Remove WebServer empty mac fixes as requested by @epenet

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>

---------

Signed-off-by: Álvaro Fernández Rojas <noltari@gmail.com>
2024-11-14 15:27:10 +01:00
Thibaut
01332a542c
Removing myself from template codeowners (#130617)
* Removing myself as codeowners

* Fix

---------

Co-authored-by: Joostlek <joostlek@outlook.com>
2024-11-14 15:23:55 +01:00
Andre Lengwenus
3d84e35268
Move lcn non-config_entry related code to async_setup (#130603)
* Move non-config_entry related code to async_setup

* Remove action unload
2024-11-14 14:27:19 +01:00
Josef Zweck
eea782bbfe
Add acaia integration (#130059)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2024-11-14 13:28:38 +01:00
Lennard Beers
a949d18c30
Bump eq3btsmart to 1.4.1 (#130426) 2024-11-14 13:04:22 +01:00
Marc Mueller
a748897bd2
Update hassfest image to Python 3.13 (#130607) 2024-11-14 12:44:06 +01:00
Robert Resch
3201142fd8
Fix hassfest by adding go2rtc reqs (#130602) 2024-11-14 11:01:26 +01:00
starkillerOG
d0a58b68e8
Bump reolink-aio to 0.11.1 (#130600) 2024-11-14 10:48:25 +01:00
Simone Chemelli
93f79be2f4
Update uptime deviation for Vodafone Station (#130571)
Update sensor.py
2024-11-14 10:35:03 +01:00
Robert Resch
46cfe6aa32
Refactor camera WebRTC tests (#130581) 2024-11-14 10:28:04 +01:00
Robert Resch
301043ec38
Add require_webrtc_support decorator (#130519) 2024-11-14 10:27:45 +01:00
puddly
245fc246d8
Ensure ZHA setup works with container installs (#130470) 2024-11-14 10:13:29 +01:00
Noah Husby
58fd917cb7
Disable brightness from devices with no display in Cambridge Audio (#130369) 2024-11-14 10:11:44 +01:00
Steven B.
2c1d1f5777
Do not trigger events for updated ring events (#130430) 2024-11-14 10:09:58 +01:00
Luke Lashley
938b1eca22
Fix when the Roborock map is being provisioned (#130574) 2024-11-14 09:52:28 +01:00
Brett Adams
2fda4c82de
Force login prompt in Tesla Fleet (#130576) 2024-11-14 09:46:24 +01:00
J. Nick Koston
4200913d03
Fix non-thread-safe operation in powerview number (#130557) 2024-11-14 09:45:08 +01:00
Tony
4aad614497
Bump aioruckus to 0.42 (#130487) 2024-11-14 09:43:59 +01:00
epenet
6a3b4a6a23
Adjust minimum scapy version to 2.6.1 (#130565) 2024-11-13 17:49:39 -06:00
Michael Hansen
51c6ee97b1
Upgrade to hassil 2.0 (#130544)
* Working on hassil 2.0

* Bump to hassil 2.0

* Update snapshots

* Remove debug logging
2024-11-13 16:50:08 -05:00
Simon Lamon
4002bc3c25
Downgrade devcontainer to Python 3.12 again (#130562) 2024-11-13 22:03:34 +01:00
J. Nick Koston
c35ef6bda3
Bump aiohttp to 3.11.0 (#130542) 2024-11-13 19:32:14 +01:00
Marc Mueller
ed5560aec2
Update base image to Python 3.13 and deprecated 3.12 (#130425) 2024-11-13 19:28:53 +01:00
Sheldon Ip
0a5a2de78e
Fix translations in subaru (#130486) 2024-11-13 18:46:52 +01:00
Brig Lamoreaux
7fd337d67f
fix translation in srp_energy (#130540) 2024-11-13 18:42:26 +01:00
72 changed files with 1722 additions and 363 deletions

View file

@ -10,7 +10,7 @@ on:
env: env:
BUILD_TYPE: core BUILD_TYPE: core
DEFAULT_PYTHON: "3.12" DEFAULT_PYTHON: "3.13"
PIP_TIMEOUT: 60 PIP_TIMEOUT: 60
UV_HTTP_TIMEOUT: 60 UV_HTTP_TIMEOUT: 60
UV_SYSTEM_PYTHON: "true" UV_SYSTEM_PYTHON: "true"

View file

@ -40,6 +40,8 @@ build.json @home-assistant/supervisor
# Integrations # Integrations
/homeassistant/components/abode/ @shred86 /homeassistant/components/abode/ @shred86
/tests/components/abode/ @shred86 /tests/components/abode/ @shred86
/homeassistant/components/acaia/ @zweckj
/tests/components/acaia/ @zweckj
/homeassistant/components/accuweather/ @bieniu /homeassistant/components/accuweather/ @bieniu
/tests/components/accuweather/ @bieniu /tests/components/accuweather/ @bieniu
/homeassistant/components/acmeda/ @atmurray /homeassistant/components/acmeda/ @atmurray
@ -1487,8 +1489,8 @@ build.json @home-assistant/supervisor
/tests/components/tedee/ @patrickhilker @zweckj /tests/components/tedee/ @patrickhilker @zweckj
/homeassistant/components/tellduslive/ @fredrike /homeassistant/components/tellduslive/ @fredrike
/tests/components/tellduslive/ @fredrike /tests/components/tellduslive/ @fredrike
/homeassistant/components/template/ @PhracturedBlue @tetienne @home-assistant/core /homeassistant/components/template/ @PhracturedBlue @home-assistant/core
/tests/components/template/ @PhracturedBlue @tetienne @home-assistant/core /tests/components/template/ @PhracturedBlue @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77 /homeassistant/components/tesla_fleet/ @Bre77
/tests/components/tesla_fleet/ @Bre77 /tests/components/tesla_fleet/ @Bre77
/homeassistant/components/tesla_wall_connector/ @einarhauks /homeassistant/components/tesla_wall_connector/ @einarhauks

View file

@ -1,10 +1,10 @@
image: ghcr.io/home-assistant/{arch}-homeassistant image: ghcr.io/home-assistant/{arch}-homeassistant
build_from: build_from:
aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.06.1 aarch64: ghcr.io/home-assistant/aarch64-homeassistant-base:2024.11.0
armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.06.1 armhf: ghcr.io/home-assistant/armhf-homeassistant-base:2024.11.0
armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.06.1 armv7: ghcr.io/home-assistant/armv7-homeassistant-base:2024.11.0
amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.06.1 amd64: ghcr.io/home-assistant/amd64-homeassistant-base:2024.11.0
i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.06.1 i386: ghcr.io/home-assistant/i386-homeassistant-base:2024.11.0
codenotary: codenotary:
signer: notary@home-assistant.io signer: notary@home-assistant.io
base_image: notary@home-assistant.io base_image: notary@home-assistant.io

View file

@ -515,7 +515,7 @@ async def async_from_config_dict(
issue_registry.async_create_issue( issue_registry.async_create_issue(
hass, hass,
core.DOMAIN, core.DOMAIN,
"python_version", f"python_version_{required_python_version}",
is_fixable=False, is_fixable=False,
severity=issue_registry.IssueSeverity.WARNING, severity=issue_registry.IssueSeverity.WARNING,
breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE, breaks_in_ha_version=REQUIRED_NEXT_PYTHON_HA_RELEASE,

View file

@ -0,0 +1,29 @@
"""Initialize the Acaia component."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import AcaiaConfigEntry, AcaiaCoordinator
PLATFORMS = [
Platform.BUTTON,
]
async def async_setup_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
"""Set up acaia as config entry."""
coordinator = AcaiaCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: AcaiaConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View file

@ -0,0 +1,61 @@
"""Button entities for Acaia scales."""
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from aioacaia.acaiascale import AcaiaScale
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .coordinator import AcaiaConfigEntry
from .entity import AcaiaEntity
@dataclass(kw_only=True, frozen=True)
class AcaiaButtonEntityDescription(ButtonEntityDescription):
"""Description for acaia button entities."""
press_fn: Callable[[AcaiaScale], Coroutine[Any, Any, None]]
BUTTONS: tuple[AcaiaButtonEntityDescription, ...] = (
AcaiaButtonEntityDescription(
key="tare",
translation_key="tare",
press_fn=lambda scale: scale.tare(),
),
AcaiaButtonEntityDescription(
key="reset_timer",
translation_key="reset_timer",
press_fn=lambda scale: scale.reset_timer(),
),
AcaiaButtonEntityDescription(
key="start_stop",
translation_key="start_stop",
press_fn=lambda scale: scale.start_stop_timer(),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: AcaiaConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up button entities and services."""
coordinator = entry.runtime_data
async_add_entities(AcaiaButton(coordinator, description) for description in BUTTONS)
class AcaiaButton(AcaiaEntity, ButtonEntity):
"""Representation of an Acaia button."""
entity_description: AcaiaButtonEntityDescription
async def async_press(self) -> None:
"""Handle the button press."""
await self.entity_description.press_fn(self._scale)

View file

@ -0,0 +1,149 @@
"""Config flow for Acaia integration."""
import logging
from typing import Any
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice
from aioacaia.helpers import is_new_scale
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS, CONF_NAME
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.selector import (
SelectOptionDict,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import CONF_IS_NEW_STYLE_SCALE, DOMAIN
_LOGGER = logging.getLogger(__name__)
class AcaiaConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for acaia."""
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered: dict[str, Any] = {}
self._discovered_devices: dict[str, str] = {}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
errors: dict[str, str] = {}
if user_input is not None:
mac = format_mac(user_input[CONF_ADDRESS])
try:
is_new_style_scale = await is_new_scale(mac)
except AcaiaDeviceNotFound:
errors["base"] = "device_not_found"
except AcaiaError:
_LOGGER.exception("Error occurred while connecting to the scale")
errors["base"] = "unknown"
except AcaiaUnknownDevice:
return self.async_abort(reason="unsupported_device")
else:
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
if not errors:
return self.async_create_entry(
title=self._discovered_devices[user_input[CONF_ADDRESS]],
data={
CONF_ADDRESS: mac,
CONF_IS_NEW_STYLE_SCALE: is_new_style_scale,
},
)
for device in async_discovered_service_info(self.hass):
self._discovered_devices[device.address] = device.name
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
options = [
SelectOptionDict(
value=device_mac,
label=f"{device_name} ({device_mac})",
)
for device_mac, device_name in self._discovered_devices.items()
]
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_ADDRESS): SelectSelector(
SelectSelectorConfig(
options=options,
mode=SelectSelectorMode.DROPDOWN,
)
)
}
),
errors=errors,
)
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle a discovered Bluetooth device."""
self._discovered[CONF_ADDRESS] = mac = format_mac(discovery_info.address)
self._discovered[CONF_NAME] = discovery_info.name
await self.async_set_unique_id(mac)
self._abort_if_unique_id_configured()
try:
self._discovered[CONF_IS_NEW_STYLE_SCALE] = await is_new_scale(
discovery_info.address
)
except AcaiaDeviceNotFound:
_LOGGER.debug("Device not found during discovery")
return self.async_abort(reason="device_not_found")
except AcaiaError:
_LOGGER.debug(
"Error occurred while connecting to the scale during discovery",
exc_info=True,
)
return self.async_abort(reason="unknown")
except AcaiaUnknownDevice:
_LOGGER.debug("Unsupported device during discovery")
return self.async_abort(reason="unsupported_device")
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle confirmation of Bluetooth discovery."""
if user_input is not None:
return self.async_create_entry(
title=self._discovered[CONF_NAME],
data={
CONF_ADDRESS: self._discovered[CONF_ADDRESS],
CONF_IS_NEW_STYLE_SCALE: self._discovered[CONF_IS_NEW_STYLE_SCALE],
},
)
self.context["title_placeholders"] = placeholders = {
CONF_NAME: self._discovered[CONF_NAME]
}
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders=placeholders,
)

View file

@ -0,0 +1,4 @@
"""Constants for component."""
DOMAIN = "acaia"
CONF_IS_NEW_STYLE_SCALE = "is_new_style_scale"

View file

@ -0,0 +1,86 @@
"""Coordinator for Acaia integration."""
from __future__ import annotations
from datetime import timedelta
import logging
from aioacaia.acaiascale import AcaiaScale
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import CONF_IS_NEW_STYLE_SCALE
SCAN_INTERVAL = timedelta(seconds=15)
_LOGGER = logging.getLogger(__name__)
type AcaiaConfigEntry = ConfigEntry[AcaiaCoordinator]
class AcaiaCoordinator(DataUpdateCoordinator[None]):
"""Class to handle fetching data from the scale."""
config_entry: AcaiaConfigEntry
def __init__(self, hass: HomeAssistant, entry: AcaiaConfigEntry) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
name="acaia coordinator",
update_interval=SCAN_INTERVAL,
config_entry=entry,
)
self._scale = AcaiaScale(
address_or_ble_device=entry.data[CONF_ADDRESS],
name=entry.title,
is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE],
notify_callback=self.async_update_listeners,
)
@property
def scale(self) -> AcaiaScale:
"""Return the scale object."""
return self._scale
async def _async_update_data(self) -> None:
"""Fetch data."""
# scale is already connected, return
if self._scale.connected:
return
# scale is not connected, try to connect
try:
await self._scale.connect(setup_tasks=False)
except (AcaiaDeviceNotFound, AcaiaError, TimeoutError) as ex:
_LOGGER.debug(
"Could not connect to scale: %s, Error: %s",
self.config_entry.data[CONF_ADDRESS],
ex,
)
self._scale.device_disconnected_handler(notify=False)
return
# connected, set up background tasks
if not self._scale.heartbeat_task or self._scale.heartbeat_task.done():
self._scale.heartbeat_task = self.config_entry.async_create_background_task(
hass=self.hass,
target=self._scale.send_heartbeats(),
name="acaia_heartbeat_task",
)
if not self._scale.process_queue_task or self._scale.process_queue_task.done():
self._scale.process_queue_task = (
self.config_entry.async_create_background_task(
hass=self.hass,
target=self._scale.process_queue(),
name="acaia_process_queue_task",
)
)

View file

@ -0,0 +1,40 @@
"""Base class for Acaia entities."""
from dataclasses import dataclass
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AcaiaCoordinator
@dataclass
class AcaiaEntity(CoordinatorEntity[AcaiaCoordinator]):
"""Common elements for all entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: AcaiaCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._scale = coordinator.scale
self._attr_unique_id = f"{self._scale.mac}_{entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._scale.mac)},
manufacturer="Acaia",
model=self._scale.model,
suggested_area="Kitchen",
)
@property
def available(self) -> bool:
"""Returns whether entity is available."""
return super().available and self._scale.connected

View file

@ -0,0 +1,15 @@
{
"entity": {
"button": {
"tare": {
"default": "mdi:scale-balance"
},
"reset_timer": {
"default": "mdi:timer-refresh"
},
"start_stop": {
"default": "mdi:timer-play"
}
}
}
}

View file

@ -0,0 +1,29 @@
{
"domain": "acaia",
"name": "Acaia",
"bluetooth": [
{
"manufacturer_id": 16962
},
{
"local_name": "ACAIA*"
},
{
"local_name": "PYXIS-*"
},
{
"local_name": "LUNAR-*"
},
{
"local_name": "PROCHBT001"
}
],
"codeowners": ["@zweckj"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/acaia",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["aioacaia"],
"requirements": ["aioacaia==0.1.6"]
}

View file

@ -0,0 +1,38 @@
{
"config": {
"flow_title": "{name}",
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"unsupported_device": "This device is not supported."
},
"error": {
"device_not_found": "Device could not be found.",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"bluetooth_confirm": {
"description": "[%key:component::bluetooth::config::step::bluetooth_confirm::description%]"
},
"user": {
"description": "[%key:component::bluetooth::config::step::user::description%]",
"data": {
"address": "[%key:common::config_flow::data::device%]"
}
}
}
},
"entity": {
"button": {
"tare": {
"name": "Tare"
},
"reset_timer": {
"name": "Reset timer"
},
"start_stop": {
"name": "Start/stop timer"
}
}
}
}

View file

@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/airzone", "documentation": "https://www.home-assistant.io/integrations/airzone",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioairzone"], "loggers": ["aioairzone"],
"requirements": ["aioairzone==0.9.5"] "requirements": ["aioairzone==0.9.6"]
} }

View file

@ -7,6 +7,6 @@
"integration_type": "device", "integration_type": "device",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["aiostreammagic"], "loggers": ["aiostreammagic"],
"requirements": ["aiostreammagic==2.8.4"], "requirements": ["aiostreammagic==2.8.5"],
"zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."] "zeroconf": ["_stream-magic._tcp.local.", "_smoip._tcp.local."]
} }

View file

@ -51,8 +51,13 @@ CONTROL_ENTITIES: tuple[CambridgeAudioSelectEntityDescription, ...] = (
CambridgeAudioSelectEntityDescription( CambridgeAudioSelectEntityDescription(
key="display_brightness", key="display_brightness",
translation_key="display_brightness", translation_key="display_brightness",
options=[x.value for x in DisplayBrightness], options=[
DisplayBrightness.BRIGHT.value,
DisplayBrightness.DIM.value,
DisplayBrightness.OFF.value,
],
entity_category=EntityCategory.CONFIG, entity_category=EntityCategory.CONFIG,
load_fn=lambda client: client.display.brightness != DisplayBrightness.NONE,
value_fn=lambda client: client.display.brightness, value_fn=lambda client: client.display.brightness,
set_value_fn=lambda client, value: client.set_display_brightness( set_value_fn=lambda client, value: client.set_display_brightness(
DisplayBrightness(value) DisplayBrightness(value)

View file

@ -6,7 +6,7 @@ from abc import ABC, abstractmethod
import asyncio import asyncio
from collections.abc import Awaitable, Callable, Iterable from collections.abc import Awaitable, Callable, Iterable
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from functools import cache, partial from functools import cache, partial, wraps
import logging import logging
from typing import TYPE_CHECKING, Any, Protocol from typing import TYPE_CHECKING, Any, Protocol
@ -205,6 +205,49 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
) )
type WsCommandWithCamera = Callable[
[websocket_api.ActiveConnection, dict[str, Any], Camera],
Awaitable[None],
]
def require_webrtc_support(
error_code: str,
) -> Callable[[WsCommandWithCamera], websocket_api.AsyncWebSocketCommandHandler]:
"""Validate that the camera supports WebRTC."""
def decorate(
func: WsCommandWithCamera,
) -> websocket_api.AsyncWebSocketCommandHandler:
"""Decorate func."""
@wraps(func)
async def validate(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Validate that the camera supports WebRTC."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
error_code,
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
await func(connection, msg, camera)
return validate
return decorate
@websocket_api.websocket_command( @websocket_api.websocket_command(
{ {
vol.Required("type"): "camera/webrtc/offer", vol.Required("type"): "camera/webrtc/offer",
@ -213,8 +256,9 @@ async def _async_refresh_providers(hass: HomeAssistant) -> None:
} }
) )
@websocket_api.async_response @websocket_api.async_response
@require_webrtc_support("webrtc_offer_failed")
async def ws_webrtc_offer( async def ws_webrtc_offer(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None: ) -> None:
"""Handle the signal path for a WebRTC stream. """Handle the signal path for a WebRTC stream.
@ -226,20 +270,7 @@ async def ws_webrtc_offer(
Async friendly. Async friendly.
""" """
entity_id = msg["entity_id"]
offer = msg["offer"] offer = msg["offer"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_offer_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
session_id = ulid() session_id = ulid()
connection.subscriptions[msg["id"]] = partial( connection.subscriptions[msg["id"]] = partial(
camera.close_webrtc_session, session_id camera.close_webrtc_session, session_id
@ -278,23 +309,11 @@ async def ws_webrtc_offer(
} }
) )
@websocket_api.async_response @websocket_api.async_response
@require_webrtc_support("webrtc_get_client_config_failed")
async def ws_get_client_config( async def ws_get_client_config(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None: ) -> None:
"""Handle get WebRTC client config websocket command.""" """Handle get WebRTC client config websocket command."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_get_client_config_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
config = camera.async_get_webrtc_client_configuration().to_frontend_dict() config = camera.async_get_webrtc_client_configuration().to_frontend_dict()
connection.send_result( connection.send_result(
msg["id"], msg["id"],
@ -311,23 +330,11 @@ async def ws_get_client_config(
} }
) )
@websocket_api.async_response @websocket_api.async_response
@require_webrtc_support("webrtc_candidate_failed")
async def ws_candidate( async def ws_candidate(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any] connection: websocket_api.ActiveConnection, msg: dict[str, Any], camera: Camera
) -> None: ) -> None:
"""Handle WebRTC candidate websocket command.""" """Handle WebRTC candidate websocket command."""
entity_id = msg["entity_id"]
camera = get_camera_from_entity_id(hass, entity_id)
if camera.frontend_stream_type != StreamType.WEB_RTC:
connection.send_error(
msg["id"],
"webrtc_candidate_failed",
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
),
)
return
await camera.async_on_webrtc_candidate( await camera.async_on_webrtc_candidate(
msg["session_id"], RTCIceCandidate(msg["candidate"]) msg["session_id"], RTCIceCandidate(msg["candidate"])
) )

View file

@ -16,11 +16,11 @@ from hassil.expression import Expression, ListReference, Sequence
from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList from hassil.intents import Intents, SlotList, TextSlotList, WildcardSlotList
from hassil.recognize import ( from hassil.recognize import (
MISSING_ENTITY, MISSING_ENTITY,
MatchEntity,
RecognizeResult, RecognizeResult,
UnmatchedTextEntity,
recognize_all, recognize_all,
recognize_best,
) )
from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
from hassil.util import merge_dict from hassil.util import merge_dict
from home_assistant_intents import ErrorKey, get_intents, get_languages from home_assistant_intents import ErrorKey, get_intents, get_languages
import yaml import yaml
@ -499,6 +499,7 @@ class DefaultAgent(ConversationEntity):
maybe_result: RecognizeResult | None = None maybe_result: RecognizeResult | None = None
best_num_matched_entities = 0 best_num_matched_entities = 0
best_num_unmatched_entities = 0 best_num_unmatched_entities = 0
best_num_unmatched_ranges = 0
for result in recognize_all( for result in recognize_all(
user_input.text, user_input.text,
lang_intents.intents, lang_intents.intents,
@ -517,10 +518,14 @@ class DefaultAgent(ConversationEntity):
num_matched_entities += 1 num_matched_entities += 1
num_unmatched_entities = 0 num_unmatched_entities = 0
num_unmatched_ranges = 0
for unmatched_entity in result.unmatched_entities_list: for unmatched_entity in result.unmatched_entities_list:
if isinstance(unmatched_entity, UnmatchedTextEntity): if isinstance(unmatched_entity, UnmatchedTextEntity):
if unmatched_entity.text != MISSING_ENTITY: if unmatched_entity.text != MISSING_ENTITY:
num_unmatched_entities += 1 num_unmatched_entities += 1
elif isinstance(unmatched_entity, UnmatchedRangeEntity):
num_unmatched_ranges += 1
num_unmatched_entities += 1
else: else:
num_unmatched_entities += 1 num_unmatched_entities += 1
@ -532,15 +537,24 @@ class DefaultAgent(ConversationEntity):
(num_matched_entities == best_num_matched_entities) (num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities < best_num_unmatched_entities) and (num_unmatched_entities < best_num_unmatched_entities)
) )
or (
# Prefer unmatched ranges
(num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges > best_num_unmatched_ranges)
)
or ( or (
# More literal text matched # More literal text matched
(num_matched_entities == best_num_matched_entities) (num_matched_entities == best_num_matched_entities)
and (num_unmatched_entities == best_num_unmatched_entities) and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and (result.text_chunks_matched > maybe_result.text_chunks_matched) and (result.text_chunks_matched > maybe_result.text_chunks_matched)
) )
or ( or (
# Prefer match failures with entities # Prefer match failures with entities
(result.text_chunks_matched == maybe_result.text_chunks_matched) (result.text_chunks_matched == maybe_result.text_chunks_matched)
and (num_unmatched_entities == best_num_unmatched_entities)
and (num_unmatched_ranges == best_num_unmatched_ranges)
and ( and (
("name" in result.entities) ("name" in result.entities)
or ("name" in result.unmatched_entities) or ("name" in result.unmatched_entities)
@ -550,6 +564,7 @@ class DefaultAgent(ConversationEntity):
maybe_result = result maybe_result = result
best_num_matched_entities = num_matched_entities best_num_matched_entities = num_matched_entities
best_num_unmatched_entities = num_unmatched_entities best_num_unmatched_entities = num_unmatched_entities
best_num_unmatched_ranges = num_unmatched_ranges
return maybe_result return maybe_result
@ -562,77 +577,16 @@ class DefaultAgent(ConversationEntity):
language: str, language: str,
) -> RecognizeResult | None: ) -> RecognizeResult | None:
"""Search intents for a strict match to user input.""" """Search intents for a strict match to user input."""
custom_found = False return recognize_best(
name_found = False
best_results: list[RecognizeResult] = []
best_name_quality: int | None = None
best_text_chunks_matched: int | None = None
for result in recognize_all(
user_input.text, user_input.text,
lang_intents.intents, lang_intents.intents,
slot_lists=slot_lists, slot_lists=slot_lists,
intent_context=intent_context, intent_context=intent_context,
language=language, language=language,
): best_metadata_key=METADATA_CUSTOM_SENTENCE,
# Prioritize user intents best_slot_name="name",
is_custom = (
result.intent_metadata is not None
and result.intent_metadata.get(METADATA_CUSTOM_SENTENCE)
) )
if custom_found and not is_custom:
continue
if not custom_found and is_custom:
custom_found = True
# Clear builtin results
name_found = False
best_results = []
best_name_quality = None
best_text_chunks_matched = None
# Prioritize results with a "name" slot
name = result.entities.get("name")
is_name = name and not name.is_wildcard
if name_found and not is_name:
continue
if not name_found and is_name:
name_found = True
# Clear non-name results
best_results = []
best_text_chunks_matched = None
if is_name:
# Prioritize results with a better "name" slot
name_quality = len(cast(MatchEntity, name).value.split())
if (best_name_quality is None) or (name_quality > best_name_quality):
best_name_quality = name_quality
# Clear worse name results
best_results = []
best_text_chunks_matched = None
elif name_quality < best_name_quality:
continue
# Prioritize results with more literal text
# This causes wildcards to match last.
if (best_text_chunks_matched is None) or (
result.text_chunks_matched > best_text_chunks_matched
):
best_results = [result]
best_text_chunks_matched = result.text_chunks_matched
elif result.text_chunks_matched == best_text_chunks_matched:
# Accumulate results with the same number of literal text matched.
# We will resolve the ambiguity below.
best_results.append(result)
if best_results:
# Successful strict match
return best_results[0]
return None
async def _build_speech( async def _build_speech(
self, self,
language: str, language: str,

View file

@ -6,12 +6,8 @@ from collections.abc import Iterable
from typing import Any from typing import Any
from aiohttp import web from aiohttp import web
from hassil.recognize import ( from hassil.recognize import MISSING_ENTITY, RecognizeResult
MISSING_ENTITY, from hassil.string_matcher import UnmatchedRangeEntity, UnmatchedTextEntity
RecognizeResult,
UnmatchedRangeEntity,
UnmatchedTextEntity,
)
import voluptuous as vol import voluptuous as vol
from homeassistant.components import http, websocket_api from homeassistant.components import http, websocket_api

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation", "documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["hassil==1.7.4", "home-assistant-intents==2024.11.6"] "requirements": ["hassil==2.0.1", "home-assistant-intents==2024.11.13"]
} }

View file

@ -4,7 +4,8 @@ from __future__ import annotations
from typing import Any from typing import Any
from hassil.recognize import PUNCTUATION, RecognizeResult from hassil.recognize import RecognizeResult
from hassil.util import PUNCTUATION_ALL
import voluptuous as vol import voluptuous as vol
from homeassistant.const import CONF_COMMAND, CONF_PLATFORM from homeassistant.const import CONF_COMMAND, CONF_PLATFORM
@ -20,7 +21,7 @@ from .const import DATA_DEFAULT_ENTITY, DOMAIN
def has_no_punctuation(value: list[str]) -> list[str]: def has_no_punctuation(value: list[str]) -> list[str]:
"""Validate result does not contain punctuation.""" """Validate result does not contain punctuation."""
for sentence in value: for sentence in value:
if PUNCTUATION.search(sentence): if PUNCTUATION_ALL.search(sentence):
raise vol.Invalid("sentence should not contain punctuation") raise vol.Invalid("sentence should not contain punctuation")
return value return value

View file

@ -21,6 +21,7 @@ from .models import Eq3Config, Eq3ConfigEntryData
PLATFORMS = [ PLATFORMS = [
Platform.BINARY_SENSOR, Platform.BINARY_SENSOR,
Platform.CLIMATE, Platform.CLIMATE,
Platform.NUMBER,
Platform.SWITCH, Platform.SWITCH,
] ]

View file

@ -24,6 +24,11 @@ ENTITY_KEY_WINDOW = "window"
ENTITY_KEY_LOCK = "lock" ENTITY_KEY_LOCK = "lock"
ENTITY_KEY_BOOST = "boost" ENTITY_KEY_BOOST = "boost"
ENTITY_KEY_AWAY = "away" ENTITY_KEY_AWAY = "away"
ENTITY_KEY_COMFORT = "comfort"
ENTITY_KEY_ECO = "eco"
ENTITY_KEY_OFFSET = "offset"
ENTITY_KEY_WINDOW_OPEN_TEMPERATURE = "window_open_temperature"
ENTITY_KEY_WINDOW_OPEN_TIMEOUT = "window_open_timeout"
GET_DEVICE_TIMEOUT = 5 # seconds GET_DEVICE_TIMEOUT = 5 # seconds
@ -77,3 +82,5 @@ DEFAULT_SCAN_INTERVAL = 10 # seconds
SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected" SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected"
SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected" SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected"
EQ3BT_STEP = 0.5

View file

@ -8,6 +8,23 @@
} }
} }
}, },
"number": {
"comfort": {
"default": "mdi:sun-thermometer"
},
"eco": {
"default": "mdi:snowflake-thermometer"
},
"offset": {
"default": "mdi:thermometer-plus"
},
"window_open_temperature": {
"default": "mdi:window-open-variant"
},
"window_open_timeout": {
"default": "mdi:timer-refresh"
}
},
"switch": { "switch": {
"away": { "away": {
"default": "mdi:home-account", "default": "mdi:home-account",

View file

@ -23,5 +23,5 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["eq3btsmart"], "loggers": ["eq3btsmart"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["eq3btsmart==1.2.1", "bleak-esphome==1.1.0"] "requirements": ["eq3btsmart==1.4.1", "bleak-esphome==1.1.0"]
} }

View file

@ -2,7 +2,6 @@
from dataclasses import dataclass from dataclasses import dataclass
from eq3btsmart.const import DEFAULT_AWAY_HOURS, DEFAULT_AWAY_TEMP
from eq3btsmart.thermostat import Thermostat from eq3btsmart.thermostat import Thermostat
from .const import ( from .const import (
@ -23,8 +22,6 @@ class Eq3Config:
target_temp_selector: TargetTemperatureSelector = DEFAULT_TARGET_TEMP_SELECTOR target_temp_selector: TargetTemperatureSelector = DEFAULT_TARGET_TEMP_SELECTOR
external_temp_sensor: str = "" external_temp_sensor: str = ""
scan_interval: int = DEFAULT_SCAN_INTERVAL scan_interval: int = DEFAULT_SCAN_INTERVAL
default_away_hours: float = DEFAULT_AWAY_HOURS
default_away_temperature: float = DEFAULT_AWAY_TEMP
@dataclass(slots=True) @dataclass(slots=True)

View file

@ -0,0 +1,158 @@
"""Platform for eq3 number entities."""
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING
from eq3btsmart import Thermostat
from eq3btsmart.const import (
EQ3BT_MAX_OFFSET,
EQ3BT_MAX_TEMP,
EQ3BT_MIN_OFFSET,
EQ3BT_MIN_TEMP,
)
from eq3btsmart.models import Presets
from homeassistant.components.number import (
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
)
from homeassistant.const import EntityCategory, UnitOfTemperature, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from . import Eq3ConfigEntry
from .const import (
ENTITY_KEY_COMFORT,
ENTITY_KEY_ECO,
ENTITY_KEY_OFFSET,
ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
EQ3BT_STEP,
)
from .entity import Eq3Entity
@dataclass(frozen=True, kw_only=True)
class Eq3NumberEntityDescription(NumberEntityDescription):
"""Entity description for eq3 number entities."""
value_func: Callable[[Presets], float]
value_set_func: Callable[
[Thermostat],
Callable[[float], Awaitable[None]],
]
mode: NumberMode = NumberMode.BOX
entity_category: EntityCategory | None = EntityCategory.CONFIG
NUMBER_ENTITY_DESCRIPTIONS = [
Eq3NumberEntityDescription(
key=ENTITY_KEY_COMFORT,
value_func=lambda presets: presets.comfort_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_comfort_temperature,
translation_key=ENTITY_KEY_COMFORT,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_ECO,
value_func=lambda presets: presets.eco_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_eco_temperature,
translation_key=ENTITY_KEY_ECO,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
value_func=lambda presets: presets.window_open_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_temperature,
translation_key=ENTITY_KEY_WINDOW_OPEN_TEMPERATURE,
native_min_value=EQ3BT_MIN_TEMP,
native_max_value=EQ3BT_MAX_TEMP,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_OFFSET,
value_func=lambda presets: presets.offset_temperature.value,
value_set_func=lambda thermostat: thermostat.async_configure_temperature_offset,
translation_key=ENTITY_KEY_OFFSET,
native_min_value=EQ3BT_MIN_OFFSET,
native_max_value=EQ3BT_MAX_OFFSET,
native_step=EQ3BT_STEP,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=NumberDeviceClass.TEMPERATURE,
),
Eq3NumberEntityDescription(
key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
value_set_func=lambda thermostat: thermostat.async_configure_window_open_duration,
value_func=lambda presets: presets.window_open_time.value.total_seconds() / 60,
translation_key=ENTITY_KEY_WINDOW_OPEN_TIMEOUT,
native_min_value=0,
native_max_value=60,
native_step=5,
native_unit_of_measurement=UnitOfTime.MINUTES,
),
]
async def async_setup_entry(
hass: HomeAssistant,
entry: Eq3ConfigEntry,
async_add_entities: AddEntitiesCallback,
) -> None:
"""Set up the entry."""
async_add_entities(
Eq3NumberEntity(entry, entity_description)
for entity_description in NUMBER_ENTITY_DESCRIPTIONS
)
class Eq3NumberEntity(Eq3Entity, NumberEntity):
"""Base class for all eq3 number entities."""
entity_description: Eq3NumberEntityDescription
def __init__(
self, entry: Eq3ConfigEntry, entity_description: Eq3NumberEntityDescription
) -> None:
"""Initialize the entity."""
super().__init__(entry, entity_description.key)
self.entity_description = entity_description
@property
def native_value(self) -> float:
"""Return the state of the entity."""
if TYPE_CHECKING:
assert self._thermostat.status is not None
assert self._thermostat.status.presets is not None
return self.entity_description.value_func(self._thermostat.status.presets)
async def async_set_native_value(self, value: float) -> None:
"""Set the state of the entity."""
await self.entity_description.value_set_func(self._thermostat)(value)
@property
def available(self) -> bool:
"""Return whether the entity is available."""
return (
self._thermostat.status is not None
and self._thermostat.status.presets is not None
and self._attr_available
)

View file

@ -25,6 +25,23 @@
"name": "Daylight saving time" "name": "Daylight saving time"
} }
}, },
"number": {
"comfort": {
"name": "Comfort temperature"
},
"eco": {
"name": "Eco temperature"
},
"offset": {
"name": "Offset temperature"
},
"window_open_temperature": {
"name": "Window open temperature"
},
"window_open_timeout": {
"name": "Window open timeout"
}
},
"switch": { "switch": {
"lock": { "lock": {
"name": "Lock" "name": "Lock"

View file

@ -95,7 +95,7 @@ class PowerViewNumber(ShadeEntity, RestoreNumber):
self.entity_description = description self.entity_description = description
self._attr_unique_id = f"{self._attr_unique_id}_{description.key}" self._attr_unique_id = f"{self._attr_unique_id}_{description.key}"
def set_native_value(self, value: float) -> None: async def async_set_native_value(self, value: float) -> None:
"""Update the current value.""" """Update the current value."""
self._attr_native_value = value self._attr_native_value = value
self.entity_description.store_value_fn(self.coordinator, self._shade.id, value) self.entity_description.store_value_fn(self.coordinator, self._shade.id, value)

View file

@ -20,7 +20,8 @@ from homeassistant.const import (
Platform, Platform,
) )
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from .const import ( from .const import (
ADD_ENTITIES_CALLBACKS, ADD_ENTITIES_CALLBACKS,
@ -41,15 +42,26 @@ from .helpers import (
register_lcn_address_devices, register_lcn_address_devices,
register_lcn_host_device, register_lcn_host_device,
) )
from .services import SERVICES from .services import register_services
from .websocket import register_panel_and_ws_api from .websocket import register_panel_and_ws_api
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the LCN component."""
hass.data.setdefault(DOMAIN, {})
await register_services(hass)
await register_panel_and_ws_api(hass)
return True
async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool: async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
"""Set up a connection to PCHK host from a config entry.""" """Set up a connection to PCHK host from a config entry."""
hass.data.setdefault(DOMAIN, {})
if config_entry.entry_id in hass.data[DOMAIN]: if config_entry.entry_id in hass.data[DOMAIN]:
return False return False
@ -109,15 +121,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
) )
lcn_connection.register_for_inputs(input_received) lcn_connection.register_for_inputs(input_received)
# register service calls
for service_name, service in SERVICES:
if not hass.services.has_service(DOMAIN, service_name):
hass.services.async_register(
DOMAIN, service_name, service(hass).async_call_service, service.schema
)
await register_panel_and_ws_api(hass)
return True return True
@ -168,11 +171,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
host = hass.data[DOMAIN].pop(config_entry.entry_id) host = hass.data[DOMAIN].pop(config_entry.entry_id)
await host[CONNECTION].async_close() await host[CONNECTION].async_close()
# unregister service calls
if unload_ok and not hass.data[DOMAIN]: # check if this is the last entry to unload
for service_name, _ in SERVICES:
hass.services.async_remove(DOMAIN, service_name)
return unload_ok return unload_ok

View file

@ -429,3 +429,11 @@ SERVICES = (
(LcnService.DYN_TEXT, DynText), (LcnService.DYN_TEXT, DynText),
(LcnService.PCK, Pck), (LcnService.PCK, Pck),
) )
async def register_services(hass: HomeAssistant) -> None:
"""Register services for LCN."""
for service_name, service in SERVICES:
hass.services.async_register(
DOMAIN, service_name, service(hass).async_call_service, service.schema
)

View file

@ -18,5 +18,5 @@
"documentation": "https://www.home-assistant.io/integrations/reolink", "documentation": "https://www.home-assistant.io/integrations/reolink",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["reolink_aio"], "loggers": ["reolink_aio"],
"requirements": ["reolink-aio==0.11.0"] "requirements": ["reolink-aio==0.11.1"]
} }

View file

@ -96,7 +96,7 @@ class RingEvent(RingBaseEntity[RingListenCoordinator, RingDeviceT], EventEntity)
@callback @callback
def _handle_coordinator_update(self) -> None: def _handle_coordinator_update(self) -> None:
if alert := self._get_coordinator_alert(): if (alert := self._get_coordinator_alert()) and not alert.is_update:
self._async_handle_event(alert.kind) self._async_handle_event(alert.kind)
super()._handle_coordinator_update() super()._handle_coordinator_update()

View file

@ -2,7 +2,6 @@
from __future__ import annotations from __future__ import annotations
import asyncio
from datetime import timedelta from datetime import timedelta
import logging import logging
@ -107,8 +106,12 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
async def _async_update_data(self) -> DeviceProp: async def _async_update_data(self) -> DeviceProp:
"""Update data via library.""" """Update data via library."""
try: try:
await asyncio.gather(*(self._update_device_prop(), self.get_rooms())) # Update device props and standard api information
await self._update_device_prop()
# Set the new map id from the updated device props
self._set_current_map() self._set_current_map()
# Get the rooms for that map id.
await self.get_rooms()
except RoborockException as ex: except RoborockException as ex:
raise UpdateFailed(ex) from ex raise UpdateFailed(ex) from ex
return self.roborock_device_info.props return self.roborock_device_info.props

View file

@ -135,6 +135,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
RoborockCommand.LOAD_MULTI_MAP, RoborockCommand.LOAD_MULTI_MAP,
[map_id], [map_id],
) )
# Update the current map id manually so that nothing gets broken
# if another service hits the api.
self.coordinator.current_map = map_id
# We need to wait after updating the map # We need to wait after updating the map
# so that other commands will be executed correctly. # so that other commands will be executed correctly.
await asyncio.sleep(MAP_SLEEP) await asyncio.sleep(MAP_SLEEP)
@ -148,6 +151,9 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
@property @property
def current_option(self) -> str | None: def current_option(self) -> str | None:
"""Get the current status of the select entity from device_status.""" """Get the current status of the select entity from device_status."""
if (current_map := self.coordinator.current_map) is not None: if (
(current_map := self.coordinator.current_map) is not None
and current_map in self.coordinator.maps
): # 63 means it is searching for a map.
return self.coordinator.maps[current_map].name return self.coordinator.maps[current_map].name
return None return None

View file

@ -7,5 +7,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["aioruckus"], "loggers": ["aioruckus"],
"requirements": ["aioruckus==0.41"] "requirements": ["aioruckus==0.42"]
} }

View file

@ -28,6 +28,10 @@
"deprecated_yaml_import_issue_auth_error": { "deprecated_yaml_import_issue_auth_error": {
"title": "YAML import failed due to an authentication error", "title": "YAML import failed due to an authentication error",
"description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually." "description": "Configuring {integration_title} using YAML is being removed but there was an authentication error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
},
"deprecated_yaml_import_issue_cannot_connect": {
"title": "YAML import failed due to a connection error",
"description": "Configuring {integration_title} using YAML is being removed but there was a connect error while importing your existing configuration.\nSetup will not proceed.\n\nVerify that your {integration_title} is operating correctly and restart Home Assistant to attempt the import again.\n\nAlternatively, you may remove the `{domain}` configuration from your configuration.yaml entirely, restart Home Assistant, and add the {integration_title} integration manually."
} }
}, },
"entity": { "entity": {

View file

@ -17,7 +17,8 @@
"unknown": "[%key:common::config_flow::error::unknown%]" "unknown": "[%key:common::config_flow::error::unknown%]"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]" "already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"unknown": "Unexpected error"
} }
}, },
"entity": { "entity": {

View file

@ -37,13 +37,13 @@
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"incorrect_pin": "Incorrect PIN", "incorrect_pin": "Incorrect PIN",
"bad_pin_format": "PIN should be 4 digits", "bad_pin_format": "PIN should be 4 digits",
"two_factor_request_failed": "Request for 2FA code failed, please try again",
"bad_validation_code_format": "Validation code should be 6 digits", "bad_validation_code_format": "Validation code should be 6 digits",
"incorrect_validation_code": "Incorrect validation code" "incorrect_validation_code": "Incorrect validation code"
}, },
"abort": { "abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"two_factor_request_failed": "Request for 2FA code failed, please try again"
} }
}, },
"options": { "options": {

View file

@ -2,7 +2,7 @@
"domain": "template", "domain": "template",
"name": "Template", "name": "Template",
"after_dependencies": ["group"], "after_dependencies": ["group"],
"codeowners": ["@PhracturedBlue", "@tetienne", "@home-assistant/core"], "codeowners": ["@PhracturedBlue", "@home-assistant/core"],
"config_flow": true, "config_flow": true,
"dependencies": ["blueprint"], "dependencies": ["blueprint"],
"documentation": "https://www.home-assistant.io/integrations/template", "documentation": "https://www.home-assistant.io/integrations/template",

View file

@ -49,6 +49,7 @@ class TeslaSystemImplementation(config_entry_oauth2_flow.LocalOAuth2Implementati
def extra_authorize_data(self) -> dict[str, Any]: def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url.""" """Extra data that needs to be appended to the authorize url."""
return { return {
"prompt": "login",
"scope": " ".join(SCOPES), "scope": " ".join(SCOPES),
"code_challenge": self.code_challenge, # PKCE "code_challenge": self.code_challenge, # PKCE
} }
@ -83,4 +84,4 @@ class TeslaUserImplementation(AuthImplementation):
@property @property
def extra_authorize_data(self) -> dict[str, Any]: def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data that needs to be appended to the authorize url.""" """Extra data that needs to be appended to the authorize url."""
return {"scope": " ".join(SCOPES)} return {"prompt": "login", "scope": " ".join(SCOPES)}

View file

@ -22,7 +22,7 @@ from .const import _LOGGER, DOMAIN, LINE_TYPES
from .coordinator import VodafoneStationRouter from .coordinator import VodafoneStationRouter
NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"] NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"]
UPTIME_DEVIATION = 45 UPTIME_DEVIATION = 60
@dataclass(frozen=True, kw_only=True) @dataclass(frozen=True, kw_only=True)

View file

@ -33,6 +33,7 @@ from homeassistant.config_entries import (
from homeassistant.const import CONF_NAME from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.selector import FileSelector, FileSelectorConfig from homeassistant.helpers.selector import FileSelector, FileSelectorConfig
from homeassistant.util import dt as dt_util from homeassistant.util import dt as dt_util
@ -104,9 +105,10 @@ async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
yellow_radio.description = "Yellow Zigbee module" yellow_radio.description = "Yellow Zigbee module"
yellow_radio.manufacturer = "Nabu Casa" yellow_radio.manufacturer = "Nabu Casa"
if is_hassio(hass):
# Present the multi-PAN addon as a setup option, if it's available # Present the multi-PAN addon as a setup option, if it's available
multipan_manager = await silabs_multiprotocol_addon.get_multiprotocol_addon_manager( multipan_manager = (
hass await silabs_multiprotocol_addon.get_multiprotocol_addon_manager(hass)
) )
try: try:

View file

@ -29,9 +29,9 @@ PATCH_VERSION: Final = "0.dev0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)
REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0)
# Truthy date string triggers showing related deprecation warning messages. # Truthy date string triggers showing related deprecation warning messages.
REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "" REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = "2025.2"
# Format for platform files # Format for platform files
PLATFORM_FORMAT: Final = "{platform}.{domain}" PLATFORM_FORMAT: Final = "{platform}.{domain}"

View file

@ -8,6 +8,26 @@ from __future__ import annotations
from typing import Final from typing import Final
BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
{
"domain": "acaia",
"manufacturer_id": 16962,
},
{
"domain": "acaia",
"local_name": "ACAIA*",
},
{
"domain": "acaia",
"local_name": "PYXIS-*",
},
{
"domain": "acaia",
"local_name": "LUNAR-*",
},
{
"domain": "acaia",
"local_name": "PROCHBT001",
},
{ {
"domain": "airthings_ble", "domain": "airthings_ble",
"manufacturer_id": 820, "manufacturer_id": 820,

View file

@ -24,6 +24,7 @@ FLOWS = {
], ],
"integration": [ "integration": [
"abode", "abode",
"acaia",
"accuweather", "accuweather",
"acmeda", "acmeda",
"adax", "adax",

View file

@ -11,6 +11,12 @@
"config_flow": true, "config_flow": true,
"iot_class": "cloud_push" "iot_class": "cloud_push"
}, },
"acaia": {
"name": "Acaia",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
"accuweather": { "accuweather": {
"name": "AccuWeather", "name": "AccuWeather",
"integration_type": "service", "integration_type": "service",

View file

@ -5,7 +5,7 @@ aiodiscover==2.1.0
aiodns==3.2.0 aiodns==3.2.0
aiohasupervisor==0.2.1 aiohasupervisor==0.2.1
aiohttp-fast-zlib==0.1.1 aiohttp-fast-zlib==0.1.1
aiohttp==3.11.0rc2 aiohttp==3.11.0
aiohttp_cors==0.7.0 aiohttp_cors==0.7.0
aiozoneinfo==0.2.1 aiozoneinfo==0.2.1
astral==2.2 astral==2.2
@ -32,10 +32,10 @@ go2rtc-client==0.1.1
ha-ffmpeg==3.2.2 ha-ffmpeg==3.2.2
habluetooth==3.6.0 habluetooth==3.6.0
hass-nabucasa==0.84.0 hass-nabucasa==0.84.0
hassil==1.7.4 hassil==2.0.1
home-assistant-bluetooth==1.13.0 home-assistant-bluetooth==1.13.0
home-assistant-frontend==20241106.2 home-assistant-frontend==20241106.2
home-assistant-intents==2024.11.6 home-assistant-intents==2024.11.13
httpx==0.27.2 httpx==0.27.2
ifaddr==0.2.0 ifaddr==0.2.0
Jinja2==3.1.4 Jinja2==3.1.4
@ -181,8 +181,8 @@ chacha20poly1305-reuseable>=0.13.0
# https://github.com/pycountry/pycountry/blob/ea69bab36f00df58624a0e490fdad4ccdc14268b/HISTORY.txt#L39 # https://github.com/pycountry/pycountry/blob/ea69bab36f00df58624a0e490fdad4ccdc14268b/HISTORY.txt#L39
pycountry>=23.12.11 pycountry>=23.12.11
# scapy<2.5.0 will not work with python3.12 # scapy==2.6.0 causes CI failures due to a race condition
scapy>=2.5.0 scapy>=2.6.1
# tuf isn't updated to deal with breaking changes in securesystemslib==1.0. # tuf isn't updated to deal with breaking changes in securesystemslib==1.0.
# Only tuf>=4 includes a constraint to <1.0. # Only tuf>=4 includes a constraint to <1.0.

View file

@ -19,6 +19,7 @@ classifiers = [
"License :: OSI Approved :: Apache Software License", "License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent", "Operating System :: OS Independent",
"Programming Language :: Python :: 3.12", "Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Topic :: Home Automation", "Topic :: Home Automation",
] ]
requires-python = ">=3.12.0" requires-python = ">=3.12.0"
@ -28,7 +29,7 @@ dependencies = [
# change behavior based on presence of supervisor. Deprecated with #127228 # change behavior based on presence of supervisor. Deprecated with #127228
# Lib can be removed with 2025.11 # Lib can be removed with 2025.11
"aiohasupervisor==0.2.1", "aiohasupervisor==0.2.1",
"aiohttp==3.11.0rc2", "aiohttp==3.11.0",
"aiohttp_cors==0.7.0", "aiohttp_cors==0.7.0",
"aiohttp-fast-zlib==0.1.1", "aiohttp-fast-zlib==0.1.1",
"aiozoneinfo==0.2.1", "aiozoneinfo==0.2.1",

View file

@ -5,7 +5,7 @@
# Home Assistant Core # Home Assistant Core
aiodns==3.2.0 aiodns==3.2.0
aiohasupervisor==0.2.1 aiohasupervisor==0.2.1
aiohttp==3.11.0rc2 aiohttp==3.11.0
aiohttp_cors==0.7.0 aiohttp_cors==0.7.0
aiohttp-fast-zlib==0.1.1 aiohttp-fast-zlib==0.1.1
aiozoneinfo==0.2.1 aiozoneinfo==0.2.1

View file

@ -172,6 +172,9 @@ aio-geojson-usgs-earthquakes==0.3
# homeassistant.components.gdacs # homeassistant.components.gdacs
aio-georss-gdacs==0.10 aio-georss-gdacs==0.10
# homeassistant.components.acaia
aioacaia==0.1.6
# homeassistant.components.airq # homeassistant.components.airq
aioairq==0.3.2 aioairq==0.3.2
@ -179,7 +182,7 @@ aioairq==0.3.2
aioairzone-cloud==0.6.10 aioairzone-cloud==0.6.10
# homeassistant.components.airzone # homeassistant.components.airzone
aioairzone==0.9.5 aioairzone==0.9.6
# homeassistant.components.ambient_network # homeassistant.components.ambient_network
# homeassistant.components.ambient_station # homeassistant.components.ambient_station
@ -354,7 +357,7 @@ aiorecollect==2023.09.0
aioridwell==2024.01.0 aioridwell==2024.01.0
# homeassistant.components.ruckus_unleashed # homeassistant.components.ruckus_unleashed
aioruckus==0.41 aioruckus==0.42
# homeassistant.components.russound_rio # homeassistant.components.russound_rio
aiorussound==4.1.0 aiorussound==4.1.0
@ -381,7 +384,7 @@ aiosolaredge==0.2.0
aiosteamist==1.0.0 aiosteamist==1.0.0
# homeassistant.components.cambridge_audio # homeassistant.components.cambridge_audio
aiostreammagic==2.8.4 aiostreammagic==2.8.5
# homeassistant.components.switcher_kis # homeassistant.components.switcher_kis
aioswitcher==4.4.0 aioswitcher==4.4.0
@ -860,7 +863,7 @@ epion==0.0.3
epson-projector==0.5.1 epson-projector==0.5.1
# homeassistant.components.eq3btsmart # homeassistant.components.eq3btsmart
eq3btsmart==1.2.1 eq3btsmart==1.4.1
# homeassistant.components.esphome # homeassistant.components.esphome
esphome-dashboard-api==1.2.3 esphome-dashboard-api==1.2.3
@ -1093,7 +1096,7 @@ hass-nabucasa==0.84.0
hass-splunk==0.1.1 hass-splunk==0.1.1
# homeassistant.components.conversation # homeassistant.components.conversation
hassil==1.7.4 hassil==2.0.1
# homeassistant.components.jewish_calendar # homeassistant.components.jewish_calendar
hdate==0.10.9 hdate==0.10.9
@ -1130,7 +1133,7 @@ holidays==0.60
home-assistant-frontend==20241106.2 home-assistant-frontend==20241106.2
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2024.11.6 home-assistant-intents==2024.11.13
# homeassistant.components.home_connect # homeassistant.components.home_connect
homeconnect==0.8.0 homeconnect==0.8.0
@ -2553,7 +2556,7 @@ renault-api==0.2.7
renson-endura-delta==1.7.1 renson-endura-delta==1.7.1
# homeassistant.components.reolink # homeassistant.components.reolink
reolink-aio==0.11.0 reolink-aio==0.11.1
# homeassistant.components.idteck_prox # homeassistant.components.idteck_prox
rfk101py==0.0.1 rfk101py==0.0.1

View file

@ -160,6 +160,9 @@ aio-geojson-usgs-earthquakes==0.3
# homeassistant.components.gdacs # homeassistant.components.gdacs
aio-georss-gdacs==0.10 aio-georss-gdacs==0.10
# homeassistant.components.acaia
aioacaia==0.1.6
# homeassistant.components.airq # homeassistant.components.airq
aioairq==0.3.2 aioairq==0.3.2
@ -167,7 +170,7 @@ aioairq==0.3.2
aioairzone-cloud==0.6.10 aioairzone-cloud==0.6.10
# homeassistant.components.airzone # homeassistant.components.airzone
aioairzone==0.9.5 aioairzone==0.9.6
# homeassistant.components.ambient_network # homeassistant.components.ambient_network
# homeassistant.components.ambient_station # homeassistant.components.ambient_station
@ -336,7 +339,7 @@ aiorecollect==2023.09.0
aioridwell==2024.01.0 aioridwell==2024.01.0
# homeassistant.components.ruckus_unleashed # homeassistant.components.ruckus_unleashed
aioruckus==0.41 aioruckus==0.42
# homeassistant.components.russound_rio # homeassistant.components.russound_rio
aiorussound==4.1.0 aiorussound==4.1.0
@ -363,7 +366,7 @@ aiosolaredge==0.2.0
aiosteamist==1.0.0 aiosteamist==1.0.0
# homeassistant.components.cambridge_audio # homeassistant.components.cambridge_audio
aiostreammagic==2.8.4 aiostreammagic==2.8.5
# homeassistant.components.switcher_kis # homeassistant.components.switcher_kis
aioswitcher==4.4.0 aioswitcher==4.4.0
@ -729,7 +732,7 @@ epion==0.0.3
epson-projector==0.5.1 epson-projector==0.5.1
# homeassistant.components.eq3btsmart # homeassistant.components.eq3btsmart
eq3btsmart==1.2.1 eq3btsmart==1.4.1
# homeassistant.components.esphome # homeassistant.components.esphome
esphome-dashboard-api==1.2.3 esphome-dashboard-api==1.2.3
@ -928,7 +931,7 @@ habluetooth==3.6.0
hass-nabucasa==0.84.0 hass-nabucasa==0.84.0
# homeassistant.components.conversation # homeassistant.components.conversation
hassil==1.7.4 hassil==2.0.1
# homeassistant.components.jewish_calendar # homeassistant.components.jewish_calendar
hdate==0.10.9 hdate==0.10.9
@ -956,7 +959,7 @@ holidays==0.60
home-assistant-frontend==20241106.2 home-assistant-frontend==20241106.2
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2024.11.6 home-assistant-intents==2024.11.13
# homeassistant.components.home_connect # homeassistant.components.home_connect
homeconnect==0.8.0 homeconnect==0.8.0
@ -2044,7 +2047,7 @@ renault-api==0.2.7
renson-endura-delta==1.7.1 renson-endura-delta==1.7.1
# homeassistant.components.reolink # homeassistant.components.reolink
reolink-aio==0.11.0 reolink-aio==0.11.1
# homeassistant.components.rflink # homeassistant.components.rflink
rflink==0.0.66 rflink==0.0.66

View file

@ -214,8 +214,8 @@ chacha20poly1305-reuseable>=0.13.0
# https://github.com/pycountry/pycountry/blob/ea69bab36f00df58624a0e490fdad4ccdc14268b/HISTORY.txt#L39 # https://github.com/pycountry/pycountry/blob/ea69bab36f00df58624a0e490fdad4ccdc14268b/HISTORY.txt#L39
pycountry>=23.12.11 pycountry>=23.12.11
# scapy<2.5.0 will not work with python3.12 # scapy==2.6.0 causes CI failures due to a race condition
scapy>=2.5.0 scapy>=2.6.1
# tuf isn't updated to deal with breaking changes in securesystemslib==1.0. # tuf isn't updated to deal with breaking changes in securesystemslib==1.0.
# Only tuf>=4 includes a constraint to <1.0. # Only tuf>=4 includes a constraint to <1.0.

View file

@ -80,7 +80,7 @@ WORKDIR /config
_HASSFEST_TEMPLATE = r"""# Automatically generated by hassfest. _HASSFEST_TEMPLATE = r"""# Automatically generated by hassfest.
# #
# To update, run python3 -m script.hassfest -p docker # To update, run python3 -m script.hassfest -p docker
FROM python:3.12-alpine FROM python:3.13-alpine
ENV \ ENV \
UV_SYSTEM_PYTHON=true \ UV_SYSTEM_PYTHON=true \
@ -161,6 +161,8 @@ def _generate_hassfest_dockerimage(
packages.update( packages.update(
gather_recursive_requirements(platform.value, already_checked_domains) gather_recursive_requirements(platform.value, already_checked_domains)
) )
# Add go2rtc requirements as this file needs the go2rtc integration
packages.update(gather_recursive_requirements("go2rtc", already_checked_domains))
return File( return File(
_HASSFEST_TEMPLATE.format( _HASSFEST_TEMPLATE.format(

View file

@ -1,7 +1,7 @@
# Automatically generated by hassfest. # Automatically generated by hassfest.
# #
# To update, run python3 -m script.hassfest -p docker # To update, run python3 -m script.hassfest -p docker
FROM python:3.12-alpine FROM python:3.13-alpine
ENV \ ENV \
UV_SYSTEM_PYTHON=true \ UV_SYSTEM_PYTHON=true \
@ -23,7 +23,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.5.0,source=/uv,target=/bin/uv \
-c /usr/src/homeassistant/homeassistant/package_constraints.txt \ -c /usr/src/homeassistant/homeassistant/package_constraints.txt \
-r /usr/src/homeassistant/requirements.txt \ -r /usr/src/homeassistant/requirements.txt \
stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.3 \ stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.3 \
PyTurboJPEG==1.7.5 ha-ffmpeg==3.2.2 hassil==1.7.4 home-assistant-intents==2024.11.6 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2 PyTurboJPEG==1.7.5 go2rtc-client==0.1.1 ha-ffmpeg==3.2.2 hassil==2.0.1 home-assistant-intents==2024.11.13 mutagen==1.47.0 pymicro-vad==1.0.1 pyspeex-noise==1.0.2
LABEL "name"="hassfest" LABEL "name"="hassfest"
LABEL "maintainer"="Home Assistant <hello@home-assistant.io>" LABEL "maintainer"="Home Assistant <hello@home-assistant.io>"

View file

@ -0,0 +1,14 @@
"""Common test tools for the acaia integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Set up the acaia integration for testing."""
mock_config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()

View file

@ -0,0 +1,80 @@
"""Common fixtures for the acaia tests."""
from collections.abc import Generator
from unittest.mock import AsyncMock, MagicMock, patch
from aioacaia.acaiascale import AcaiaDeviceState
from aioacaia.const import UnitMass as AcaiaUnitOfMass
import pytest
from homeassistant.components.acaia.const import CONF_IS_NEW_STYLE_SCALE, DOMAIN
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from . import setup_integration
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.acaia.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_verify() -> Generator[AsyncMock]:
"""Override is_new_scale check."""
with patch(
"homeassistant.components.acaia.config_flow.is_new_scale", return_value=True
) as mock_verify:
yield mock_verify
@pytest.fixture
def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
title="LUNAR-DDEEFF",
domain=DOMAIN,
version=1,
data={
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
CONF_IS_NEW_STYLE_SCALE: True,
},
unique_id="aa:bb:cc:dd:ee:ff",
)
@pytest.fixture
async def init_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_scale: MagicMock
) -> None:
"""Set up the acaia integration for testing."""
await setup_integration(hass, mock_config_entry)
@pytest.fixture
def mock_scale() -> Generator[MagicMock]:
"""Return a mocked acaia scale client."""
with (
patch(
"homeassistant.components.acaia.coordinator.AcaiaScale",
autospec=True,
) as scale_mock,
):
scale = scale_mock.return_value
scale.connected = True
scale.mac = "aa:bb:cc:dd:ee:ff"
scale.model = "Lunar"
scale.timer_running = True
scale.heartbeat_task = None
scale.process_queue_task = None
scale.device_state = AcaiaDeviceState(
battery_level=42, units=AcaiaUnitOfMass.GRAMS
)
scale.weight = 123.45
yield scale

View file

@ -0,0 +1,139 @@
# serializer version: 1
# name: test_buttons[button.lunar_ddeeff_reset_timer-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.lunar_ddeeff_reset_timer',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Reset timer',
'platform': 'acaia',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'reset_timer',
'unique_id': 'aa:bb:cc:dd:ee:ff_reset_timer',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[button.lunar_ddeeff_reset_timer-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'LUNAR-DDEEFF Reset timer',
}),
'context': <ANY>,
'entity_id': 'button.lunar_ddeeff_reset_timer',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.lunar_ddeeff_start_stop_timer-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.lunar_ddeeff_start_stop_timer',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Start/stop timer',
'platform': 'acaia',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'start_stop',
'unique_id': 'aa:bb:cc:dd:ee:ff_start_stop',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[button.lunar_ddeeff_start_stop_timer-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'LUNAR-DDEEFF Start/stop timer',
}),
'context': <ANY>,
'entity_id': 'button.lunar_ddeeff_start_stop_timer',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_buttons[button.lunar_ddeeff_tare-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'button',
'entity_category': None,
'entity_id': 'button.lunar_ddeeff_tare',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Tare',
'platform': 'acaia',
'previous_unique_id': None,
'supported_features': 0,
'translation_key': 'tare',
'unique_id': 'aa:bb:cc:dd:ee:ff_tare',
'unit_of_measurement': None,
})
# ---
# name: test_buttons[button.lunar_ddeeff_tare-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'LUNAR-DDEEFF Tare',
}),
'context': <ANY>,
'entity_id': 'button.lunar_ddeeff_tare',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---

View file

@ -0,0 +1,33 @@
# serializer version: 1
# name: test_device
DeviceRegistryEntrySnapshot({
'area_id': 'kitchen',
'config_entries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'acaia',
'aa:bb:cc:dd:ee:ff',
),
}),
'is_new': False,
'labels': set({
}),
'manufacturer': 'Acaia',
'model': 'Lunar',
'model_id': None,
'name': 'LUNAR-DDEEFF',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'suggested_area': 'Kitchen',
'sw_version': None,
'via_device_id': None,
})
# ---

View file

@ -0,0 +1,90 @@
"""Tests for the acaia buttons."""
from datetime import timedelta
from unittest.mock import MagicMock, patch
from freezegun.api import FrozenDateTimeFactory
from syrupy import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import (
ATTR_ENTITY_ID,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
BUTTONS = (
"tare",
"reset_timer",
"start_stop_timer",
)
async def test_buttons(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_scale: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the acaia buttons."""
with patch("homeassistant.components.acaia.PLATFORMS", [Platform.BUTTON]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_button_presses(
hass: HomeAssistant,
mock_scale: MagicMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the acaia button presses."""
await setup_integration(hass, mock_config_entry)
for button in BUTTONS:
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{
ATTR_ENTITY_ID: f"button.lunar_ddeeff_{button}",
},
blocking=True,
)
function = getattr(mock_scale, button)
function.assert_called_once()
async def test_buttons_unavailable_on_disconnected_scale(
hass: HomeAssistant,
mock_scale: MagicMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the acaia buttons are unavailable when the scale is disconnected."""
await setup_integration(hass, mock_config_entry)
for button in BUTTONS:
state = hass.states.get(f"button.lunar_ddeeff_{button}")
assert state
assert state.state == STATE_UNKNOWN
mock_scale.connected = False
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
for button in BUTTONS:
state = hass.states.get(f"button.lunar_ddeeff_{button}")
assert state
assert state.state == STATE_UNAVAILABLE

View file

@ -0,0 +1,242 @@
"""Test the acaia config flow."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError, AcaiaUnknownDevice
import pytest
from homeassistant.components.acaia.const import CONF_IS_NEW_STYLE_SCALE, DOMAIN
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER
from homeassistant.const import CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo
from tests.common import MockConfigEntry
service_info = BluetoothServiceInfo(
name="LUNAR-DDEEFF",
address="aa:bb:cc:dd:ee:ff",
rssi=-63,
manufacturer_data={},
service_data={},
service_uuids=[],
source="local",
)
@pytest.fixture
def mock_discovered_service_info() -> Generator[AsyncMock]:
"""Override getting Bluetooth service info."""
with patch(
"homeassistant.components.acaia.config_flow.async_discovered_service_info",
return_value=[service_info],
) as mock_discovered_service_info:
yield mock_discovered_service_info
async def test_form(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_verify: AsyncMock,
mock_discovered_service_info: AsyncMock,
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
user_input = {
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=user_input,
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "LUNAR-DDEEFF"
assert result2["data"] == {
**user_input,
CONF_IS_NEW_STYLE_SCALE: True,
}
async def test_bluetooth_discovery(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_verify: AsyncMock,
) -> None:
"""Test we can discover a device."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "bluetooth_confirm"
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={},
)
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == service_info.name
assert result2["data"] == {
CONF_ADDRESS: service_info.address,
CONF_IS_NEW_STYLE_SCALE: True,
}
@pytest.mark.parametrize(
("exception", "error"),
[
(AcaiaDeviceNotFound("Error"), "device_not_found"),
(AcaiaError, "unknown"),
(AcaiaUnknownDevice, "unsupported_device"),
],
)
async def test_bluetooth_discovery_errors(
hass: HomeAssistant,
mock_verify: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test abortions of Bluetooth discovery."""
mock_verify.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == error
async def test_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_verify: AsyncMock,
mock_discovered_service_info: AsyncMock,
) -> None:
"""Ensure we can't add the same device twice."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured"
async def test_already_configured_bluetooth_discovery(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Ensure configure device is not discovered again."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_BLUETOOTH}, data=service_info
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("exception", "error"),
[
(AcaiaDeviceNotFound("Error"), "device_not_found"),
(AcaiaError, "unknown"),
],
)
async def test_recoverable_config_flow_errors(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_verify: AsyncMock,
mock_discovered_service_info: AsyncMock,
exception: Exception,
error: str,
) -> None:
"""Test recoverable errors."""
mock_verify.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
},
)
assert result2["type"] is FlowResultType.FORM
assert result2["errors"] == {"base": error}
# recover
mock_verify.side_effect = None
result3 = await hass.config_entries.flow.async_configure(
result2["flow_id"],
{
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
},
)
assert result3["type"] is FlowResultType.CREATE_ENTRY
async def test_unsupported_device(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_verify: AsyncMock,
mock_discovered_service_info: AsyncMock,
) -> None:
"""Test flow aborts on unsupported device."""
mock_verify.side_effect = AcaiaUnknownDevice
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_ADDRESS: "aa:bb:cc:dd:ee:ff",
},
)
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "unsupported_device"
async def test_no_bluetooth_devices(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_discovered_service_info: AsyncMock,
) -> None:
"""Test flow aborts on unsupported device."""
mock_discovered_service_info.return_value = []
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_devices_found"

View file

@ -0,0 +1,65 @@
"""Test init of acaia integration."""
from datetime import timedelta
from unittest.mock import MagicMock
from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy import SnapshotAssertion
from homeassistant.components.acaia.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry, async_fire_time_changed
pytestmark = pytest.mark.usefixtures("init_integration")
async def test_load_unload_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test loading and unloading the integration."""
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
"exception", [AcaiaError, AcaiaDeviceNotFound("Boom"), TimeoutError]
)
async def test_update_exception_leads_to_active_disconnect(
hass: HomeAssistant,
mock_scale: MagicMock,
freezer: FrozenDateTimeFactory,
exception: Exception,
) -> None:
"""Test scale gets disconnected on exception."""
mock_scale.connect.side_effect = exception
mock_scale.connected = False
freezer.tick(timedelta(minutes=10))
async_fire_time_changed(hass)
await hass.async_block_till_done()
mock_scale.device_disconnected_handler.assert_called_once()
async def test_device(
mock_scale: MagicMock,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot the device from registry."""
device = device_registry.async_get_device({(DOMAIN, mock_scale.mac)})
assert device
assert device == snapshot

View file

@ -697,7 +697,7 @@
'speech': dict({ 'speech': dict({
'plain': dict({ 'plain': dict({
'extra_data': None, 'extra_data': None,
'speech': 'Sorry, I am not aware of any area called are', 'speech': 'Sorry, I am not aware of any area called Are',
}), }),
}), }),
}), }),
@ -741,7 +741,7 @@
'speech': dict({ 'speech': dict({
'plain': dict({ 'plain': dict({
'extra_data': None, 'extra_data': None,
'speech': 'Sorry, I am not aware of any area called are', 'speech': 'Sorry, I am not aware of any area called Are',
}), }),
}), }),
}), }),

View file

@ -139,42 +139,46 @@ async def init_test_integration(
return test_camera return test_camera
@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") @pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_async_register_webrtc_provider( async def test_async_register_webrtc_provider(
hass: HomeAssistant, hass: HomeAssistant,
) -> None: ) -> None:
"""Test registering a WebRTC provider.""" """Test registering a WebRTC provider."""
await async_setup_component(hass, "camera", {})
camera = get_camera_from_entity_id(hass, "camera.demo_camera") camera = get_camera_from_entity_id(hass, "camera.demo_camera")
assert camera.frontend_stream_type is StreamType.HLS assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS}
provider = SomeTestProvider() provider = SomeTestProvider()
unregister = async_register_webrtc_provider(hass, provider) unregister = async_register_webrtc_provider(hass, provider)
await hass.async_block_till_done() await hass.async_block_till_done()
assert camera.frontend_stream_type is StreamType.WEB_RTC assert camera.camera_capabilities.frontend_stream_types == {
StreamType.HLS,
StreamType.WEB_RTC,
}
# Mark stream as unsupported # Mark stream as unsupported
provider._is_supported = False provider._is_supported = False
# Manually refresh the provider # Manually refresh the provider
await camera.async_refresh_providers() await camera.async_refresh_providers()
assert camera.frontend_stream_type is StreamType.HLS assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS}
# Mark stream as supported # Mark stream as supported
provider._is_supported = True provider._is_supported = True
# Manually refresh the provider # Manually refresh the provider
await camera.async_refresh_providers() await camera.async_refresh_providers()
assert camera.frontend_stream_type is StreamType.WEB_RTC assert camera.camera_capabilities.frontend_stream_types == {
StreamType.HLS,
StreamType.WEB_RTC,
}
unregister() unregister()
await hass.async_block_till_done() await hass.async_block_till_done()
assert camera.frontend_stream_type is StreamType.HLS assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS}
@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") @pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_async_register_webrtc_provider_twice( async def test_async_register_webrtc_provider_twice(
hass: HomeAssistant, hass: HomeAssistant,
register_test_provider: SomeTestProvider, register_test_provider: SomeTestProvider,
@ -192,13 +196,11 @@ async def test_async_register_webrtc_provider_camera_not_loaded(
async_register_webrtc_provider(hass, SomeTestProvider()) async_register_webrtc_provider(hass, SomeTestProvider())
@pytest.mark.usefixtures("mock_camera", "mock_stream", "mock_stream_source") @pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_async_register_ice_server( async def test_async_register_ice_server(
hass: HomeAssistant, hass: HomeAssistant,
) -> None: ) -> None:
"""Test registering an ICE server.""" """Test registering an ICE server."""
await async_setup_component(hass, "camera", {})
# Clear any existing ICE servers # Clear any existing ICE servers
hass.data[DATA_ICE_SERVERS].clear() hass.data[DATA_ICE_SERVERS].clear()
@ -216,7 +218,7 @@ async def test_async_register_ice_server(
unregister = async_register_ice_servers(hass, get_ice_servers) unregister = async_register_ice_servers(hass, get_ice_servers)
assert not called assert not called
camera = get_camera_from_entity_id(hass, "camera.demo_camera") camera = get_camera_from_entity_id(hass, "camera.async")
config = camera.async_get_webrtc_client_configuration() config = camera.async_get_webrtc_client_configuration()
assert config.configuration.ice_servers == [ assert config.configuration.ice_servers == [
@ -277,7 +279,7 @@ async def test_async_register_ice_server(
assert config.configuration.ice_servers == [] assert config.configuration.ice_servers == []
@pytest.mark.usefixtures("mock_camera_webrtc") @pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_get_client_config( async def test_ws_get_client_config(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None: ) -> None:
@ -286,7 +288,7 @@ async def test_ws_get_client_config(
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
await client.send_json_auto_id( await client.send_json_auto_id(
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"} {"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"}
) )
msg = await client.receive_json() msg = await client.receive_json()
@ -320,7 +322,7 @@ async def test_ws_get_client_config(
async_register_ice_servers(hass, get_ice_server) async_register_ice_servers(hass, get_ice_server)
await client.send_json_auto_id( await client.send_json_auto_id(
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"} {"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"}
) )
msg = await client.receive_json() msg = await client.receive_json()
@ -370,7 +372,7 @@ async def test_ws_get_client_config_sync_offer(
} }
@pytest.mark.usefixtures("mock_camera_webrtc") @pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_get_client_config_custom_config( async def test_ws_get_client_config_custom_config(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None: ) -> None:
@ -384,7 +386,7 @@ async def test_ws_get_client_config_custom_config(
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
await client.send_json_auto_id( await client.send_json_auto_id(
{"type": "camera/webrtc/get_client_config", "entity_id": "camera.demo_camera"} {"type": "camera/webrtc/get_client_config", "entity_id": "camera.async"}
) )
msg = await client.receive_json() msg = await client.receive_json()
@ -435,7 +437,7 @@ def mock_rtsp_to_webrtc_fixture(hass: HomeAssistant) -> Generator[Mock]:
unsub() unsub()
@pytest.mark.usefixtures("mock_camera_webrtc") @pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_websocket_webrtc_offer( async def test_websocket_webrtc_offer(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None: ) -> None:
@ -444,7 +446,7 @@ async def test_websocket_webrtc_offer(
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
"type": "camera/webrtc/offer", "type": "camera/webrtc/offer",
"entity_id": "camera.demo_camera", "entity_id": "camera.async",
"offer": WEBRTC_OFFER, "offer": WEBRTC_OFFER,
} }
) )
@ -555,11 +557,11 @@ async def test_websocket_webrtc_offer_webrtc_provider(
mock_async_close_session.assert_called_once_with(session_id) mock_async_close_session.assert_called_once_with(session_id)
@pytest.mark.usefixtures("mock_camera_webrtc")
async def test_websocket_webrtc_offer_invalid_entity( async def test_websocket_webrtc_offer_invalid_entity(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None: ) -> None:
"""Test WebRTC with a camera entity that does not exist.""" """Test WebRTC with a camera entity that does not exist."""
await async_setup_component(hass, "camera", {})
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
@ -578,7 +580,7 @@ async def test_websocket_webrtc_offer_invalid_entity(
} }
@pytest.mark.usefixtures("mock_camera_webrtc") @pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_websocket_webrtc_offer_missing_offer( async def test_websocket_webrtc_offer_missing_offer(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None: ) -> None:
@ -605,7 +607,6 @@ async def test_websocket_webrtc_offer_missing_offer(
(TimeoutError(), "Timeout handling WebRTC offer"), (TimeoutError(), "Timeout handling WebRTC offer"),
], ],
) )
@pytest.mark.usefixtures("mock_camera_webrtc_frontendtype_only")
async def test_websocket_webrtc_offer_failure( async def test_websocket_webrtc_offer_failure(
hass: HomeAssistant, hass: HomeAssistant,
hass_ws_client: WebSocketGenerator, hass_ws_client: WebSocketGenerator,
@ -949,7 +950,7 @@ async def test_rtsp_to_webrtc_offer_not_accepted(
unsub() unsub()
@pytest.mark.usefixtures("mock_camera_webrtc") @pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_webrtc_candidate( async def test_ws_webrtc_candidate(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None: ) -> None:
@ -957,13 +958,13 @@ async def test_ws_webrtc_candidate(
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
session_id = "session_id" session_id = "session_id"
candidate = "candidate" candidate = "candidate"
with patch( with patch.object(
"homeassistant.components.camera.Camera.async_on_webrtc_candidate" get_camera_from_entity_id(hass, "camera.async"), "async_on_webrtc_candidate"
) as mock_on_webrtc_candidate: ) as mock_on_webrtc_candidate:
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
"type": "camera/webrtc/candidate", "type": "camera/webrtc/candidate",
"entity_id": "camera.demo_camera", "entity_id": "camera.async",
"session_id": session_id, "session_id": session_id,
"candidate": candidate, "candidate": candidate,
} }
@ -976,7 +977,7 @@ async def test_ws_webrtc_candidate(
) )
@pytest.mark.usefixtures("mock_camera_webrtc") @pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_webrtc_candidate_not_supported( async def test_ws_webrtc_candidate_not_supported(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None: ) -> None:
@ -985,7 +986,7 @@ async def test_ws_webrtc_candidate_not_supported(
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
"type": "camera/webrtc/candidate", "type": "camera/webrtc/candidate",
"entity_id": "camera.demo_camera", "entity_id": "camera.sync",
"session_id": "session_id", "session_id": "session_id",
"candidate": "candidate", "candidate": "candidate",
} }
@ -1028,11 +1029,11 @@ async def test_ws_webrtc_candidate_webrtc_provider(
) )
@pytest.mark.usefixtures("mock_camera_webrtc")
async def test_ws_webrtc_candidate_invalid_entity( async def test_ws_webrtc_candidate_invalid_entity(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None: ) -> None:
"""Test ws WebRTC candidate command with a camera entity that does not exist.""" """Test ws WebRTC candidate command with a camera entity that does not exist."""
await async_setup_component(hass, "camera", {})
client = await hass_ws_client(hass) client = await hass_ws_client(hass)
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
@ -1052,7 +1053,7 @@ async def test_ws_webrtc_candidate_invalid_entity(
} }
@pytest.mark.usefixtures("mock_camera_webrtc") @pytest.mark.usefixtures("mock_test_webrtc_cameras")
async def test_ws_webrtc_canidate_missing_candidate( async def test_ws_webrtc_canidate_missing_candidate(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None: ) -> None:
@ -1061,7 +1062,7 @@ async def test_ws_webrtc_canidate_missing_candidate(
await client.send_json_auto_id( await client.send_json_auto_id(
{ {
"type": "camera/webrtc/candidate", "type": "camera/webrtc/candidate",
"entity_id": "camera.demo_camera", "entity_id": "camera.async",
"session_id": "session_id", "session_id": "session_id",
} }
) )

View file

@ -26,7 +26,12 @@ from homeassistant.config_entries import (
) )
from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowHandler, FlowManager, FlowResultType from homeassistant.data_entry_flow import (
FlowContext,
FlowHandler,
FlowManager,
FlowResultType,
)
from homeassistant.helpers.translation import async_get_translations from homeassistant.helpers.translation import async_get_translations
if TYPE_CHECKING: if TYPE_CHECKING:
@ -557,12 +562,12 @@ def _validate_translation_placeholders(
description_placeholders is None description_placeholders is None
or placeholder not in description_placeholders or placeholder not in description_placeholders
): ):
pytest.fail( ignore_translations[full_key] = (
f"Description not found for placeholder `{placeholder}` in {full_key}" f"Description not found for placeholder `{placeholder}` in {full_key}"
) )
async def _ensure_translation_exists( async def _validate_translation(
hass: HomeAssistant, hass: HomeAssistant,
ignore_translations: dict[str, StoreInfo], ignore_translations: dict[str, StoreInfo],
category: str, category: str,
@ -588,7 +593,7 @@ async def _ensure_translation_exists(
ignore_translations[full_key] = "used" ignore_translations[full_key] = "used"
return return
pytest.fail( ignore_translations[full_key] = (
f"Translation not found for {component}: `{category}.{key}`. " f"Translation not found for {component}: `{category}.{key}`. "
f"Please add to homeassistant/components/{component}/strings.json" f"Please add to homeassistant/components/{component}/strings.json"
) )
@ -604,27 +609,20 @@ def ignore_translations() -> str | list[str]:
return [] return []
@pytest.fixture(autouse=True) async def _check_config_flow_result_translations(
def check_config_translations(ignore_translations: str | list[str]) -> Generator[None]: manager: FlowManager,
"""Ensure config_flow translations are available.""" flow: FlowHandler,
if not isinstance(ignore_translations, list): result: FlowResult[FlowContext, str],
ignore_translations = [ignore_translations] ignore_translations: dict[str, str],
) -> None:
_ignore_translations = {k: "unused" for k in ignore_translations} if isinstance(manager, ConfigEntriesFlowManager):
_original = FlowManager._async_handle_step
async def _async_handle_step(
self: FlowManager, flow: FlowHandler, *args
) -> FlowResult:
result = await _original(self, flow, *args)
if isinstance(self, ConfigEntriesFlowManager):
category = "config" category = "config"
component = flow.handler integration = flow.handler
elif isinstance(self, OptionsFlowManager): elif isinstance(manager, OptionsFlowManager):
category = "options" category = "options"
component = flow.hass.config_entries.async_get_entry(flow.handler).domain integration = flow.hass.config_entries.async_get_entry(flow.handler).domain
else: else:
return result return
# Check if this flow has been seen before # Check if this flow has been seen before
# Gets set to False on first run, and to True on subsequent runs # Gets set to False on first run, and to True on subsequent runs
@ -636,52 +634,81 @@ def check_config_translations(ignore_translations: str | list[str]) -> Generator
# - title defaults to integration name # - title defaults to integration name
# - description is optional # - description is optional
for header in ("title", "description"): for header in ("title", "description"):
await _ensure_translation_exists( await _validate_translation(
flow.hass, flow.hass,
_ignore_translations, ignore_translations,
category, category,
component, integration,
f"step.{step_id}.{header}", f"step.{step_id}.{header}",
result["description_placeholders"], result["description_placeholders"],
translation_required=False, translation_required=False,
) )
if errors := result.get("errors"): if errors := result.get("errors"):
for error in errors.values(): for error in errors.values():
await _ensure_translation_exists( await _validate_translation(
flow.hass, flow.hass,
_ignore_translations, ignore_translations,
category, category,
component, integration,
f"error.{error}", f"error.{error}",
result["description_placeholders"], result["description_placeholders"],
) )
return result return
if result["type"] is FlowResultType.ABORT: if result["type"] is FlowResultType.ABORT:
# We don't need translations for a discovery flow which immediately # We don't need translations for a discovery flow which immediately
# aborts, since such flows won't be seen by users # aborts, since such flows won't be seen by users
if not flow.__flow_seen_before and flow.source in DISCOVERY_SOURCES: if not flow.__flow_seen_before and flow.source in DISCOVERY_SOURCES:
return result return
await _ensure_translation_exists( await _validate_translation(
flow.hass, flow.hass,
_ignore_translations, ignore_translations,
category, category,
component, integration,
f"abort.{result["reason"]}", f"abort.{result["reason"]}",
result["description_placeholders"], result["description_placeholders"],
) )
@pytest.fixture(autouse=True)
def check_translations(ignore_translations: str | list[str]) -> Generator[None]:
"""Check that translation requirements are met.
Current checks:
- data entry flow results (ConfigFlow/OptionsFlow)
"""
if not isinstance(ignore_translations, list):
ignore_translations = [ignore_translations]
_ignore_translations = {k: "unused" for k in ignore_translations}
# Keep reference to original functions
_original_flow_manager_async_handle_step = FlowManager._async_handle_step
# Prepare override functions
async def _flow_manager_async_handle_step(
self: FlowManager, flow: FlowHandler, *args
) -> FlowResult:
result = await _original_flow_manager_async_handle_step(self, flow, *args)
await _check_config_flow_result_translations(
self, flow, result, _ignore_translations
)
return result return result
# Use override functions
with patch( with patch(
"homeassistant.data_entry_flow.FlowManager._async_handle_step", "homeassistant.data_entry_flow.FlowManager._async_handle_step",
_async_handle_step, _flow_manager_async_handle_step,
): ):
yield yield
# Run final checks
unused_ignore = [k for k, v in _ignore_translations.items() if v == "unused"] unused_ignore = [k for k, v in _ignore_translations.items() if v == "unused"]
if unused_ignore: if unused_ignore:
pytest.fail( pytest.fail(
f"Unused ignore translations: {', '.join(unused_ignore)}. " f"Unused ignore translations: {', '.join(unused_ignore)}. "
"Please remove them from the ignore_translations fixture." "Please remove them from the ignore_translations fixture."
) )
for description in _ignore_translations.values():
if description not in {"used", "unused"}:
pytest.fail(description)

View file

@ -639,7 +639,7 @@
'details': dict({ 'details': dict({
'brightness': dict({ 'brightness': dict({
'name': 'brightness', 'name': 'brightness',
'text': '100%', 'text': '100',
'value': 100, 'value': 100,
}), }),
'name': dict({ 'name': dict({
@ -654,7 +654,7 @@
'match': True, 'match': True,
'sentence_template': '[<numeric_value_set>] <name> brightness [to] <brightness>', 'sentence_template': '[<numeric_value_set>] <name> brightness [to] <brightness>',
'slots': dict({ 'slots': dict({
'brightness': '100%', 'brightness': '100',
'name': 'test light', 'name': 'test light',
}), }),
'source': 'builtin', 'source': 'builtin',

View file

@ -770,8 +770,8 @@ async def test_error_no_device_on_floor_exposed(
) )
with patch( with patch(
"homeassistant.components.conversation.default_agent.recognize_all", "homeassistant.components.conversation.default_agent.recognize_best",
return_value=[recognize_result], return_value=recognize_result,
): ):
result = await conversation.async_converse( result = await conversation.async_converse(
hass, "turn on test light on the ground floor", None, Context(), None hass, "turn on test light on the ground floor", None, Context(), None
@ -838,8 +838,8 @@ async def test_error_no_domain(hass: HomeAssistant) -> None:
) )
with patch( with patch(
"homeassistant.components.conversation.default_agent.recognize_all", "homeassistant.components.conversation.default_agent.recognize_best",
return_value=[recognize_result], return_value=recognize_result,
): ):
result = await conversation.async_converse( result = await conversation.async_converse(
hass, "turn on the fans", None, Context(), None hass, "turn on the fans", None, Context(), None
@ -873,8 +873,8 @@ async def test_error_no_domain_exposed(hass: HomeAssistant) -> None:
) )
with patch( with patch(
"homeassistant.components.conversation.default_agent.recognize_all", "homeassistant.components.conversation.default_agent.recognize_best",
return_value=[recognize_result], return_value=recognize_result,
): ):
result = await conversation.async_converse( result = await conversation.async_converse(
hass, "turn on the fans", None, Context(), None hass, "turn on the fans", None, Context(), None
@ -1047,8 +1047,8 @@ async def test_error_no_device_class(hass: HomeAssistant) -> None:
) )
with patch( with patch(
"homeassistant.components.conversation.default_agent.recognize_all", "homeassistant.components.conversation.default_agent.recognize_best",
return_value=[recognize_result], return_value=recognize_result,
): ):
result = await conversation.async_converse( result = await conversation.async_converse(
hass, "open the windows", None, Context(), None hass, "open the windows", None, Context(), None
@ -1096,8 +1096,8 @@ async def test_error_no_device_class_exposed(hass: HomeAssistant) -> None:
) )
with patch( with patch(
"homeassistant.components.conversation.default_agent.recognize_all", "homeassistant.components.conversation.default_agent.recognize_best",
return_value=[recognize_result], return_value=recognize_result,
): ):
result = await conversation.async_converse( result = await conversation.async_converse(
hass, "open all the windows", None, Context(), None hass, "open all the windows", None, Context(), None
@ -1207,8 +1207,8 @@ async def test_error_no_device_class_on_floor_exposed(
) )
with patch( with patch(
"homeassistant.components.conversation.default_agent.recognize_all", "homeassistant.components.conversation.default_agent.recognize_best",
return_value=[recognize_result], return_value=recognize_result,
): ):
result = await conversation.async_converse( result = await conversation.async_converse(
hass, "open ground floor windows", None, Context(), None hass, "open ground floor windows", None, Context(), None
@ -1229,8 +1229,8 @@ async def test_error_no_device_class_on_floor_exposed(
async def test_error_no_intent(hass: HomeAssistant) -> None: async def test_error_no_intent(hass: HomeAssistant) -> None:
"""Test response with an intent match failure.""" """Test response with an intent match failure."""
with patch( with patch(
"homeassistant.components.conversation.default_agent.recognize_all", "homeassistant.components.conversation.default_agent.recognize_best",
return_value=[], return_value=None,
): ):
result = await conversation.async_converse( result = await conversation.async_converse(
hass, "do something", None, Context(), None hass, "do something", None, Context(), None

View file

@ -56,7 +56,7 @@ async def test_converation_trace(
"intent_name": "HassListAddItem", "intent_name": "HassListAddItem",
"slots": { "slots": {
"name": "Shopping List", "name": "Shopping List",
"item": "apples ", "item": "apples",
}, },
} }

View file

@ -1,21 +0,0 @@
"""Tests for the dhcp integration."""
import os
import pathlib
def pytest_sessionstart(session):
"""Try to avoid flaky FileExistsError in CI.
Called after the Session object has been created and
before performing collection and entering the run test loop.
This is needed due to a race condition in scapy v2.6.0
See https://github.com/secdev/scapy/pull/4558
Can be removed when scapy 2.6.1 is released.
"""
for sub_dir in (".cache", ".config"):
path = pathlib.Path(os.path.join(os.path.expanduser("~"), sub_dir))
if not path.exists():
path.mkdir(mode=0o700, exist_ok=True)

View file

@ -100,10 +100,6 @@ async def test_form_invalid_auth(
assert result["errors"] == {"base": "invalid_auth"} assert result["errors"] == {"base": "invalid_auth"}
@pytest.mark.parametrize( # Remove when translations fixed
"ignore_translations",
["component.srp_energy.config.abort.unknown"],
)
async def test_form_unknown_error( async def test_form_unknown_error(
hass: HomeAssistant, hass: HomeAssistant,
mock_srp_energy_config_flow: MagicMock, mock_srp_energy_config_flow: MagicMock,

View file

@ -192,10 +192,6 @@ async def test_two_factor_request_success(
assert len(mock_two_factor_request.mock_calls) == 1 assert len(mock_two_factor_request.mock_calls) == 1
@pytest.mark.parametrize( # Remove when translations fixed
"ignore_translations",
["component.subaru.config.abort.two_factor_request_failed"],
)
async def test_two_factor_request_fail( async def test_two_factor_request_fail(
hass: HomeAssistant, two_factor_start_form hass: HomeAssistant, two_factor_start_form
) -> None: ) -> None:

View file

@ -21,7 +21,7 @@ import zigpy.types
from homeassistant import config_entries from homeassistant import config_entries
from homeassistant.components import ssdp, usb, zeroconf from homeassistant.components import ssdp, usb, zeroconf
from homeassistant.components.hassio import AddonState from homeassistant.components.hassio import AddonError, AddonState
from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL from homeassistant.components.ssdp import ATTR_UPNP_MANUFACTURER_URL, ATTR_UPNP_SERIAL
from homeassistant.components.zha import config_flow, radio_manager from homeassistant.components.zha import config_flow, radio_manager
from homeassistant.components.zha.const import ( from homeassistant.components.zha.const import (
@ -1878,10 +1878,23 @@ async def test_config_flow_port_yellow_port_name(hass: HomeAssistant) -> None:
) )
async def test_config_flow_ports_no_hassio(hass: HomeAssistant) -> None:
"""Test config flow serial port name when this is not a hassio install."""
with (
patch("homeassistant.components.zha.config_flow.is_hassio", return_value=False),
patch("serial.tools.list_ports.comports", MagicMock(return_value=[])),
):
ports = await config_flow.list_serial_ports(hass)
assert ports == []
async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> None: async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) -> None:
"""Test config flow serial port name for multiprotocol add-on.""" """Test config flow serial port name for multiprotocol add-on."""
with ( with (
patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True),
patch( patch(
"homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info" "homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info"
) as async_get_addon_info, ) as async_get_addon_info,
@ -1889,16 +1902,28 @@ async def test_config_flow_port_multiprotocol_port_name(hass: HomeAssistant) ->
): ):
async_get_addon_info.return_value.state = AddonState.RUNNING async_get_addon_info.return_value.state = AddonState.RUNNING
async_get_addon_info.return_value.hostname = "core-silabs-multiprotocol" async_get_addon_info.return_value.hostname = "core-silabs-multiprotocol"
ports = await config_flow.list_serial_ports(hass)
result = await hass.config_entries.flow.async_init( assert len(ports) == 1
DOMAIN, assert ports[0].description == "Multiprotocol add-on"
context={CONF_SOURCE: SOURCE_USER}, assert ports[0].manufacturer == "Nabu Casa"
) assert ports[0].device == "socket://core-silabs-multiprotocol:9999"
assert (
result["data_schema"].schema["path"].container[0] async def test_config_flow_port_no_multiprotocol(hass: HomeAssistant) -> None:
== "socket://core-silabs-multiprotocol:9999 - Multiprotocol add-on - Nabu Casa" """Test config flow serial port listing when addon info fails to load."""
)
with (
patch("homeassistant.components.zha.config_flow.is_hassio", return_value=True),
patch(
"homeassistant.components.hassio.addon_manager.AddonManager.async_get_addon_info",
side_effect=AddonError,
),
patch("serial.tools.list_ports.comports", MagicMock(return_value=[])),
):
ports = await config_flow.list_serial_ports(hass)
assert ports == []
@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()])) @patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()]))