Merge branch 'dev' into dev

This commit is contained in:
Jonathan Sady do Nascimento 2025-02-18 18:39:52 -03:00 committed by GitHub
commit ce5dd8c83a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
74 changed files with 878 additions and 155 deletions

View File

@ -234,6 +234,7 @@ homeassistant.components.here_travel_time.*
homeassistant.components.history.*
homeassistant.components.history_stats.*
homeassistant.components.holiday.*
homeassistant.components.home_connect.*
homeassistant.components.homeassistant.*
homeassistant.components.homeassistant_alerts.*
homeassistant.components.homeassistant_green.*

38
Dockerfile generated
View File

@ -12,8 +12,26 @@ ENV \
ARG QEMU_CPU
# Home Assistant S6-Overlay
COPY rootfs /
# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary
RUN \
case "${BUILD_ARCH}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
# Install uv
RUN pip3 install uv==0.6.0
RUN pip3 install uv==0.6.1
WORKDIR /usr/src
@ -42,22 +60,4 @@ RUN \
&& python3 -m compileall \
homeassistant/homeassistant
# Home Assistant S6-Overlay
COPY rootfs /
# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary
RUN \
case "${BUILD_ARCH}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${BUILD_ARCH} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v1.9.8/go2rtc_linux_${go2rtc_suffix} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
WORKDIR /config

View File

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

View File

@ -83,6 +83,7 @@ class AirQConfigFlow(ConfigFlow, domain=DOMAIN):
await self.async_set_unique_id(device_info["id"])
self._abort_if_unique_id_configured()
_LOGGER.debug("Creating an entry for %s", device_info["name"])
return self.async_create_entry(title=device_info["name"], data=user_input)
return self.async_show_form(

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import timedelta
import logging
from aioairq import AirQ
from aioairq.core import AirQ, identify_warming_up_sensors
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
@ -55,6 +55,9 @@ class AirQCoordinator(DataUpdateCoordinator):
async def _async_update_data(self) -> dict:
"""Fetch the data from the device."""
if "name" not in self.device_info:
_LOGGER.debug(
"'name' not found in AirQCoordinator.device_info, fetching from the device"
)
info = await self.airq.fetch_device_info()
self.device_info.update(
DeviceInfo(
@ -64,7 +67,16 @@ class AirQCoordinator(DataUpdateCoordinator):
hw_version=info["hw_version"],
)
)
return await self.airq.get_latest_data( # type: ignore[no-any-return]
_LOGGER.debug(
"Updated AirQCoordinator.device_info for 'name' %s",
self.device_info.get("name"),
)
data: dict = await self.airq.get_latest_data(
return_average=self.return_average,
clip_negative_values=self.clip_negative,
)
if warming_up_sensors := identify_warming_up_sensors(data):
_LOGGER.debug(
"Following sensors are still warming up: %s", warming_up_sensors
)
return data

View File

@ -10,7 +10,7 @@
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"cannot_receive_deviceinfo": "Failed to retreive MAC Address. Make sure the device is turned on"
"cannot_receive_deviceinfo": "Failed to retrieve MAC Address. Make sure the device is turned on"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from aranet4.client import Aranet4Advertisement
from aranet4.client import Aranet4Advertisement, Color
from bleak.backends.device import BLEDevice
from homeassistant.components.bluetooth.passive_update_processor import (
@ -74,6 +74,13 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=UnitOfPressure.HPA,
state_class=SensorStateClass.MEASUREMENT,
),
"status": AranetSensorEntityDescription(
key="threshold",
translation_key="threshold",
name="Threshold",
device_class=SensorDeviceClass.ENUM,
options=[status.name.lower() for status in Color],
),
"co2": AranetSensorEntityDescription(
key="co2",
name="Carbon Dioxide",
@ -161,7 +168,10 @@ def sensor_update_to_bluetooth_data_update(
val = getattr(adv.readings, key)
if val == -1:
continue
val *= desc.scale
if key == "status":
val = val.name.lower()
else:
val *= desc.scale
data[tag] = val
names[tag] = desc.name
descs[tag] = desc

View File

@ -21,5 +21,17 @@
"no_devices_found": "No unconfigured Aranet devices found.",
"outdated_version": "This device is using outdated firmware. Please update it to at least v1.2.0 and try again."
}
},
"entity": {
"sensor": {
"threshold": {
"state": {
"error": "Error",
"green": "Green",
"yellow": "Yellow",
"red": "Red"
}
}
}
}
}

View File

@ -11,7 +11,7 @@
},
"error": {
"cannot_connect": "Unable to connect, please check serial port, address, electrical connection and that inverter is on (in daylight)",
"invalid_serial_port": "Serial port is not a valid device or could not be openned",
"invalid_serial_port": "Serial port is not a valid device or could not be opened",
"cannot_open_serial_port": "Cannot open serial port, please check and try again"
},
"abort": {

View File

@ -3,11 +3,13 @@
from __future__ import annotations
from datetime import timedelta
from typing import Any
from homeassistant.components.humidifier import (
DEFAULT_MAX_HUMIDITY,
DEFAULT_MIN_HUMIDITY,
MODE_AUTO,
HumidifierAction,
HumidifierDeviceClass,
HumidifierEntity,
HumidifierEntityFeature,
@ -41,6 +43,12 @@ async def async_setup_entry(
async_add_entities(entities, True)
ECOBEE_HUMIDIFIER_ACTION_TO_HASS = {
"humidifier": HumidifierAction.HUMIDIFYING,
"dehumidifier": HumidifierAction.DRYING,
}
class EcobeeHumidifier(HumidifierEntity):
"""A humidifier class for an ecobee thermostat with humidifier attached."""
@ -52,7 +60,7 @@ class EcobeeHumidifier(HumidifierEntity):
_attr_has_entity_name = True
_attr_name = None
def __init__(self, data, thermostat_index):
def __init__(self, data, thermostat_index) -> None:
"""Initialize ecobee humidifier platform."""
self.data = data
self.thermostat_index = thermostat_index
@ -80,11 +88,11 @@ class EcobeeHumidifier(HumidifierEntity):
)
@property
def available(self):
def available(self) -> bool:
"""Return if device is available."""
return self.thermostat["runtime"]["connected"]
async def async_update(self):
async def async_update(self) -> None:
"""Get the latest state from the thermostat."""
if self.update_without_throttle:
await self.data.update(no_throttle=True)
@ -96,12 +104,20 @@ class EcobeeHumidifier(HumidifierEntity):
self._last_humidifier_on_mode = self.mode
@property
def is_on(self):
def action(self) -> HumidifierAction:
"""Return the current action."""
for status in self.thermostat["equipmentStatus"].split(","):
if status in ECOBEE_HUMIDIFIER_ACTION_TO_HASS:
return ECOBEE_HUMIDIFIER_ACTION_TO_HASS[status]
return HumidifierAction.IDLE if self.is_on else HumidifierAction.OFF
@property
def is_on(self) -> bool:
"""Return True if the humidifier is on."""
return self.mode != MODE_OFF
@property
def mode(self):
def mode(self) -> str:
"""Return the current mode, e.g., off, auto, manual."""
return self.thermostat["settings"]["humidifierMode"]
@ -118,9 +134,11 @@ class EcobeeHumidifier(HumidifierEntity):
except KeyError:
return None
def set_mode(self, mode):
def set_mode(self, mode: str) -> None:
"""Set humidifier mode (auto, off, manual)."""
if mode.lower() not in (self.available_modes):
if self.available_modes is None:
raise NotImplementedError("Humidifier does not support modes.")
if mode.lower() not in self.available_modes:
raise ValueError(
f"Invalid mode value: {mode} Valid values are"
f" {', '.join(self.available_modes)}."
@ -134,10 +152,10 @@ class EcobeeHumidifier(HumidifierEntity):
self.data.ecobee.set_humidity(self.thermostat_index, humidity)
self.update_without_throttle = True
def turn_off(self, **kwargs):
def turn_off(self, **kwargs: Any) -> None:
"""Set humidifier to off mode."""
self.set_mode(MODE_OFF)
def turn_on(self, **kwargs):
def turn_on(self, **kwargs: Any) -> None:
"""Set humidifier to on mode."""
self.set_mode(self._last_humidifier_on_mode)

View File

@ -19,7 +19,7 @@
},
"abort": {
"metering_point_id_already_configured": "Metering point with ID `{metering_point_id}` is already configured.",
"no_metering_points": "The provived API token has no metering points."
"no_metering_points": "The provided API token has no metering points."
}
}
}

View File

@ -1,7 +1,7 @@
{
"config": {
"error": {
"api_error": "An error occured in the pyemoncms API : {details}"
"api_error": "An error occurred in the pyemoncms API : {details}"
},
"step": {
"user": {

View File

@ -57,7 +57,7 @@
"init": {
"title": "Envoy {serial} {host} options",
"data": {
"diagnostics_include_fixtures": "Include test fixture data in diagnostic report. Use when requested to provide test data for troubleshooting or development activies. With this option enabled the diagnostic report may take more time to download. When report is created best disable this option again.",
"diagnostics_include_fixtures": "Include test fixture data in diagnostic report. Use when requested to provide test data for troubleshooting or development activities. With this option enabled the diagnostic report may take more time to download. When report is created best disable this option again.",
"disable_keep_alive": "Always use a new connection when requesting data from the Envoy. May resolve communication issues with some Envoy firmwares."
},
"data_description": {

View File

@ -36,7 +36,7 @@
"issues": {
"import_yaml_error_url_error": {
"title": "The Feedreader YAML configuration import failed",
"description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that url is reachable and accessable for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually."
"description": "Configuring the Feedreader using YAML is being removed but there was a connection error when trying to import the YAML configuration for `{url}`.\n\nPlease verify that url is reachable and accessible for Home Assistant and restart Home Assistant to try again or remove the Feedreader YAML configuration from your configuration.yaml file and continue to set up the integration manually."
}
}
}

View File

@ -548,7 +548,7 @@
},
"cancel_quest": {
"name": "Cancel a pending quest",
"description": "Cancels a quest that has not yet startet. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
"description": "Cancels a quest that has not yet started. All accepted and pending invitations will be canceled and the quest roll returned to the owner's inventory. Only quest leader or group leader can perform this action.",
"fields": {
"config_entry": {
"name": "[%key:component::habitica::common::config_entry_name%]",

View File

@ -3,5 +3,6 @@
ATTR_PASSWORD = "password"
ATTR_USERNAME = "username"
DOMAIN = "heos"
SERVICE_GROUP_VOLUME_SET = "group_volume_set"
SERVICE_SIGN_IN = "sign_in"
SERVICE_SIGN_OUT = "sign_out"

View File

@ -1,5 +1,8 @@
{
"services": {
"group_volume_set": {
"service": "mdi:volume-medium"
},
"sign_in": {
"service": "mdi:login"
},

View File

@ -17,10 +17,12 @@ from pyheos import (
RepeatType,
const as heos_const,
)
import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_VOLUME_LEVEL,
BrowseMedia,
MediaPlayerEnqueue,
MediaPlayerEntity,
@ -33,13 +35,17 @@ from homeassistant.components.media_player import (
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers import (
config_validation as cv,
entity_platform,
entity_registry as er,
)
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.util.dt import utcnow
from .const import DOMAIN as HEOS_DOMAIN
from .const import DOMAIN as HEOS_DOMAIN, SERVICE_GROUP_VOLUME_SET
from .coordinator import HeosConfigEntry, HeosCoordinator
PARALLEL_UPDATES = 0
@ -93,6 +99,13 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Add media players for a config entry."""
# Register custom entity services
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
SERVICE_GROUP_VOLUME_SET,
{vol.Required(ATTR_MEDIA_VOLUME_LEVEL): cv.small_float},
"async_set_group_volume_level",
)
def add_entities_callback(players: Sequence[HeosPlayer]) -> None:
"""Add entities for each player."""
@ -346,6 +359,19 @@ class HeosMediaPlayer(CoordinatorEntity[HeosCoordinator], MediaPlayerEntity):
"""Set volume level, range 0..1."""
await self._player.set_volume(int(volume * 100))
@catch_action_error("set group volume level")
async def async_set_group_volume_level(self, volume_level: float) -> None:
"""Set group volume level."""
if self._player.group_id is None:
raise ServiceValidationError(
translation_domain=HEOS_DOMAIN,
translation_key="entity_not_grouped",
translation_placeholders={"entity_id": self.entity_id},
)
await self.coordinator.heos.set_group_volume(
self._player.group_id, int(volume_level * 100)
)
@catch_action_error("join players")
async def async_join_players(self, group_members: list[str]) -> None:
"""Join `group_members` as a player group with the current player."""

View File

@ -1,3 +1,17 @@
group_volume_set:
target:
entity:
integration: heos
domain: media_player
fields:
volume_level:
required: true
selector:
number:
min: 0
max: 1
step: 0.01
sign_in:
fields:
username:

View File

@ -8,7 +8,7 @@
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "Host name or IP address of a HEOS-capable product (preferrably one connected via wire to the network)."
"host": "Host name or IP address of a HEOS-capable product (preferably one connected via wire to the network)."
}
},
"reconfigure": {
@ -71,6 +71,16 @@
}
},
"services": {
"group_volume_set": {
"name": "Set group volume",
"description": "Sets the group's volume while preserving member volume ratios.",
"fields": {
"volume_level": {
"name": "Level",
"description": "The volume. 0 is inaudible, 1 is the maximum volume."
}
}
},
"sign_in": {
"name": "Sign in",
"description": "Signs in to a HEOS account.",
@ -94,6 +104,9 @@
"action_error": {
"message": "Unable to {action}: {error}"
},
"entity_not_grouped": {
"message": "Entity {entity_id} is not joined to a group"
},
"entity_not_found": {
"message": "Entity {entity_id} was not found"
},

View File

@ -24,7 +24,7 @@
}
},
"options": {
"description": "Read the documention for further details on how to configure the history stats sensor using these options.",
"description": "Read the documentation for further details on how to configure the history stats sensor using these options.",
"data": {
"start": "Start",
"end": "End",

View File

@ -237,7 +237,7 @@ async def _get_client_and_ha_id(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
"""Set up Home Connect component."""
async def _async_service_program(call: ServiceCall, start: bool):
async def _async_service_program(call: ServiceCall, start: bool) -> None:
"""Execute calls to services taking a program."""
program = call.data[ATTR_PROGRAM]
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
@ -323,7 +323,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
},
) from err
async def _async_service_set_program_options(call: ServiceCall, active: bool):
async def _async_service_set_program_options(
call: ServiceCall, active: bool
) -> None:
"""Execute calls to services taking a program."""
option_key = call.data[ATTR_KEY]
value = call.data[ATTR_VALUE]
@ -396,7 +398,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
},
) from err
async def _async_service_command(call: ServiceCall, command_key: CommandKey):
async def _async_service_command(
call: ServiceCall, command_key: CommandKey
) -> None:
"""Execute calls to services executing a command."""
client, ha_id = await _get_client_and_ha_id(hass, call.data[ATTR_DEVICE_ID])
@ -412,15 +416,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
},
) from err
async def async_service_option_active(call: ServiceCall):
async def async_service_option_active(call: ServiceCall) -> None:
"""Service for setting an option for an active program."""
await _async_service_set_program_options(call, True)
async def async_service_option_selected(call: ServiceCall):
async def async_service_option_selected(call: ServiceCall) -> None:
"""Service for setting an option for a selected program."""
await _async_service_set_program_options(call, False)
async def async_service_setting(call: ServiceCall):
async def async_service_setting(call: ServiceCall) -> None:
"""Service for changing a setting."""
key = call.data[ATTR_KEY]
value = call.data[ATTR_VALUE]
@ -439,19 +443,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
},
) from err
async def async_service_pause_program(call: ServiceCall):
async def async_service_pause_program(call: ServiceCall) -> None:
"""Service for pausing a program."""
await _async_service_command(call, CommandKey.BSH_COMMON_PAUSE_PROGRAM)
async def async_service_resume_program(call: ServiceCall):
async def async_service_resume_program(call: ServiceCall) -> None:
"""Service for resuming a paused program."""
await _async_service_command(call, CommandKey.BSH_COMMON_RESUME_PROGRAM)
async def async_service_select_program(call: ServiceCall):
async def async_service_select_program(call: ServiceCall) -> None:
"""Service for selecting a program."""
await _async_service_program(call, False)
async def async_service_set_program_and_options(call: ServiceCall):
async def async_service_set_program_and_options(call: ServiceCall) -> None:
"""Service for setting a program and options."""
data = dict(call.data)
program = data.pop(ATTR_PROGRAM, None)
@ -521,7 +525,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
},
) from err
async def async_service_start_program(call: ServiceCall):
async def async_service_start_program(call: ServiceCall) -> None:
"""Service for starting a program."""
await _async_service_program(call, True)

View File

@ -1,5 +1,7 @@
"""API for Home Connect bound to HASS OAuth."""
from typing import cast
from aiohomeconnect.client import AbstractAuth
from aiohomeconnect.const import API_ENDPOINT
@ -25,4 +27,4 @@ class AsyncConfigEntryAuth(AbstractAuth):
"""Return a valid access token."""
await self.session.async_ensure_token_valid()
return self.session.token["access_token"]
return cast(str, self.session.token["access_token"])

View File

@ -3,7 +3,7 @@
from dataclasses import dataclass
from typing import cast
from aiohomeconnect.model import StatusKey
from aiohomeconnect.model import EventKey, StatusKey
from homeassistant.components.automation import automations_with_entity
from homeassistant.components.binary_sensor import (
@ -12,6 +12,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntityDescription,
)
from homeassistant.components.script import scripts_with_entity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@ -93,12 +94,24 @@ BINARY_SENSORS = (
key=StatusKey.CONSUMER_PRODUCTS_CLEANING_ROBOT_LOST,
translation_key="lost",
),
HomeConnectBinarySensorEntityDescription(
key=StatusKey.REFRIGERATION_COMMON_DOOR_BOTTLE_COOLER,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
device_class=BinarySensorDeviceClass.DOOR,
translation_key="bottle_cooler_door",
),
HomeConnectBinarySensorEntityDescription(
key=StatusKey.REFRIGERATION_COMMON_DOOR_CHILLER_COMMON,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
device_class=BinarySensorDeviceClass.DOOR,
translation_key="chiller_door",
),
HomeConnectBinarySensorEntityDescription(
key=StatusKey.REFRIGERATION_COMMON_DOOR_FLEX_COMPARTMENT,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
device_class=BinarySensorDeviceClass.DOOR,
translation_key="flex_compartment_door",
),
HomeConnectBinarySensorEntityDescription(
key=StatusKey.REFRIGERATION_COMMON_DOOR_FREEZER,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
@ -111,6 +124,17 @@ BINARY_SENSORS = (
device_class=BinarySensorDeviceClass.DOOR,
translation_key="refrigerator_door",
),
HomeConnectBinarySensorEntityDescription(
key=StatusKey.REFRIGERATION_COMMON_DOOR_WINE_COMPARTMENT,
boolean_map=REFRIGERATION_DOOR_BOOLEAN_MAP,
device_class=BinarySensorDeviceClass.DOOR,
translation_key="wine_compartment_door",
),
)
CONNECTED_BINARY_ENTITY_DESCRIPTION = BinarySensorEntityDescription(
key=EventKey.BSH_COMMON_APPLIANCE_CONNECTED,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
)
@ -119,7 +143,11 @@ def _get_entities_for_appliance(
appliance: HomeConnectApplianceData,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
entities: list[HomeConnectEntity] = []
entities: list[HomeConnectEntity] = [
HomeConnectConnectivityBinarySensor(
entry.runtime_data, appliance, CONNECTED_BINARY_ENTITY_DESCRIPTION
)
]
entities.extend(
HomeConnectBinarySensor(entry.runtime_data, appliance, description)
for description in BINARY_SENSORS
@ -159,6 +187,21 @@ class HomeConnectBinarySensor(HomeConnectEntity, BinarySensorEntity):
self._attr_is_on = None
class HomeConnectConnectivityBinarySensor(HomeConnectEntity, BinarySensorEntity):
"""Binary sensor for Home Connect appliance's connection status."""
_attr_entity_category = EntityCategory.DIAGNOSTIC
def update_native_value(self) -> None:
"""Set the native value of the binary sensor."""
self._attr_is_on = self.appliance.info.connected
@property
def available(self) -> bool:
"""Return the availability."""
return self.coordinator.last_update_success
class HomeConnectDoorBinarySensor(HomeConnectBinarySensor):
"""Binary sensor for Home Connect Generic Door."""

View File

@ -254,7 +254,7 @@ class HomeConnectCoordinator(
await self.async_refresh()
@callback
def _call_event_listener(self, event_message: EventMessage):
def _call_event_listener(self, event_message: EventMessage) -> None:
"""Call listener for event."""
for event in event_message.data.items:
for listener in self.context_listeners.get(
@ -263,7 +263,7 @@ class HomeConnectCoordinator(
listener()
@callback
def _call_all_event_listeners_for_appliance(self, ha_id: str):
def _call_all_event_listeners_for_appliance(self, ha_id: str) -> None:
for listener, context in self._listeners.values():
if isinstance(context, tuple) and context[0] == ha_id:
listener()

View File

@ -76,6 +76,16 @@ NUMBERS = (
device_class=NumberDeviceClass.TEMPERATURE,
translation_key="wine_compartment_3_setpoint_temperature",
),
NumberEntityDescription(
key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_1_BASE_LEVEL,
device_class=NumberDeviceClass.VOLUME,
translation_key="washer_i_dos_1_base_level",
),
NumberEntityDescription(
key=SettingKey.LAUNDRY_CARE_WASHER_I_DOS_2_BASE_LEVEL,
device_class=NumberDeviceClass.VOLUME,
translation_key="washer_i_dos_2_base_level",
),
)

View File

@ -12,7 +12,7 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, UnitOfTime, UnitOfVolume
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfTime, UnitOfVolume
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util, slugify
@ -99,16 +99,19 @@ SENSORS = (
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="coffee_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_POWDER_COFFEE,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="powder_coffee_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER,
entity_category=EntityCategory.DIAGNOSTIC,
native_unit_of_measurement=UnitOfVolume.MILLILITERS,
device_class=SensorDeviceClass.VOLUME,
state_class=SensorStateClass.TOTAL_INCREASING,
@ -116,31 +119,37 @@ SENSORS = (
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_WATER_CUPS,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="hot_water_cups_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_HOT_MILK,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="hot_milk_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_FROTHY_MILK,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="frothy_milk_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_MILK,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="milk_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_COFFEE_AND_MILK,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="coffee_and_milk_counter",
),
HomeConnectSensorEntityDescription(
key=StatusKey.CONSUMER_PRODUCTS_COFFEE_MAKER_BEVERAGE_COUNTER_RISTRETTO_ESPRESSO,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
translation_key="ristretto_espresso_counter",
),

View File

@ -793,14 +793,23 @@
"lost": {
"name": "Lost"
},
"bottle_cooler_door": {
"name": "Bottle cooler door"
},
"chiller_door": {
"name": "Chiller door"
},
"flex_compartment_door": {
"name": "Flex compartment door"
},
"freezer_door": {
"name": "Freezer door"
},
"refrigerator_door": {
"name": "Refrigerator door"
},
"wine_compartment_door": {
"name": "Wine compartment door"
}
},
"light": {
@ -844,6 +853,12 @@
},
"wine_compartment_3_setpoint_temperature": {
"name": "Wine compartment 3 temperature"
},
"washer_i_dos_1_base_level": {
"name": "i-Dos 1 base level"
},
"washer_i_dos_2_base_level": {
"name": "i-Dos 2 base level"
}
},
"select": {

View File

@ -44,7 +44,7 @@
},
"no_platform_setup": {
"title": "Unused YAML configuration for the {platform} integration",
"description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}"
"description": "It's not possible to configure {platform} {domain} by adding `{platform_key}` to the {domain} configuration. Please check the documentation for more information on how to set up this integration.\n\nTo resolve this:\n1. Remove `{platform_key}` occurrences from the `{domain}:` configuration in your YAML configuration file.\n2. Restart Home Assistant.\n\nExample that should be removed:\n{yaml_example}"
},
"storage_corruption": {
"title": "Storage corruption detected for {storage_key}",

View File

@ -14,7 +14,7 @@
},
"step": {
"import_finish": {
"description": "The existing YAML configuration has succesfully been imported.\n\nYou can now remove the `homeworks` configuration from your configuration.yaml file."
"description": "The existing YAML configuration has successfully been imported.\n\nYou can now remove the `homeworks` configuration from your configuration.yaml file."
},
"import_controller_name": {
"description": "Lutron Homeworks is no longer configured through configuration.yaml.\n\nPlease fill in the form to import the existing configuration to the UI.",

View File

@ -3,7 +3,7 @@
"step": {
"user": {
"title": "Set up madVR Envy",
"description": "Your device needs to be on in order to add the integation.",
"description": "Your device needs to be on in order to add the integration.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
@ -15,7 +15,7 @@
},
"reconfigure": {
"title": "Reconfigure madVR Envy",
"description": "Your device needs to be on in order to reconfigure the integation.",
"description": "Your device needs to be on in order to reconfigure the integration.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"

View File

@ -22,7 +22,7 @@
"error": {
"unauthorized_error": "The credentials are incorrect.",
"network_error": "The Mastodon instance was not found.",
"unknown": "Unknown error occured when connecting to the Mastodon instance."
"unknown": "Unknown error occurred when connecting to the Mastodon instance."
}
},
"exceptions": {

View File

@ -7,7 +7,7 @@
"llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]"
},
"data_description": {
"llm_hass_api": "The method for controling Home Assistant to expose with the Model Context Protocol."
"llm_hass_api": "The method for controlling Home Assistant to expose with the Model Context Protocol."
}
}
},

View File

@ -21,7 +21,7 @@
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"connection_error_during_import": "Connection error occured during yaml configuration import",
"connection_error_during_import": "Connection error occurred during yaml configuration import",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
@ -70,7 +70,7 @@
"name": "Cache memory"
},
"nextcloud_cache_num_entries": {
"name": "Cache number of entires"
"name": "Cache number of entries"
},
"nextcloud_cache_num_hits": {
"name": "Cache number of hits"

View File

@ -61,7 +61,7 @@
"step": {
"confirm": {
"title": "[%key:component::proximity::issues::tracked_entity_removed::title%]",
"description": "The entity `{entity_id}` has been removed from HA, but is used in proximity {name}. Please remove `{entity_id}` from the list of tracked entities. Related proximity sensor entites were set to unavailable and can be removed."
"description": "The entity `{entity_id}` has been removed from HA, but is used in proximity {name}. Please remove `{entity_id}` from the list of tracked entities. Related proximity sensor entities were set to unavailable and can be removed."
}
}
}

View File

@ -33,10 +33,10 @@
"name": "Upload speed limit"
},
"alltime_download": {
"name": "Alltime download"
"name": "All-time download"
},
"alltime_upload": {
"name": "Alltime upload"
"name": "All-time upload"
},
"global_ratio": {
"name": "Global ratio"
@ -115,7 +115,7 @@
"message": "No entry with ID {device_id} was found"
},
"login_error": {
"message": "A login error occured. Please check your username and password."
"message": "A login error occurred. Please check your username and password."
},
"cannot_connect": {
"message": "Can't connect to qBittorrent, please check your configuration."

View File

@ -10,7 +10,7 @@
"abort": {
"already_configured": "Controller already configured",
"discovery_in_progress": "Discovery in progress",
"not_supported": "Configuration for QBUS happens through MQTT discovery. If your controller is not automatically discovered, check the prerequisites and troubleshooting section of the documention."
"not_supported": "Configuration for QBUS happens through MQTT discovery. If your controller is not automatically discovered, check the prerequisites and troubleshooting section of the documentation."
},
"error": {
"no_controller": "No controllers were found"

View File

@ -126,7 +126,7 @@
},
"hub_switch_deprecated": {
"title": "Reolink Home Hub switches deprecated",
"description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are depricated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned."
"description": "The redundant 'Record', 'Email on event', 'FTP upload', 'Push notifications', and 'Buzzer on event' switches on the Reolink Home Hub are deprecated since the new firmware no longer supports these. Please use the equally named switches under each of the camera devices connected to the Home Hub instead. To remove this issue, please adjust automations accordingly and disable the switch entities mentioned."
}
},
"services": {

View File

@ -164,7 +164,9 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(self.info[CONF_MAC])
await self.async_set_unique_id(
self.info[CONF_MAC], raise_on_progress=False
)
self._abort_if_unique_id_configured({CONF_HOST: host})
self.host = host
self.port = port

View File

@ -13,6 +13,9 @@
{
"type": "_http._tcp.local.",
"name": "shelly*"
},
{
"type": "_shelly._tcp.local."
}
]
}

View File

@ -36,3 +36,5 @@ CONF_BROWSE_LIMIT = "browse_limit"
CONF_VOLUME_STEP = "volume_step"
DEFAULT_BROWSE_LIMIT = 1000
DEFAULT_VOLUME_STEP = 5
ATTR_ANNOUNCE_VOLUME = "announce_volume"
ATTR_ANNOUNCE_TIMEOUT = "announce_timeout"

View File

@ -14,6 +14,7 @@ import voluptuous as vol
from homeassistant.components import media_source
from homeassistant.components.media_player import (
ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_EXTRA,
BrowseError,
BrowseMedia,
MediaPlayerEnqueue,
@ -52,6 +53,8 @@ from .browse_media import (
media_source_content_filter,
)
from .const import (
ATTR_ANNOUNCE_TIMEOUT,
ATTR_ANNOUNCE_VOLUME,
CONF_BROWSE_LIMIT,
CONF_VOLUME_STEP,
DEFAULT_BROWSE_LIMIT,
@ -157,6 +160,26 @@ async def async_setup_entry(
entry.async_on_unload(async_at_start(hass, start_server_discovery))
def get_announce_volume(extra: dict) -> float | None:
"""Get announce volume from extra service data."""
if ATTR_ANNOUNCE_VOLUME not in extra:
return None
announce_volume = float(extra[ATTR_ANNOUNCE_VOLUME])
if not (0 < announce_volume <= 1):
raise ValueError
return announce_volume * 100
def get_announce_timeout(extra: dict) -> int | None:
"""Get announce volume from extra service data."""
if ATTR_ANNOUNCE_TIMEOUT not in extra:
return None
announce_timeout = int(extra[ATTR_ANNOUNCE_TIMEOUT])
if announce_timeout < 1:
raise ValueError
return announce_timeout
class SqueezeBoxMediaPlayerEntity(
CoordinatorEntity[SqueezeBoxPlayerUpdateCoordinator], MediaPlayerEntity
):
@ -184,6 +207,7 @@ class SqueezeBoxMediaPlayerEntity(
| MediaPlayerEntityFeature.STOP
| MediaPlayerEntityFeature.GROUPING
| MediaPlayerEntityFeature.MEDIA_ENQUEUE
| MediaPlayerEntityFeature.MEDIA_ANNOUNCE
)
_attr_has_entity_name = True
_attr_name = None
@ -437,7 +461,11 @@ class SqueezeBoxMediaPlayerEntity(
await self.coordinator.async_refresh()
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
self,
media_type: MediaType | str,
media_id: str,
announce: bool | None = None,
**kwargs: Any,
) -> None:
"""Send the play_media command to the media player."""
index = None
@ -460,6 +488,32 @@ class SqueezeBoxMediaPlayerEntity(
)
media_id = play_item.url
if announce:
if media_type not in MediaType.MUSIC:
raise ServiceValidationError(
"Announcements must have media type of 'music'. Playlists are not supported"
)
extra = kwargs.get(ATTR_MEDIA_EXTRA, {})
cmd = "announce"
try:
announce_volume = get_announce_volume(extra)
except ValueError:
raise ServiceValidationError(
f"{ATTR_ANNOUNCE_VOLUME} must be a number greater than 0 and less than or equal to 1"
) from None
else:
self._player.set_announce_volume(announce_volume)
try:
announce_timeout = get_announce_timeout(extra)
except ValueError:
raise ServiceValidationError(
f"{ATTR_ANNOUNCE_TIMEOUT} must be a whole number greater than 0"
) from None
else:
self._player.set_announce_timeout(announce_timeout)
if media_type in MediaType.MUSIC:
if not media_id.startswith(SQUEEZEBOX_SOURCE_STRINGS):
# do not process special squeezebox "source" media ids

View File

@ -21,7 +21,7 @@
}
},
"state_characteristic": {
"description": "Read the documention for further details on available options and how to use them.",
"description": "Read the documentation for further details on available options and how to use them.",
"data": {
"state_characteristic": "Statistic characteristic"
},
@ -30,7 +30,7 @@
}
},
"options": {
"description": "Read the documention for further details on how to configure the statistics sensor using these options.",
"description": "Read the documentation for further details on how to configure the statistics sensor using these options.",
"data": {
"sampling_size": "Sampling size",
"max_age": "Max age",

View File

@ -2,7 +2,7 @@
"config": {
"step": {
"user": {
"description": "Select the location you want to recieve the Stookwijzer information for.",
"description": "Select the location you want to receive the Stookwijzer information for.",
"data": {
"location": "[%key:common::config_flow::data::location%]"
},

View File

@ -506,7 +506,7 @@
},
"exceptions": {
"unknown": {
"message": "An unknown issue occured changing {name}."
"message": "An unknown issue occurred changing {name}."
},
"not_supported": {
"message": "{name} is not supported."

View File

@ -57,7 +57,7 @@
"name": "[%key:component::sensor::entity_component::pm25::name%]"
},
"neph": {
"name": "Visbility using nephelometry"
"name": "Visibility using nephelometry"
},
"dominant_pollutant": {
"name": "Dominant pollutant",

View File

@ -37,7 +37,7 @@
"name": "Indoor unit water pump"
},
"indoor_unit_auxiliary_pump_state": {
"name": "Indoor unit auxilary water pump"
"name": "Indoor unit auxiliary water pump"
},
"indoor_unit_dhw_valve_or_pump_state": {
"name": "Indoor unit DHW valve or water pump"

View File

@ -7,7 +7,7 @@ from dataclasses import dataclass
from datetime import timedelta
from typing import Any
from yolink.const import ATTR_DEVICE_SMART_REMOTER
from yolink.const import ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH
from yolink.device import YoLinkDevice
from yolink.exception import YoLinkAuthFailError, YoLinkClientError
from yolink.home_manager import YoLinkHome
@ -75,7 +75,8 @@ class YoLinkHomeMessageListener(MessageListener):
device_coordinator.async_set_updated_data(msg_data)
# handling events
if (
device_coordinator.device.device_type == ATTR_DEVICE_SMART_REMOTER
device_coordinator.device.device_type
in [ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH]
and msg_data.get("event") is not None
):
device_registry = dr.async_get(self._hass)

View File

@ -33,3 +33,7 @@ DEV_MODEL_PLUG_YS6602_UC = "YS6602-UC"
DEV_MODEL_PLUG_YS6602_EC = "YS6602-EC"
DEV_MODEL_PLUG_YS6803_UC = "YS6803-UC"
DEV_MODEL_PLUG_YS6803_EC = "YS6803-EC"
DEV_MODEL_SWITCH_YS5708_UC = "YS5708-UC"
DEV_MODEL_SWITCH_YS5708_EC = "YS5708-EC"
DEV_MODEL_SWITCH_YS5709_UC = "YS5709-UC"
DEV_MODEL_SWITCH_YS5709_EC = "YS5709-EC"

View File

@ -5,7 +5,7 @@ from __future__ import annotations
from typing import Any
import voluptuous as vol
from yolink.const import ATTR_DEVICE_SMART_REMOTER
from yolink.const import ATTR_DEVICE_SMART_REMOTER, ATTR_DEVICE_SWITCH
from homeassistant.components.device_automation import DEVICE_TRIGGER_BASE_SCHEMA
from homeassistant.components.homeassistant.triggers import event as event_trigger
@ -21,6 +21,10 @@ from .const import (
DEV_MODEL_FLEX_FOB_YS3604_UC,
DEV_MODEL_FLEX_FOB_YS3614_EC,
DEV_MODEL_FLEX_FOB_YS3614_UC,
DEV_MODEL_SWITCH_YS5708_EC,
DEV_MODEL_SWITCH_YS5708_UC,
DEV_MODEL_SWITCH_YS5709_EC,
DEV_MODEL_SWITCH_YS5709_UC,
)
CONF_BUTTON_1 = "button_1"
@ -30,7 +34,7 @@ CONF_BUTTON_4 = "button_4"
CONF_SHORT_PRESS = "short_press"
CONF_LONG_PRESS = "long_press"
FLEX_FOB_4_BUTTONS = {
FLEX_BUTTONS_4 = {
f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}",
f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}",
f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}",
@ -41,7 +45,7 @@ FLEX_FOB_4_BUTTONS = {
f"{CONF_BUTTON_4}_{CONF_LONG_PRESS}",
}
FLEX_FOB_2_BUTTONS = {
FLEX_BUTTONS_2 = {
f"{CONF_BUTTON_1}_{CONF_SHORT_PRESS}",
f"{CONF_BUTTON_1}_{CONF_LONG_PRESS}",
f"{CONF_BUTTON_2}_{CONF_SHORT_PRESS}",
@ -49,16 +53,19 @@ FLEX_FOB_2_BUTTONS = {
}
TRIGGER_SCHEMA = DEVICE_TRIGGER_BASE_SCHEMA.extend(
{vol.Required(CONF_TYPE): vol.In(FLEX_FOB_4_BUTTONS)}
{vol.Required(CONF_TYPE): vol.In(FLEX_BUTTONS_4)}
)
# YoLink Remotes YS3604/YS3614
FLEX_FOB_TRIGGER_TYPES: dict[str, set[str]] = {
DEV_MODEL_FLEX_FOB_YS3604_EC: FLEX_FOB_4_BUTTONS,
DEV_MODEL_FLEX_FOB_YS3604_UC: FLEX_FOB_4_BUTTONS,
DEV_MODEL_FLEX_FOB_YS3614_UC: FLEX_FOB_2_BUTTONS,
DEV_MODEL_FLEX_FOB_YS3614_EC: FLEX_FOB_2_BUTTONS,
# YoLink Remotes YS3604/YS3614, Switch YS5708/YS5709
TRIGGER_MAPPINGS: dict[str, set[str]] = {
DEV_MODEL_FLEX_FOB_YS3604_EC: FLEX_BUTTONS_4,
DEV_MODEL_FLEX_FOB_YS3604_UC: FLEX_BUTTONS_4,
DEV_MODEL_FLEX_FOB_YS3614_UC: FLEX_BUTTONS_2,
DEV_MODEL_FLEX_FOB_YS3614_EC: FLEX_BUTTONS_2,
DEV_MODEL_SWITCH_YS5708_EC: FLEX_BUTTONS_2,
DEV_MODEL_SWITCH_YS5708_UC: FLEX_BUTTONS_2,
DEV_MODEL_SWITCH_YS5709_EC: FLEX_BUTTONS_2,
DEV_MODEL_SWITCH_YS5709_UC: FLEX_BUTTONS_2,
}
@ -68,9 +75,12 @@ async def async_get_triggers(
"""List device triggers for YoLink devices."""
device_registry = dr.async_get(hass)
registry_device = device_registry.async_get(device_id)
if not registry_device or registry_device.model != ATTR_DEVICE_SMART_REMOTER:
if not registry_device or registry_device.model not in [
ATTR_DEVICE_SMART_REMOTER,
ATTR_DEVICE_SWITCH,
]:
return []
if registry_device.model_id not in list(FLEX_FOB_TRIGGER_TYPES.keys()):
if registry_device.model_id not in list(TRIGGER_MAPPINGS.keys()):
return []
return [
{
@ -79,7 +89,7 @@ async def async_get_triggers(
CONF_PLATFORM: "device",
CONF_TYPE: trigger,
}
for trigger in FLEX_FOB_TRIGGER_TYPES[registry_device.model_id]
for trigger in TRIGGER_MAPPINGS[registry_device.model_id]
]

View File

@ -162,11 +162,12 @@ class YoLinkSwitchEntity(YoLinkEntity, SwitchEntity):
@callback
def update_entity_state(self, state: dict[str, str | list[str]]) -> None:
"""Update HA Entity State."""
self._attr_is_on = self._get_state(
state.get("state"),
self.entity_description.plug_index_fn(self.coordinator.device),
)
self.async_write_ha_state()
if (state_value := state.get("state")) is not None:
self._attr_is_on = self._get_state(
state_value,
self.entity_description.plug_index_fn(self.coordinator.device),
)
self.async_write_ha_state()
async def call_state_change(self, state: str) -> None:
"""Call setState api to change switch state."""

View File

@ -28,8 +28,8 @@ MINOR_VERSION: Final = 3
PATCH_VERSION: Final = "0.dev0"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0)
REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 0)
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
REQUIRED_NEXT_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
# Truthy date string triggers showing related deprecation warning messages.
REQUIRED_NEXT_PYTHON_HA_RELEASE: Final = ""

View File

@ -803,6 +803,11 @@ ZEROCONF = {
"domain": "russound_rio",
},
],
"_shelly._tcp.local.": [
{
"domain": "shelly",
},
],
"_sideplay._tcp.local.": [
{
"domain": "ecobee",

View File

@ -67,7 +67,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.12.2,<5.0
ulid-transform==1.2.0
urllib3>=1.26.5,<2
uv==0.6.0
uv==0.6.1
voluptuous-openapi==0.0.6
voluptuous-serialize==2.6.0
voluptuous==0.15.2

10
mypy.ini generated
View File

@ -2096,6 +2096,16 @@ disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.home_connect.*]
check_untyped_defs = true
disallow_incomplete_defs = true
disallow_subclassing_any = true
disallow_untyped_calls = true
disallow_untyped_decorators = true
disallow_untyped_defs = true
warn_return_any = true
warn_unreachable = true
[mypy-homeassistant.components.homeassistant.*]
check_untyped_defs = true
disallow_incomplete_defs = true

View File

@ -21,7 +21,7 @@ classifiers = [
"Programming Language :: Python :: 3.13",
"Topic :: Home Automation",
]
requires-python = ">=3.13.0"
requires-python = ">=3.13.2"
dependencies = [
"aiodns==3.2.0",
# Integrations may depend on hassio integration without listing it to
@ -76,7 +76,7 @@ dependencies = [
# Temporary setting an upper bound, to prevent compat issues with urllib3>=2
# https://github.com/home-assistant/core/issues/97248
"urllib3>=1.26.5,<2",
"uv==0.6.0",
"uv==0.6.1",
"voluptuous==0.15.2",
"voluptuous-serialize==2.6.0",
"voluptuous-openapi==0.0.6",

2
requirements.txt generated
View File

@ -45,7 +45,7 @@ standard-telnetlib==3.13.0
typing-extensions>=4.12.2,<5.0
ulid-transform==1.2.0
urllib3>=1.26.5,<2
uv==0.6.0
uv==0.6.1
voluptuous==0.15.2
voluptuous-serialize==2.6.0
voluptuous-openapi==0.0.6

View File

@ -26,6 +26,24 @@ ENV \
ARG QEMU_CPU
# Home Assistant S6-Overlay
COPY rootfs /
# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary
RUN \
case "${{BUILD_ARCH}}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${{BUILD_ARCH}} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
# Install uv
RUN pip3 install uv=={uv}
@ -56,24 +74,6 @@ RUN \
&& python3 -m compileall \
homeassistant/homeassistant
# Home Assistant S6-Overlay
COPY rootfs /
# Needs to be redefined inside the FROM statement to be set for RUN commands
ARG BUILD_ARCH
# Get go2rtc binary
RUN \
case "${{BUILD_ARCH}}" in \
"aarch64") go2rtc_suffix='arm64' ;; \
"armhf") go2rtc_suffix='armv6' ;; \
"armv7") go2rtc_suffix='arm' ;; \
*) go2rtc_suffix=${{BUILD_ARCH}} ;; \
esac \
&& curl -L https://github.com/AlexxIT/go2rtc/releases/download/v{go2rtc}/go2rtc_linux_${{go2rtc_suffix}} --output /bin/go2rtc \
&& chmod +x /bin/go2rtc \
# Verify go2rtc can be executed
&& go2rtc --version
WORKDIR /config
"""

View File

@ -14,7 +14,7 @@ WORKDIR "/github/workspace"
COPY . /usr/src/homeassistant
# Uv is only needed during build
RUN --mount=from=ghcr.io/astral-sh/uv:0.6.0,source=/uv,target=/bin/uv \
RUN --mount=from=ghcr.io/astral-sh/uv:0.6.1,source=/uv,target=/bin/uv \
# Uv creates a lock file in /tmp
--mount=type=tmpfs,target=/tmp \
# Required for PyTurboJPEG

View File

@ -1,5 +1,6 @@
"""Test the air-Q config flow."""
import logging
from unittest.mock import patch
from aioairq import DeviceInfo, InvalidAuth
@ -37,8 +38,9 @@ DEFAULT_OPTIONS = {
}
async def test_form(hass: HomeAssistant) -> None:
async def test_form(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) -> None:
"""Test we get the form."""
caplog.set_level(logging.DEBUG)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
@ -54,6 +56,7 @@ async def test_form(hass: HomeAssistant) -> None:
TEST_USER_DATA,
)
await hass.async_block_till_done()
assert f"Creating an entry for {TEST_DEVICE_INFO['name']}" in caplog.text
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == TEST_DEVICE_INFO["name"]

View File

@ -0,0 +1,129 @@
"""Test the air-Q coordinator."""
import logging
from unittest.mock import patch
from aioairq import DeviceInfo as AirQDeviceInfo
import pytest
from homeassistant.components.airq import AirQCoordinator
from homeassistant.components.airq.const import DOMAIN
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from tests.common import MockConfigEntry
pytestmark = pytest.mark.usefixtures("mock_setup_entry")
MOCKED_ENTRY = MockConfigEntry(
domain=DOMAIN,
data={
CONF_IP_ADDRESS: "192.168.0.0",
CONF_PASSWORD: "password",
},
unique_id="123-456",
)
TEST_DEVICE_INFO = AirQDeviceInfo(
id="id",
name="name",
model="model",
sw_version="sw",
hw_version="hw",
)
TEST_DEVICE_DATA = {"co2": 500.0, "Status": "OK"}
STATUS_WARMUP = {
"co": "co sensor still in warm up phase; waiting time = 18 s",
"tvoc": "tvoc sensor still in warm up phase; waiting time = 18 s",
"so2": "so2 sensor still in warm up phase; waiting time = 17 s",
}
async def test_logging_in_coordinator_first_update_data(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that the first AirQCoordinator._async_update_data call logs necessary setup.
The fields of AirQCoordinator.device_info that are specific to the device are only
populated upon the first call to AirQCoordinator._async_update_data. The one field
which is actually necessary is 'name', and its absence is checked and logged,
as well as its being set.
"""
caplog.set_level(logging.DEBUG)
coordinator = AirQCoordinator(hass, MOCKED_ENTRY)
# check that the name _is_ missing
assert "name" not in coordinator.device_info
# First call: fetch missing device info
with (
patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO),
patch("aioairq.AirQ.get_latest_data", return_value=TEST_DEVICE_DATA),
):
await coordinator._async_update_data()
# check that the missing name is logged...
assert (
"'name' not found in AirQCoordinator.device_info, fetching from the device"
in caplog.text
)
# ...and fixed
assert coordinator.device_info.get("name") == TEST_DEVICE_INFO["name"]
assert (
f"Updated AirQCoordinator.device_info for 'name' {TEST_DEVICE_INFO['name']}"
in caplog.text
)
# Also that no warming up sensors is found as none are mocked
assert "Following sensors are still warming up" not in caplog.text
async def test_logging_in_coordinator_subsequent_update_data(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that the second AirQCoordinator._async_update_data call has nothing to log.
The second call is emulated by setting up AirQCoordinator.device_info correctly,
instead of actually calling the _async_update_data, which would populate the log
with the messages we want to see not being repeated.
"""
caplog.set_level(logging.DEBUG)
coordinator = AirQCoordinator(hass, MOCKED_ENTRY)
coordinator.device_info.update(DeviceInfo(**TEST_DEVICE_INFO))
with (
patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO),
patch("aioairq.AirQ.get_latest_data", return_value=TEST_DEVICE_DATA),
):
await coordinator._async_update_data()
# check that the name _is not_ missing
assert "name" in coordinator.device_info
# and that nothing of the kind is logged
assert (
"'name' not found in AirQCoordinator.device_info, fetching from the device"
not in caplog.text
)
assert (
f"Updated AirQCoordinator.device_info for 'name' {TEST_DEVICE_INFO['name']}"
not in caplog.text
)
async def test_logging_when_warming_up_sensor_present(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture
) -> None:
"""Test that warming up sensors are logged."""
caplog.set_level(logging.DEBUG)
coordinator = AirQCoordinator(hass, MOCKED_ENTRY)
with (
patch("aioairq.AirQ.fetch_device_info", return_value=TEST_DEVICE_INFO),
patch(
"aioairq.AirQ.get_latest_data",
return_value=TEST_DEVICE_DATA | {"Status": STATUS_WARMUP},
),
):
await coordinator._async_update_data()
assert (
f"Following sensors are still warming up: {set(STATUS_WARMUP.keys())}"
in caplog.text
)

View File

@ -3,7 +3,7 @@
import pytest
from homeassistant.components.aranet.const import DOMAIN
from homeassistant.components.sensor import ATTR_STATE_CLASS
from homeassistant.components.sensor import ATTR_OPTIONS, ATTR_STATE_CLASS
from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@ -170,7 +170,7 @@ async def test_sensors_aranet4(
assert len(hass.states.async_all("sensor")) == 0
inject_bluetooth_service_info(hass, VALID_DATA_SERVICE_INFO)
await hass.async_block_till_done()
assert len(hass.states.async_all("sensor")) == 6
assert len(hass.states.async_all("sensor")) == 7
batt_sensor = hass.states.get("sensor.aranet4_12345_battery")
batt_sensor_attrs = batt_sensor.attributes
@ -214,6 +214,12 @@ async def test_sensors_aranet4(
assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s"
assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
status_sensor = hass.states.get("sensor.aranet4_12345_threshold")
status_sensor_attrs = status_sensor.attributes
assert status_sensor.state == "green"
assert status_sensor_attrs[ATTR_FRIENDLY_NAME] == "Aranet4 12345 Threshold"
assert status_sensor_attrs[ATTR_OPTIONS] == ["error", "green", "yellow", "red"]
# Check device context for the battery sensor
entity = entity_registry.async_get("sensor.aranet4_12345_battery")
device = device_registry.async_get(entity.device_id)
@ -245,7 +251,7 @@ async def test_sensors_aranetrn(
assert len(hass.states.async_all("sensor")) == 0
inject_bluetooth_service_info(hass, VALID_ARANET_RADON_DATA_SERVICE_INFO)
await hass.async_block_till_done()
assert len(hass.states.async_all("sensor")) == 6
assert len(hass.states.async_all("sensor")) == 7
batt_sensor = hass.states.get("sensor.aranetrn_12345_battery")
batt_sensor_attrs = batt_sensor.attributes
@ -291,6 +297,12 @@ async def test_sensors_aranetrn(
assert interval_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "s"
assert interval_sensor_attrs[ATTR_STATE_CLASS] == "measurement"
status_sensor = hass.states.get("sensor.aranetrn_12345_threshold")
status_sensor_attrs = status_sensor.attributes
assert status_sensor.state == "green"
assert status_sensor_attrs[ATTR_FRIENDLY_NAME] == "AranetRn+ 12345 Threshold"
assert status_sensor_attrs[ATTR_OPTIONS] == ["error", "green", "yellow", "red"]
# Check device context for the battery sensor
entity = entity_registry.async_get("sensor.aranetrn_12345_battery")
device = device_registry.async_get(entity.device_id)

View File

@ -67,7 +67,7 @@
"hasHeatPump": false,
"humidity": "30"
},
"equipmentStatus": "fan",
"equipmentStatus": "fan,humidifier",
"events": [
{
"name": "Event1",

View File

@ -6,6 +6,7 @@ import pytest
from homeassistant.components.ecobee.humidifier import MODE_MANUAL, MODE_OFF
from homeassistant.components.humidifier import (
ATTR_ACTION,
ATTR_AVAILABLE_MODES,
ATTR_CURRENT_HUMIDITY,
ATTR_HUMIDITY,
@ -17,6 +18,7 @@ from homeassistant.components.humidifier import (
MODE_AUTO,
SERVICE_SET_HUMIDITY,
SERVICE_SET_MODE,
HumidifierAction,
HumidifierDeviceClass,
HumidifierEntityFeature,
)
@ -44,6 +46,7 @@ async def test_attributes(hass: HomeAssistant) -> None:
state = hass.states.get(DEVICE_ID)
assert state.state == STATE_ON
assert state.attributes[ATTR_ACTION] == HumidifierAction.HUMIDIFYING
assert state.attributes[ATTR_CURRENT_HUMIDITY] == 15
assert state.attributes[ATTR_MIN_HUMIDITY] == DEFAULT_MIN_HUMIDITY
assert state.attributes[ATTR_MAX_HUMIDITY] == DEFAULT_MAX_HUMIDITY

View File

@ -34,6 +34,7 @@ class MockHeos(Heos):
self.player_set_play_state: AsyncMock = AsyncMock()
self.player_set_volume: AsyncMock = AsyncMock()
self.set_group: AsyncMock = AsyncMock()
self.set_group_volume: AsyncMock = AsyncMock()
self.sign_in: AsyncMock = AsyncMock()
self.sign_out: AsyncMock = AsyncMock()

View File

@ -22,7 +22,7 @@ import pytest
from syrupy.assertion import SnapshotAssertion
from syrupy.filters import props
from homeassistant.components.heos.const import DOMAIN
from homeassistant.components.heos.const import DOMAIN, SERVICE_GROUP_VOLUME_SET
from homeassistant.components.media_player import (
ATTR_GROUP_MEMBERS,
ATTR_INPUT_SOURCE,
@ -724,6 +724,62 @@ async def test_volume_set_error(
controller.player_set_volume.assert_called_once_with(1, 100)
async def test_group_volume_set(
hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos
) -> None:
"""Test the group volume set service."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.services.async_call(
DOMAIN,
SERVICE_GROUP_VOLUME_SET,
{ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1},
blocking=True,
)
controller.set_group_volume.assert_called_once_with(999, 100)
async def test_group_volume_set_error(
hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos
) -> None:
"""Test the group volume set service errors."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
controller.set_group_volume.side_effect = CommandFailedError("", "Failure", 1)
with pytest.raises(
HomeAssistantError,
match=re.escape("Unable to set group volume level: Failure (1)"),
):
await hass.services.async_call(
DOMAIN,
SERVICE_GROUP_VOLUME_SET,
{ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1},
blocking=True,
)
controller.set_group_volume.assert_called_once_with(999, 100)
async def test_group_volume_set_not_grouped_error(
hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos
) -> None:
"""Test the group volume set service when not grouped raises error."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
player = controller.players[1]
player.group_id = None
with pytest.raises(
ServiceValidationError,
match=re.escape("Entity media_player.test_player is not joined to a group"),
):
await hass.services.async_call(
DOMAIN,
SERVICE_GROUP_VOLUME_SET,
{ATTR_ENTITY_ID: "media_player.test_player", ATTR_MEDIA_VOLUME_LEVEL: 1},
blocking=True,
)
controller.set_group_volume.assert_not_called()
async def test_select_favorite(
hass: HomeAssistant,
config_entry: MockConfigEntry,

View File

@ -346,6 +346,49 @@ async def test_binary_sensors_functionality(
assert hass.states.is_state(entity_id, expected)
async def test_connected_sensor_functionality(
hass: HomeAssistant,
config_entry: MockConfigEntry,
integration_setup: Callable[[MagicMock], Awaitable[bool]],
setup_credentials: None,
client: MagicMock,
appliance_ha_id: str,
) -> None:
"""Test if the connected binary sensor reports the right values."""
entity_id = "binary_sensor.washer_connectivity"
assert config_entry.state == ConfigEntryState.NOT_LOADED
assert await integration_setup(client)
assert config_entry.state == ConfigEntryState.LOADED
assert hass.states.is_state(entity_id, STATE_ON)
await client.add_events(
[
EventMessage(
appliance_ha_id,
EventType.DISCONNECTED,
ArrayOfEvents([]),
)
]
)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, STATE_OFF)
await client.add_events(
[
EventMessage(
appliance_ha_id,
EventType.CONNECTED,
ArrayOfEvents([]),
)
]
)
await hass.async_block_till_done()
assert hass.states.is_state(entity_id, STATE_ON)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_create_issue(
hass: HomeAssistant,

View File

@ -1101,7 +1101,7 @@
'state': '0.175296',
})
# ---
# name: test_async_setup_entry[sensor.my_nc_url_local_cache_number_of_entires-entry]
# name: test_async_setup_entry[sensor.my_nc_url_local_cache_number_of_entries-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@ -1116,7 +1116,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.my_nc_url_local_cache_number_of_entires',
'entity_id': 'sensor.my_nc_url_local_cache_number_of_entries',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@ -1128,7 +1128,7 @@
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Cache number of entires',
'original_name': 'Cache number of entries',
'platform': 'nextcloud',
'previous_unique_id': None,
'supported_features': 0,
@ -1137,14 +1137,14 @@
'unit_of_measurement': None,
})
# ---
# name: test_async_setup_entry[sensor.my_nc_url_local_cache_number_of_entires-state]
# name: test_async_setup_entry[sensor.my_nc_url_local_cache_number_of_entries-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'my.nc_url.local Cache number of entires',
'friendly_name': 'my.nc_url.local Cache number of entries',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.my_nc_url_local_cache_number_of_entires',
'entity_id': 'sensor.my_nc_url_local_cache_number_of_entries',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@ -117,6 +117,73 @@ async def test_form(
assert len(mock_setup_entry.mock_calls) == 1
async def test_user_flow_overrides_existing_discovery(
hass: HomeAssistant,
mock_rpc_device: Mock,
) -> None:
"""Test setting up from the user flow when the devices is already discovered."""
with (
patch(
"homeassistant.components.shelly.config_flow.get_info",
return_value={
"mac": "AABBCCDDEEFF",
"model": MODEL_PLUS_2PM,
"auth": False,
"gen": 2,
"port": 80,
},
),
patch(
"homeassistant.components.shelly.async_setup", return_value=True
) as mock_setup,
patch(
"homeassistant.components.shelly.async_setup_entry",
return_value=True,
) as mock_setup_entry,
):
discovery_result = await hass.config_entries.flow.async_init(
DOMAIN,
data=ZeroconfServiceInfo(
ip_address=ip_address("1.1.1.1"),
ip_addresses=[ip_address("1.1.1.1")],
hostname="mock_hostname",
name="shelly2pm-aabbccddeeff",
port=None,
properties={ATTR_PROPERTIES_ID: "shelly2pm-aabbccddeeff"},
type="mock_type",
),
context={"source": config_entries.SOURCE_ZEROCONF},
)
assert discovery_result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {}
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
{"host": "1.1.1.1", "port": 80},
)
await hass.async_block_till_done()
assert result2["type"] is FlowResultType.CREATE_ENTRY
assert result2["title"] == "Test name"
assert result2["data"] == {
"host": "1.1.1.1",
"port": 80,
"model": MODEL_PLUS_2PM,
"sleep_period": 0,
"gen": 2,
}
assert result2["context"]["unique_id"] == "AABBCCDDEEFF"
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
# discovery flow should have been aborted
assert not hass.config_entries.flow.async_progress(DOMAIN)
async def test_form_gen1_custom_port(
hass: HomeAssistant,
mock_block_device: Mock,

View File

@ -120,6 +120,11 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry:
return config_entry
async def mock_async_play_announcement(media_id: str) -> bool:
"""Mock the announcement."""
return True
async def mock_async_browse(
media_type: MediaType, limit: int, browse_id: tuple | None = None
) -> dict | None:
@ -222,6 +227,11 @@ def mock_pysqueezebox_player(uuid: str) -> MagicMock:
mock_player.generate_image_url_from_track_id = MagicMock(
return_value="http://lms.internal:9000/html/images/favorites.png"
)
mock_player.set_announce_volume = MagicMock(return_value=True)
mock_player.set_announce_timeout = MagicMock(return_value=True)
mock_player.async_play_announcement = AsyncMock(
side_effect=mock_async_play_announcement
)
mock_player.name = TEST_PLAYER_NAME
mock_player.player_id = uuid
mock_player.mode = "stop"

View File

@ -65,7 +65,7 @@
'original_name': None,
'platform': 'squeezebox',
'previous_unique_id': None,
'supported_features': <MediaPlayerEntityFeature: 3078079>,
'supported_features': <MediaPlayerEntityFeature: 4126655>,
'translation_key': None,
'unique_id': 'aa:bb:cc:dd:ee:ff',
'unit_of_measurement': None,
@ -88,7 +88,7 @@
}),
'repeat': <RepeatMode.OFF: 'off'>,
'shuffle': False,
'supported_features': <MediaPlayerEntityFeature: 3078079>,
'supported_features': <MediaPlayerEntityFeature: 4126655>,
'volume_level': 0.01,
}),
'context': <ANY>,

View File

@ -10,9 +10,11 @@ from syrupy import SnapshotAssertion
from homeassistant.components.media_player import (
ATTR_GROUP_MEMBERS,
ATTR_MEDIA_ANNOUNCE,
ATTR_MEDIA_CONTENT_ID,
ATTR_MEDIA_CONTENT_TYPE,
ATTR_MEDIA_ENQUEUE,
ATTR_MEDIA_EXTRA,
ATTR_MEDIA_POSITION,
ATTR_MEDIA_POSITION_UPDATED_AT,
ATTR_MEDIA_REPEAT,
@ -31,6 +33,8 @@ from homeassistant.components.media_player import (
RepeatMode,
)
from homeassistant.components.squeezebox.const import (
ATTR_ANNOUNCE_TIMEOUT,
ATTR_ANNOUNCE_VOLUME,
DISCOVERY_INTERVAL,
DOMAIN,
PLAYER_UPDATE_INTERVAL,
@ -436,6 +440,115 @@ async def test_squeezebox_play(
configured_player.async_play.assert_called_once()
async def test_squeezebox_play_media_with_announce(
hass: HomeAssistant, configured_player: MagicMock
) -> None:
"""Test play service call with announce."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID,
ATTR_MEDIA_ANNOUNCE: True,
},
blocking=True,
)
configured_player.async_load_url.assert_called_once_with(
FAKE_VALID_ITEM_ID, "announce"
)
@pytest.mark.parametrize(
"announce_volume",
["0.2", 0.2],
)
async def test_squeezebox_play_media_with_announce_volume(
hass: HomeAssistant, configured_player: MagicMock, announce_volume: str | int
) -> None:
"""Test play service call with announce."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID,
ATTR_MEDIA_ANNOUNCE: True,
ATTR_MEDIA_EXTRA: {ATTR_ANNOUNCE_VOLUME: announce_volume},
},
blocking=True,
)
configured_player.set_announce_volume.assert_called_once_with(20)
configured_player.async_load_url.assert_called_once_with(
FAKE_VALID_ITEM_ID, "announce"
)
@pytest.mark.parametrize("announce_volume", ["1.1", 1.1, "text", "-1", -1, 0, "0"])
async def test_squeezebox_play_media_with_announce_volume_invalid(
hass: HomeAssistant, configured_player: MagicMock, announce_volume: str | int
) -> None:
"""Test play service call with announce and volume zero."""
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID,
ATTR_MEDIA_ANNOUNCE: True,
ATTR_MEDIA_EXTRA: {ATTR_ANNOUNCE_VOLUME: announce_volume},
},
blocking=True,
)
@pytest.mark.parametrize("announce_timeout", ["-1", "text", -1, 0, "0"])
async def test_squeezebox_play_media_with_announce_timeout_invalid(
hass: HomeAssistant, configured_player: MagicMock, announce_timeout: str | int
) -> None:
"""Test play service call with announce and invalid timeout."""
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID,
ATTR_MEDIA_ANNOUNCE: True,
ATTR_MEDIA_EXTRA: {ATTR_ANNOUNCE_TIMEOUT: announce_timeout},
},
blocking=True,
)
@pytest.mark.parametrize("announce_timeout", ["100", 100])
async def test_squeezebox_play_media_with_announce_timeout(
hass: HomeAssistant, configured_player: MagicMock, announce_timeout: str | int
) -> None:
"""Test play service call with announce."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
SERVICE_PLAY_MEDIA,
{
ATTR_ENTITY_ID: "media_player.test_player",
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
ATTR_MEDIA_CONTENT_ID: FAKE_VALID_ITEM_ID,
ATTR_MEDIA_ANNOUNCE: True,
ATTR_MEDIA_EXTRA: {ATTR_ANNOUNCE_TIMEOUT: announce_timeout},
},
blocking=True,
)
configured_player.set_announce_timeout.assert_called_once_with(100)
configured_player.async_load_url.assert_called_once_with(
FAKE_VALID_ITEM_ID, "announce"
)
async def test_squeezebox_play_pause(
hass: HomeAssistant, configured_player: MagicMock
) -> None:

View File

@ -36,11 +36,11 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'RIVM - Rijksinstituut voor Volksgezondheid en Milieum, Landelijk Meetnet Luchtkwaliteit and World Air Quality Index Project',
'friendly_name': 'de Jongweg, Utrecht Visbility using nephelometry',
'friendly_name': 'de Jongweg, Utrecht Visibility using nephelometry',
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'context': <ANY>,
'entity_id': 'sensor.de_jongweg_utrecht_visbility_using_nephelometry',
'entity_id': 'sensor.de_jongweg_utrecht_visibility_using_nephelometry',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@ -1,5 +1,5 @@
# serializer version: 1
# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxilary_water_pump-entry]
# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxiliary_water_pump-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@ -12,7 +12,7 @@
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': None,
'entity_id': 'binary_sensor.test_model_indoor_unit_auxilary_water_pump',
'entity_id': 'binary_sensor.test_model_indoor_unit_auxiliary_water_pump',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@ -24,7 +24,7 @@
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'Indoor unit auxilary water pump',
'original_name': 'Indoor unit auxiliary water pump',
'platform': 'weheat',
'previous_unique_id': None,
'supported_features': 0,
@ -33,14 +33,14 @@
'unit_of_measurement': None,
})
# ---
# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxilary_water_pump-state]
# name: test_binary_entities[binary_sensor.test_model_indoor_unit_auxiliary_water_pump-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'Test Model Indoor unit auxilary water pump',
'friendly_name': 'Test Model Indoor unit auxiliary water pump',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.test_model_indoor_unit_auxilary_water_pump',
'entity_id': 'binary_sensor.test_model_indoor_unit_auxiliary_water_pump',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,