Compare commits

...

18 Commits

Author SHA1 Message Date
Michael Hansen
a6bb5dbe2a
Add assistant filter to expose entities list command (#138817) 2025-02-18 20:39:44 -05:00
skobow
f8ffbf0506
Set clean_start=True on connect to MQTT broker (#136026)
* Addresses #135443: Set  on connect.

* Make clean start implementation compatible with v2 API

* Add tests

* Do not pass default value for `clean_start` on_connect

* Revert "Do not pass default value for `clean_start` on_connect"

This reverts commit 75806736cf511a6d6b6496454843de34f05f7758.

* Use partial top pass kwargs to mqtt client connect

---------

Co-authored-by: Jan Bouwhuis <jbouwh@users.noreply.github.com>
Co-authored-by: jbouwh <jan@jbsoft.nl>
2025-02-18 23:11:21 +01:00
Andrew Sayre
6613b46071
Add HEOS group volume down/up actions (#138801)
Add group volume down/up actions
2025-02-18 15:53:59 -06:00
Christopher Fenner
1579e90d58
Fix typos in strings.json files (#138601)
* fix codespell issues

* update nextcloud snapshots

* update weheat snapshots

* update waqi snapshots
2025-02-18 22:36:28 +01:00
Franck Nijhof
b71d5737a5
Update Home Assistant base image to 2025.02.1 (#138746)
* Update Home Assistant base image to 2025.02.1

* Require Python 3.13.2 now
2025-02-18 22:34:08 +01:00
J. Diego Rodríguez Royo
8e887f550e
Add connectivity binary sensor to Home Connect (#138795)
Add connectivity binary sensor
2025-02-18 22:08:40 +01:00
J. Diego Rodríguez Royo
1af8b69dd6
Set Home Connect beverages counters as diagnostics (#138798)
Set beverages counters as diagnostics
2025-02-18 22:03:35 +01:00
J. Diego Rodríguez Royo
6ef401251c
Add Home Connect entities that weren't added before (#138796)
Added entities that weren't added before
2025-02-18 22:01:13 +01:00
J. Diego Rodríguez Royo
141bcae793
Add Home Connect to .strict-typing (#138799)
* Add Home Connect to .strict-typing

* Fix mypy errors
2025-02-18 21:50:19 +01:00
J. Nick Koston
8ae52cdc4c
Fix shelly not being able to be setup from user flow when already discovered (#138807)
raise_on_progress=False was missing in the user flow which
made it impossible to configure a shelly by IP when there
was an active discovery because the flow would abort
2025-02-18 22:05:05 +02:00
Robert Resch
13fe2a9929
Reorder Dockerfile to improve caching (#138789) 2025-02-18 20:31:41 +01:00
Robert Resch
df50863872
Bump uv to 0.6.1 (#138790) 2025-02-18 20:28:41 +01:00
SLaks
82ac3e3fdf
Ecobee: Report Humidifier Action (#138756)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-02-18 20:11:37 +01:00
Maciej Bieniek
c48797804d
Add _shelly._tcp to Shelly zeroconf configuration (#138782)
Add _shelly._tcp to zeroconf
2025-02-18 19:57:10 +01:00
Matrix
e6217efcd6
Add switch flex button support. (#137524) 2025-02-18 19:23:27 +01:00
Parker Brown
8dd1e9d101
Add threshold sensor to Aranet (#137291)
* Add threshold level sensor description to Aranet component

* Use Color enum for status options

* Add threshold level sensor tests for Aranet components

* Rename `threshold_level` key to `status`

* Update test to expect 7 sensors instead of 6

* Map sensor status to more human-friendly strings

* Rename `threshold_level` key to `concentration_status`

* Update docstring for  function

* Simplify `get_friendly_status()`

* Rename `concentration_status` to `concentration_level`

* Rename `concentration_status` to `concentration_level` in sensor tests

* Refactor concentration level handling and tests

* Normalize concentration level status values to lowercase

* Add error to translations

* Don't scale status string

* Apply suggestions from code review

Co-authored-by: Shay Levy <levyshay1@gmail.com>

* Rename `concentration_level` to `threshold_indication`

* Update threshold indication translations

* `threshold_indication` → `threshold`

* Capitalize sensor name

Co-Authored-By: Shay Levy <levyshay1@gmail.com>

---------

Co-authored-by: Shay Levy <levyshay1@gmail.com>
2025-02-18 20:16:50 +02:00
Renat Sibgatulin
096468baa4
airq: add more verbose debug logging (#138192) 2025-02-18 19:03:47 +01:00
Andrew Sayre
3659fa4c4e
Add HEOS entity service to set group volume level (#136885) 2025-02-18 11:56:50 -06:00
73 changed files with 987 additions and 164 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,8 @@
ATTR_PASSWORD = "password"
ATTR_USERNAME = "username"
DOMAIN = "heos"
SERVICE_GROUP_VOLUME_SET = "group_volume_set"
SERVICE_GROUP_VOLUME_DOWN = "group_volume_down"
SERVICE_GROUP_VOLUME_UP = "group_volume_up"
SERVICE_SIGN_IN = "sign_in"
SERVICE_SIGN_OUT = "sign_out"

View File

@ -1,5 +1,14 @@
{
"services": {
"group_volume_set": {
"service": "mdi:volume-medium"
},
"group_volume_down": {
"service": "mdi:volume-low"
},
"group_volume_up": {
"service": "mdi:volume-high"
},
"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,22 @@ 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_DOWN,
SERVICE_GROUP_VOLUME_SET,
SERVICE_GROUP_VOLUME_UP,
)
from .coordinator import HeosConfigEntry, HeosCoordinator
PARALLEL_UPDATES = 0
@ -93,6 +104,19 @@ 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",
)
platform.async_register_entity_service(
SERVICE_GROUP_VOLUME_DOWN, None, "async_group_volume_down"
)
platform.async_register_entity_service(
SERVICE_GROUP_VOLUME_UP, None, "async_group_volume_up"
)
def add_entities_callback(players: Sequence[HeosPlayer]) -> None:
"""Add entities for each player."""
@ -346,6 +370,41 @@ 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("group volume down")
async def async_group_volume_down(self) -> None:
"""Turn group volume down for media player."""
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.group_volume_down(self._player.group_id)
@catch_action_error("group volume up")
async def async_group_volume_up(self) -> None:
"""Turn group volume up for media player."""
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.group_volume_up(self._player.group_id)
@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,29 @@
group_volume_set:
target:
entity:
integration: heos
domain: media_player
fields:
volume_level:
required: true
selector:
number:
min: 0
max: 1
step: 0.01
group_volume_down:
target:
entity:
integration: heos
domain: media_player
group_volume_up:
target:
entity:
integration: heos
domain: media_player
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,24 @@
}
},
"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."
}
}
},
"group_volume_down": {
"name": "Turn down group volume",
"description": "Turns down the group volume."
},
"group_volume_up": {
"name": "Turn up group volume",
"description": "Turns up the group volume."
},
"sign_in": {
"name": "Sign in",
"description": "Signs in to a HEOS account.",
@ -94,6 +112,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

@ -432,6 +432,7 @@ def ws_expose_entity(
@websocket_api.websocket_command(
{
vol.Required("type"): "homeassistant/expose_entity/list",
vol.Optional("assistant"): vol.In(KNOWN_ASSISTANTS),
}
)
def ws_list_exposed_entities(
@ -441,10 +442,18 @@ def ws_list_exposed_entities(
result: dict[str, Any] = {}
exposed_entities = hass.data[DATA_EXPOSED_ENTITIES]
required_assistant = msg.get("assistant")
entity_registry = er.async_get(hass)
for entity_id in chain(exposed_entities.entities, entity_registry.entities):
result[entity_id] = {}
entity_settings = async_get_entity_settings(hass, entity_id)
if required_assistant and (
(required_assistant not in entity_settings)
or (not entity_settings[required_assistant].get("should_expose"))
):
# Not exposed to required assistant
continue
result[entity_id] = {}
for assistant, settings in entity_settings.items():
if "should_expose" not in settings:
continue

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

@ -298,12 +298,15 @@ class MqttClientSetup:
from .async_client import AsyncMQTTClient
config = self._config
clean_session: bool | None = None
if (protocol := config.get(CONF_PROTOCOL, DEFAULT_PROTOCOL)) == PROTOCOL_31:
proto = mqtt.MQTTv31
clean_session = True
elif protocol == PROTOCOL_5:
proto = mqtt.MQTTv5
else:
proto = mqtt.MQTTv311
clean_session = True
if (client_id := config.get(CONF_CLIENT_ID)) is None:
# PAHO MQTT relies on the MQTT server to generate random client IDs.
@ -313,6 +316,19 @@ class MqttClientSetup:
self._client = AsyncMQTTClient(
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=client_id,
# See: https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html
# clean_session (bool defaults to None)
# a boolean that determines the client type.
# If True, the broker will remove all information about this client when it
# disconnects. If False, the client is a persistent client and subscription
# information and queued messages will be retained when the client
# disconnects. Note that a client will never discard its own outgoing
# messages on disconnect. Calling connect() or reconnect() will cause the
# messages to be resent. Use reinitialise() to reset a client to its
# original state. The clean_session argument only applies to MQTT versions
# v3.1.1 and v3.1. It is not accepted if the MQTT version is v5.0 - use the
# clean_start argument on connect() instead.
clean_session=clean_session,
protocol=proto,
transport=transport, # type: ignore[arg-type]
reconnect_on_failure=False,
@ -371,6 +387,7 @@ class MQTT:
self.loop = hass.loop
self.config_entry = config_entry
self.conf = conf
self.is_mqttv5 = conf.get(CONF_PROTOCOL, DEFAULT_PROTOCOL) == PROTOCOL_5
self._simple_subscriptions: defaultdict[str, set[Subscription]] = defaultdict(
set
@ -652,14 +669,25 @@ class MQTT:
result: int | None = None
self._available_future = client_available
self._should_reconnect = True
connect_partial = partial(
self._mqttc.connect,
host=self.conf[CONF_BROKER],
port=self.conf.get(CONF_PORT, DEFAULT_PORT),
keepalive=self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE),
# See:
# https://eclipse.dev/paho/files/paho.mqtt.python/html/client.html
# `clean_start` (bool) (MQTT v5.0 only) `True`, `False` or
# `MQTT_CLEAN_START_FIRST_ONLY`. Sets the MQTT v5.0 clean_start flag
# always, never or on the first successful connect only,
# respectively. MQTT session data (such as outstanding messages and
# subscriptions) is cleared on successful connect when the
# clean_start flag is set. For MQTT v3.1.1, the clean_session
# argument of Client should be used for similar result.
clean_start=True if self.is_mqttv5 else mqtt.MQTT_CLEAN_START_FIRST_ONLY,
)
try:
async with self._connection_lock, self._async_connect_in_executor():
result = await self.hass.async_add_executor_job(
self._mqttc.connect,
self.conf[CONF_BROKER],
self.conf.get(CONF_PORT, DEFAULT_PORT),
self.conf.get(CONF_KEEPALIVE, DEFAULT_KEEPALIVE),
)
result = await self.hass.async_add_executor_job(connect_partial)
except (OSError, mqtt.WebsocketConnectionError) as err:
_LOGGER.error("Failed to connect to MQTT server due to exception: %s", err)
self._async_connection_result(False)

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

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

@ -20,6 +20,8 @@ class MockHeos(Heos):
self.get_input_sources: AsyncMock = AsyncMock()
self.get_playlists: AsyncMock = AsyncMock()
self.get_players: AsyncMock = AsyncMock()
self.group_volume_down: AsyncMock = AsyncMock()
self.group_volume_up: AsyncMock = AsyncMock()
self.load_players: AsyncMock = AsyncMock()
self.play_media: AsyncMock = AsyncMock()
self.play_preset_station: AsyncMock = AsyncMock()
@ -34,6 +36,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,12 @@ 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_DOWN,
SERVICE_GROUP_VOLUME_SET,
SERVICE_GROUP_VOLUME_UP,
)
from homeassistant.components.media_player import (
ATTR_GROUP_MEMBERS,
ATTR_INPUT_SOURCE,
@ -724,6 +729,120 @@ 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_group_volume_down(
hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos
) -> None:
"""Test the group volume down 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_DOWN,
{ATTR_ENTITY_ID: "media_player.test_player"},
blocking=True,
)
controller.group_volume_down.assert_called_with(999)
async def test_group_volume_up(
hass: HomeAssistant, config_entry: MockConfigEntry, controller: MockHeos
) -> None:
"""Test the group volume up 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_UP,
{ATTR_ENTITY_ID: "media_player.test_player"},
blocking=True,
)
controller.group_volume_up.assert_called_with(999)
@pytest.mark.parametrize(
"service", [SERVICE_GROUP_VOLUME_DOWN, SERVICE_GROUP_VOLUME_UP]
)
async def test_group_volume_down_up_ungrouped_raises(
hass: HomeAssistant,
config_entry: MockConfigEntry,
controller: MockHeos,
service: str,
) -> None:
"""Test the group volume down and up service raise if player ungrouped."""
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,
{ATTR_ENTITY_ID: "media_player.test_player"},
blocking=True,
)
controller.group_volume_down.assert_not_called()
controller.group_volume_up.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

@ -539,6 +539,70 @@ async def test_list_exposed_entities(
}
async def test_list_exposed_entities_with_filter(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test list exposed entities with filter."""
ws_client = await hass_ws_client(hass)
assert await async_setup_component(hass, "homeassistant", {})
await hass.async_block_till_done()
entry1 = entity_registry.async_get_or_create("test", "test", "unique1")
entry2 = entity_registry.async_get_or_create("test", "test", "unique2")
# Expose 1 to Alexa
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_entity",
"assistants": ["cloud.alexa"],
"entity_ids": [entry1.entity_id],
"should_expose": True,
}
)
response = await ws_client.receive_json()
assert response["success"]
# Expose 2 to Google
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_entity",
"assistants": ["cloud.google_assistant"],
"entity_ids": [entry2.entity_id],
"should_expose": True,
}
)
response = await ws_client.receive_json()
assert response["success"]
# List with filter
await ws_client.send_json_auto_id(
{"type": "homeassistant/expose_entity/list", "assistant": "cloud.alexa"}
)
response = await ws_client.receive_json()
assert response["success"]
assert response["result"] == {
"exposed_entities": {
"test.test_unique1": {"cloud.alexa": True},
},
}
await ws_client.send_json_auto_id(
{
"type": "homeassistant/expose_entity/list",
"assistant": "cloud.google_assistant",
}
)
response = await ws_client.receive_json()
assert response["success"]
assert response["result"] == {
"exposed_entities": {
"test.test_unique2": {"cloud.google_assistant": True},
},
}
async def test_listeners(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:

View File

@ -1271,7 +1271,7 @@ async def test_publish_error(
with patch(
"homeassistant.components.mqtt.async_client.AsyncMQTTClient"
) as mock_client:
mock_client().connect = lambda *args: 1
mock_client().connect = lambda **kwargs: 1
mock_client().publish().rc = 1
assert await hass.config_entries.async_setup(entry.entry_id)
with pytest.raises(HomeAssistantError):
@ -1330,7 +1330,7 @@ async def test_handle_message_callback(
@pytest.mark.parametrize(
("mqtt_config_entry_data", "protocol"),
("mqtt_config_entry_data", "protocol", "clean_session"),
[
(
{
@ -1338,6 +1338,7 @@ async def test_handle_message_callback(
CONF_PROTOCOL: "3.1",
},
3,
True,
),
(
{
@ -1345,6 +1346,7 @@ async def test_handle_message_callback(
CONF_PROTOCOL: "3.1.1",
},
4,
True,
),
(
{
@ -1352,22 +1354,72 @@ async def test_handle_message_callback(
CONF_PROTOCOL: "5",
},
5,
None,
),
],
ids=["v3.1", "v3.1.1", "v5"],
)
async def test_setup_mqtt_client_protocol(
mqtt_mock_entry: MqttMockHAClientGenerator, protocol: int
async def test_setup_mqtt_client_clean_session_and_protocol(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
mqtt_client_mock: MqttMockPahoClient,
protocol: int,
clean_session: bool | None,
) -> None:
"""Test MQTT client protocol setup."""
"""Test MQTT client clean_session and protocol setup."""
with patch(
"homeassistant.components.mqtt.async_client.AsyncMQTTClient"
) as mock_client:
await mqtt_mock_entry()
# check if clean_session was correctly
assert mock_client.call_args[1]["clean_session"] == clean_session
# check if protocol setup was correctly
assert mock_client.call_args[1]["protocol"] == protocol
@pytest.mark.parametrize(
("mqtt_config_entry_data", "connect_args"),
[
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1",
},
call(host="mock-broker", port=1883, keepalive=60, clean_start=3),
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "3.1.1",
},
call(host="mock-broker", port=1883, keepalive=60, clean_start=3),
),
(
{
mqtt.CONF_BROKER: "mock-broker",
CONF_PROTOCOL: "5",
},
call(host="mock-broker", port=1883, keepalive=60, clean_start=True),
),
],
ids=["v3.1", "v3.1.1", "v5"],
)
async def test_setup_mqtt_client_clean_start(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
mqtt_client_mock: MqttMockPahoClient,
connect_args: tuple[Any],
) -> None:
"""Test MQTT client protocol connects with `clean_start` set correctly."""
await mqtt_mock_entry()
# check if clean_start was set correctly
assert len(mqtt_client_mock.connect.mock_calls) == 1
assert mqtt_client_mock.connect.mock_calls[0] == connect_args
@patch("homeassistant.components.mqtt.client.TIMEOUT_ACK", 0.2)
async def test_handle_mqtt_timeout_on_callback(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, mock_debouncer: asyncio.Event

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

@ -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>,