Compare commits

..

2 commits

Author SHA1 Message Date
Robert Resch
6366864cd2
Fix nest test 2024-11-14 13:48:24 +01:00
Robert Resch
e4b66013c9
Use camera_capabilities instead frontend_stream_type 2024-11-14 10:46:02 +01:00
45 changed files with 113 additions and 1535 deletions

View file

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

View file

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

View file

@ -1,29 +0,0 @@
"""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

@ -1,61 +0,0 @@
"""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

@ -1,149 +0,0 @@
"""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

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

View file

@ -1,86 +0,0 @@
"""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

@ -1,40 +0,0 @@
"""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

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

View file

@ -1,29 +0,0 @@
{
"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

@ -1,38 +0,0 @@
{
"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",
"iot_class": "local_polling",
"loggers": ["aioairzone"],
"requirements": ["aioairzone==0.9.6"]
"requirements": ["aioairzone==0.9.5"]
}

View file

@ -896,7 +896,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
else:
frontend_stream_types.add(StreamType.HLS)
if self._webrtc_provider:
if self._webrtc_provider or self._legacy_webrtc_provider:
frontend_stream_types.add(StreamType.WEB_RTC)
return CameraCapabilities(frontend_stream_types)

View file

@ -64,7 +64,7 @@ class CameraMediaSource(MediaSource):
if not camera:
raise Unresolvable(f"Could not resolve media item: {item.identifier}")
if (stream_type := camera.frontend_stream_type) is None:
if not (stream_types := camera.camera_capabilities.frontend_stream_types):
return PlayMedia(
f"/api/camera_proxy_stream/{camera.entity_id}", camera.content_type
)
@ -76,7 +76,7 @@ class CameraMediaSource(MediaSource):
url = await _async_stream_endpoint_url(self.hass, camera, HLS_PROVIDER)
except HomeAssistantError as err:
# Handle known error
if stream_type != StreamType.HLS:
if StreamType.HLS not in stream_types:
raise Unresolvable(
"Camera does not support MJPEG or HLS streaming."
) from err

View file

@ -230,13 +230,15 @@ def require_webrtc_support(
"""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:
if StreamType.WEB_RTC not in (
stream_types := camera.camera_capabilities.frontend_stream_types
):
connection.send_error(
msg["id"],
error_code,
(
"Camera does not support WebRTC,"
f" frontend_stream_type={camera.frontend_stream_type}"
f" frontend_stream_types={stream_types}"
),
)
return

View file

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

View file

@ -24,11 +24,6 @@ ENTITY_KEY_WINDOW = "window"
ENTITY_KEY_LOCK = "lock"
ENTITY_KEY_BOOST = "boost"
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
@ -82,5 +77,3 @@ DEFAULT_SCAN_INTERVAL = 10 # seconds
SIGNAL_THERMOSTAT_DISCONNECTED = f"{DOMAIN}.thermostat_disconnected"
SIGNAL_THERMOSTAT_CONNECTED = f"{DOMAIN}.thermostat_connected"
EQ3BT_STEP = 0.5

View file

@ -8,23 +8,6 @@
}
}
},
"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": {
"away": {
"default": "mdi:home-account",

View file

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

View file

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

View file

@ -1,158 +0,0 @@
"""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,23 +25,6 @@
"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": {
"lock": {
"name": "Lock"

View file

@ -20,8 +20,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.typing import ConfigType
from homeassistant.helpers import device_registry as dr
from .const import (
ADD_ENTITIES_CALLBACKS,
@ -42,26 +41,15 @@ from .helpers import (
register_lcn_address_devices,
register_lcn_host_device,
)
from .services import register_services
from .services import SERVICES
from .websocket import register_panel_and_ws_api
_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:
"""Set up a connection to PCHK host from a config entry."""
hass.data.setdefault(DOMAIN, {})
if config_entry.entry_id in hass.data[DOMAIN]:
return False
@ -121,6 +109,15 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
)
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
@ -171,6 +168,11 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
host = hass.data[DOMAIN].pop(config_entry.entry_id)
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

View file

@ -429,11 +429,3 @@ SERVICES = (
(LcnService.DYN_TEXT, DynText),
(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",
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"requirements": ["reolink-aio==0.11.1"]
"requirements": ["reolink-aio==0.11.0"]
}

View file

@ -28,10 +28,6 @@
"deprecated_yaml_import_issue_auth_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."
},
"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": {

View file

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

View file

@ -8,26 +8,6 @@ from __future__ import annotations
from typing import Final
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",
"manufacturer_id": 820,

View file

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

View file

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

View file

@ -172,9 +172,6 @@ aio-geojson-usgs-earthquakes==0.3
# homeassistant.components.gdacs
aio-georss-gdacs==0.10
# homeassistant.components.acaia
aioacaia==0.1.6
# homeassistant.components.airq
aioairq==0.3.2
@ -182,7 +179,7 @@ aioairq==0.3.2
aioairzone-cloud==0.6.10
# homeassistant.components.airzone
aioairzone==0.9.6
aioairzone==0.9.5
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@ -863,7 +860,7 @@ epion==0.0.3
epson-projector==0.5.1
# homeassistant.components.eq3btsmart
eq3btsmart==1.4.1
eq3btsmart==1.2.1
# homeassistant.components.esphome
esphome-dashboard-api==1.2.3
@ -2556,7 +2553,7 @@ renault-api==0.2.7
renson-endura-delta==1.7.1
# homeassistant.components.reolink
reolink-aio==0.11.1
reolink-aio==0.11.0
# homeassistant.components.idteck_prox
rfk101py==0.0.1

View file

@ -160,9 +160,6 @@ aio-geojson-usgs-earthquakes==0.3
# homeassistant.components.gdacs
aio-georss-gdacs==0.10
# homeassistant.components.acaia
aioacaia==0.1.6
# homeassistant.components.airq
aioairq==0.3.2
@ -170,7 +167,7 @@ aioairq==0.3.2
aioairzone-cloud==0.6.10
# homeassistant.components.airzone
aioairzone==0.9.6
aioairzone==0.9.5
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@ -732,7 +729,7 @@ epion==0.0.3
epson-projector==0.5.1
# homeassistant.components.eq3btsmart
eq3btsmart==1.4.1
eq3btsmart==1.2.1
# homeassistant.components.esphome
esphome-dashboard-api==1.2.3
@ -2047,7 +2044,7 @@ renault-api==0.2.7
renson-endura-delta==1.7.1
# homeassistant.components.reolink
reolink-aio==0.11.1
reolink-aio==0.11.0
# homeassistant.components.rflink
rflink==0.0.66

View file

@ -80,7 +80,7 @@ WORKDIR /config
_HASSFEST_TEMPLATE = r"""# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
FROM python:3.13-alpine
FROM python:3.12-alpine
ENV \
UV_SYSTEM_PYTHON=true \
@ -161,8 +161,6 @@ def _generate_hassfest_dockerimage(
packages.update(
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(
_HASSFEST_TEMPLATE.format(

View file

@ -1,7 +1,7 @@
# Automatically generated by hassfest.
#
# To update, run python3 -m script.hassfest -p docker
FROM python:3.13-alpine
FROM python:3.12-alpine
ENV \
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 \
-r /usr/src/homeassistant/requirements.txt \
stdlib-list==0.10.0 pipdeptree==2.23.4 tqdm==4.66.5 ruff==0.7.3 \
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
PyTurboJPEG==1.7.5 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 "maintainer"="Home Assistant <hello@home-assistant.io>"

View file

@ -1,14 +0,0 @@
"""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

@ -1,80 +0,0 @@
"""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

@ -1,139 +0,0 @@
# 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

@ -1,33 +0,0 @@
# 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

@ -1,90 +0,0 @@
"""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

@ -1,242 +0,0 @@
"""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

@ -1,65 +0,0 @@
"""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

@ -417,7 +417,7 @@ async def test_ws_get_client_config_no_rtc_camera(
assert not msg["success"]
assert msg["error"] == {
"code": "webrtc_get_client_config_failed",
"message": "Camera does not support WebRTC, frontend_stream_type=hls",
"message": "Camera does not support WebRTC, frontend_stream_types={<StreamType.HLS: 'hls'>}",
}
@ -747,7 +747,7 @@ async def test_websocket_webrtc_offer_invalid_stream_type(
assert not response["success"]
assert response["error"] == {
"code": "webrtc_offer_failed",
"message": "Camera does not support WebRTC, frontend_stream_type=hls",
"message": "Camera does not support WebRTC, frontend_stream_types={<StreamType.HLS: 'hls'>}",
}
@ -800,45 +800,6 @@ async def mock_hls_stream_source_fixture() -> AsyncGenerator[AsyncMock]:
yield mock_hls_stream_source
@pytest.mark.usefixtures(
"mock_camera",
"mock_hls_stream_source", # Not an RTSP stream source
"mock_camera_webrtc_frontendtype_only",
)
async def test_unsupported_rtsp_to_webrtc_stream_type(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
) -> None:
"""Test rtsp-to-webrtc is not registered for non-RTSP streams."""
client = await hass_ws_client(hass)
await client.send_json_auto_id(
{
"type": "camera/webrtc/offer",
"entity_id": "camera.demo_camera",
"offer": WEBRTC_OFFER,
}
)
response = await client.receive_json()
assert response["type"] == TYPE_RESULT
assert response["success"]
subscription_id = response["id"]
# Session id
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"]["type"] == "session"
# Answer
response = await client.receive_json()
assert response["id"] == subscription_id
assert response["type"] == "event"
assert response["event"] == {
"type": "error",
"code": "webrtc_offer_failed",
"message": "Camera does not support WebRTC",
}
@pytest.mark.usefixtures("mock_camera", "mock_stream_source")
async def test_rtsp_to_webrtc_provider_unregistered(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator
@ -894,7 +855,7 @@ async def test_rtsp_to_webrtc_provider_unregistered(
assert not response["success"]
assert response["error"] == {
"code": "webrtc_offer_failed",
"message": "Camera does not support WebRTC, frontend_stream_type=hls",
"message": "Camera does not support WebRTC, frontend_stream_types={<StreamType.HLS: 'hls'>}",
}
assert not mock_provider.called
@ -1093,7 +1054,7 @@ async def test_ws_webrtc_candidate_invalid_stream_type(
assert not response["success"]
assert response["error"] == {
"code": "webrtc_candidate_failed",
"message": "Camera does not support WebRTC, frontend_stream_type=hls",
"message": "Camera does not support WebRTC, frontend_stream_types={<StreamType.HLS: 'hls'>}",
}

View file

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

@ -211,7 +211,7 @@ async def _test_setup_and_signaling(
) -> None:
"""Test the go2rtc config entry."""
entity_id = camera.entity_id
assert camera.frontend_stream_type == StreamType.HLS
assert camera.camera_capabilities.frontend_stream_types == {StreamType.HLS}
assert await async_setup_component(hass, DOMAIN, config)
await hass.async_block_till_done(wait_background_tasks=True)

View file

@ -745,7 +745,7 @@ async def test_camera_web_rtc_unsupported(
assert not msg["success"]
assert msg["error"] == {
"code": "webrtc_offer_failed",
"message": "Camera does not support WebRTC, frontend_stream_type=hls",
"message": "Camera does not support WebRTC, frontend_stream_types={<StreamType.HLS: 'hls'>}",
}