Compare commits

..

15 Commits

Author SHA1 Message Date
Bram Kragten
9767aa5e9b Add context support for triggers.yaml 2025-11-13 14:53:36 +01:00
epenet
b4eb73be98 Improve tests for Tuya alarm control panel (#156481) 2025-11-13 14:44:38 +01:00
wollew
0ac3f776fa set shorthand atrributes for supported_features in velux cover (#156524) 2025-11-13 14:18:20 +01:00
Petar Petrov
8e8a4fff11 Extract grid, gas, and water source validation into separate functions (#156515) 2025-11-13 13:28:25 +01:00
Åke Strandberg
579ffcc64d Add unique_id to senz config_entry (#156472) 2025-11-13 12:26:33 +01:00
Foscam-wangzhengyu
81943fb31d URL-encode the RTSP URL in the Foscam integration (#156488)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-11-13 12:23:28 +01:00
Petro31
70dd0bf12e Modernize template alarm control panel (#156476) 2025-11-13 12:21:03 +01:00
Tom Matheussen
c2d462c1e7 Refactor Satel Integra platforms to use shared base entity (#156499) 2025-11-13 12:20:32 +01:00
epenet
49e050cc60 Redact more DP codes in tuya diagnostics (#156497) 2025-11-13 12:18:43 +01:00
Josef Zweck
f6d829a2f3 Bump pylamarzocco to 2.1.3 (#156501) 2025-11-13 11:54:15 +01:00
Aarni Koskela
e44e3b6f25 Rename RuuviTag BLE to Ruuvi BLE (#156504) 2025-11-13 11:36:50 +01:00
Christopher Fenner
af603661c0 Fix spelling in ViCare integration (#156500) 2025-11-13 10:54:55 +01:00
puddly
35c6113777 Add firmware flashing debug loggers to hardware integrations (#156480)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-13 09:25:00 +01:00
TheJulianJES
3c2f729ddc Fix Z-Wave generating name before setting entity description (#156494) 2025-11-13 08:18:22 +01:00
Erik Montnemery
0d63cb765f Fix lg_netcast tests opening sockets (#156459)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-13 07:44:56 +01:00
50 changed files with 1178 additions and 589 deletions

View File

@@ -386,11 +386,260 @@ def _async_validate_auto_generated_cost_entity(
issues.add_issue(hass, "recorder_untracked", cost_entity_id)
def _validate_grid_source(
hass: HomeAssistant,
source: data.GridSourceType,
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
wanted_statistics_metadata: set[str],
source_result: ValidationIssues,
validate_calls: list[functools.partial[None]],
) -> None:
"""Validate grid energy source."""
flow_from: data.FlowFromGridSourceType
for flow_from in source["flow_from"]:
wanted_statistics_metadata.add(flow_from["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow_from["stat_energy_from"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_cost := flow_from.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := flow_from.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow_from.get("entity_energy_price") is not None
or flow_from.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow_from["stat_energy_from"],
source_result,
)
)
flow_to: data.FlowToGridSourceType
for flow_to in source["flow_to"]:
wanted_statistics_metadata.add(flow_to["stat_energy_to"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow_to["stat_energy_to"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_compensation := flow_to.get("stat_compensation")) is not None:
wanted_statistics_metadata.add(stat_compensation)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_compensation,
source_result,
)
)
elif (entity_energy_price := flow_to.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow_to.get("entity_energy_price") is not None
or flow_to.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow_to["stat_energy_to"],
source_result,
)
)
for power_stat in source.get("power", []):
wanted_statistics_metadata.add(power_stat["stat_rate"])
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
power_stat["stat_rate"],
POWER_USAGE_DEVICE_CLASSES,
POWER_USAGE_UNITS,
POWER_UNIT_ERROR,
source_result,
)
)
def _validate_gas_source(
hass: HomeAssistant,
source: data.GasSourceType,
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
wanted_statistics_metadata: set[str],
source_result: ValidationIssues,
validate_calls: list[functools.partial[None]],
) -> None:
"""Validate gas energy source."""
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
GAS_USAGE_DEVICE_CLASSES,
GAS_USAGE_UNITS,
GAS_UNIT_ERROR,
source_result,
)
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
GAS_PRICE_UNITS,
GAS_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
def _validate_water_source(
hass: HomeAssistant,
source: data.WaterSourceType,
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
wanted_statistics_metadata: set[str],
source_result: ValidationIssues,
validate_calls: list[functools.partial[None]],
) -> None:
"""Validate water energy source."""
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
WATER_USAGE_DEVICE_CLASSES,
WATER_USAGE_UNITS,
WATER_UNIT_ERROR,
source_result,
)
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
WATER_PRICE_UNITS,
WATER_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
"""Validate the energy configuration."""
manager: data.EnergyManager = await data.async_get_manager(hass)
statistics_metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]] = {}
validate_calls = []
validate_calls: list[functools.partial[None]] = []
wanted_statistics_metadata: set[str] = set()
result = EnergyPreferencesValidation()
@@ -404,230 +653,35 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
result.energy_sources.append(source_result)
if source["type"] == "grid":
flow: data.FlowFromGridSourceType | data.FlowToGridSourceType
for flow in source["flow_from"]:
wanted_statistics_metadata.add(flow["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow["stat_energy_from"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_cost := flow.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (
entity_energy_price := flow.get("entity_energy_price")
) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow.get("entity_energy_price") is not None
or flow.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow["stat_energy_from"],
source_result,
)
)
for flow in source["flow_to"]:
wanted_statistics_metadata.add(flow["stat_energy_to"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
flow["stat_energy_to"],
ENERGY_USAGE_DEVICE_CLASSES,
ENERGY_USAGE_UNITS,
ENERGY_UNIT_ERROR,
source_result,
)
)
if (stat_compensation := flow.get("stat_compensation")) is not None:
wanted_statistics_metadata.add(stat_compensation)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_compensation,
source_result,
)
)
elif (
entity_energy_price := flow.get("entity_energy_price")
) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
ENERGY_PRICE_UNITS,
ENERGY_PRICE_UNIT_ERROR,
)
)
if (
flow.get("entity_energy_price") is not None
or flow.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
flow["stat_energy_to"],
source_result,
)
)
for power_stat in source.get("power", []):
wanted_statistics_metadata.add(power_stat["stat_rate"])
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
power_stat["stat_rate"],
POWER_USAGE_DEVICE_CLASSES,
POWER_USAGE_UNITS,
POWER_UNIT_ERROR,
source_result,
)
)
_validate_grid_source(
hass,
source,
statistics_metadata,
wanted_statistics_metadata,
source_result,
validate_calls,
)
elif source["type"] == "gas":
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
GAS_USAGE_DEVICE_CLASSES,
GAS_USAGE_UNITS,
GAS_UNIT_ERROR,
source_result,
)
_validate_gas_source(
hass,
source,
statistics_metadata,
wanted_statistics_metadata,
source_result,
validate_calls,
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
GAS_PRICE_UNITS,
GAS_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
elif source["type"] == "water":
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(
functools.partial(
_async_validate_usage_stat,
hass,
statistics_metadata,
source["stat_energy_from"],
WATER_USAGE_DEVICE_CLASSES,
WATER_USAGE_UNITS,
WATER_UNIT_ERROR,
source_result,
)
_validate_water_source(
hass,
source,
statistics_metadata,
wanted_statistics_metadata,
source_result,
validate_calls,
)
if (stat_cost := source.get("stat_cost")) is not None:
wanted_statistics_metadata.add(stat_cost)
validate_calls.append(
functools.partial(
_async_validate_cost_stat,
hass,
statistics_metadata,
stat_cost,
source_result,
)
)
elif (entity_energy_price := source.get("entity_energy_price")) is not None:
validate_calls.append(
functools.partial(
_async_validate_price_entity,
hass,
entity_energy_price,
source_result,
WATER_PRICE_UNITS,
WATER_PRICE_UNIT_ERROR,
)
)
if (
source.get("entity_energy_price") is not None
or source.get("number_energy_price") is not None
):
validate_calls.append(
functools.partial(
_async_validate_auto_generated_cost_entity,
hass,
source["stat_energy_from"],
source_result,
)
)
elif source["type"] == "solar":
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from urllib.parse import quote
import voluptuous as vol
@@ -152,7 +153,9 @@ class HassFoscamCamera(FoscamEntity, Camera):
async def stream_source(self) -> str | None:
"""Return the stream source."""
if self._rtsp_port:
return f"rtsp://{self._username}:{self._password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
_username = quote(self._username)
_password = quote(self._password)
return f"rtsp://{_username}:{_password}@{self._foscam_session.host}:{self._rtsp_port}/video{self._stream}"
return None

View File

@@ -6,6 +6,12 @@
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_connect_zbt2",
"integration_type": "hardware",
"loggers": [
"bellows",
"universal_silabs_flasher",
"zigpy.serial",
"serial_asyncio_fast"
],
"quality_scale": "bronze",
"usb": [
{

View File

@@ -6,6 +6,12 @@
"dependencies": ["hardware", "usb", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_sky_connect",
"integration_type": "hardware",
"loggers": [
"bellows",
"universal_silabs_flasher",
"zigpy.serial",
"serial_asyncio_fast"
],
"usb": [
{
"description": "*skyconnect v1.0*",

View File

@@ -7,5 +7,11 @@
"dependencies": ["hardware", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow",
"integration_type": "hardware",
"loggers": [
"bellows",
"universal_silabs_flasher",
"zigpy.serial",
"serial_asyncio_fast"
],
"single_config_entry": true
}

View File

@@ -37,5 +37,5 @@
"iot_class": "cloud_push",
"loggers": ["pylamarzocco"],
"quality_scale": "platinum",
"requirements": ["pylamarzocco==2.1.2"]
"requirements": ["pylamarzocco==2.1.3"]
}

View File

@@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Ruuvitag BLE device from a config entry."""
"""Set up Ruuvi BLE device from a config entry."""
address = entry.unique_id
assert address is not None
data = RuuvitagBluetoothDeviceData()

View File

@@ -1,6 +1,6 @@
{
"domain": "ruuvitag_ble",
"name": "RuuviTag BLE",
"name": "Ruuvi BLE",
"bluetooth": [
{
"connectable": false,

View File

@@ -191,7 +191,7 @@ async def async_setup_entry(
entry: config_entries.ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Ruuvitag BLE sensors."""
"""Set up the Ruuvi BLE sensors."""
coordinator: PassiveBluetoothProcessorCoordinator = hass.data[DOMAIN][
entry.entry_id
]
@@ -210,7 +210,7 @@ class RuuvitagBluetoothSensorEntity(
],
SensorEntity,
):
"""Representation of a Ruuvitag BLE sensor."""
"""Representation of a Ruuvi BLE sensor."""
@property
def native_value(self) -> int | float | None:

View File

@@ -13,20 +13,19 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelState,
CodeFormat,
)
from homeassistant.const import CONF_NAME
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_ARM_HOME_MODE,
CONF_PARTITION_NUMBER,
DOMAIN,
SIGNAL_PANEL_MESSAGE,
SUBENTRY_TYPE_PARTITION,
SatelConfigEntry,
)
from .entity import SatelIntegraEntity
ALARM_STATE_MAP = {
AlarmState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
@@ -59,54 +58,49 @@ async def async_setup_entry(
for subentry in partition_subentries:
partition_num: int = subentry.data[CONF_PARTITION_NUMBER]
zone_name: str = subentry.data[CONF_NAME]
arm_home_mode: int = subentry.data[CONF_ARM_HOME_MODE]
async_add_entities(
[
SatelIntegraAlarmPanel(
controller,
zone_name,
arm_home_mode,
partition_num,
config_entry.entry_id,
subentry,
partition_num,
arm_home_mode,
)
],
config_subentry_id=subentry.subentry_id,
)
class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
class SatelIntegraAlarmPanel(SatelIntegraEntity, AlarmControlPanelEntity):
"""Representation of an AlarmDecoder-based alarm panel."""
_attr_code_format = CodeFormat.NUMBER
_attr_should_poll = False
_attr_supported_features = (
AlarmControlPanelEntityFeature.ARM_HOME
| AlarmControlPanelEntityFeature.ARM_AWAY
)
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
controller: AsyncSatel,
device_name: str,
arm_home_mode: int,
partition_id: int,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
arm_home_mode: int,
) -> None:
"""Initialize the alarm panel."""
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
self._arm_home_mode = arm_home_mode
self._partition_id = partition_id
self._satel = controller
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
super().__init__(
controller,
config_entry_id,
subentry,
device_number,
)
self._arm_home_mode = arm_home_mode
async def async_added_to_hass(self) -> None:
"""Update alarm status and register callbacks for future updates."""
self._attr_alarm_state = self._read_alarm_state()
@@ -136,7 +130,7 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
for satel_state, ha_state in ALARM_STATE_MAP.items():
if (
satel_state in self._satel.partition_states
and self._partition_id in self._satel.partition_states[satel_state]
and self._device_number in self._satel.partition_states[satel_state]
):
return ha_state
@@ -152,21 +146,21 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED
)
await self._satel.disarm(code, [self._partition_id])
await self._satel.disarm(code, [self._device_number])
if clear_alarm_necessary:
# Wait 1s before clearing the alarm
await asyncio.sleep(1)
await self._satel.clear_alarm(code, [self._partition_id])
await self._satel.clear_alarm(code, [self._device_number])
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Send arm away command."""
if code:
await self._satel.arm(code, [self._partition_id])
await self._satel.arm(code, [self._device_number])
async def async_alarm_arm_home(self, code: str | None = None) -> None:
"""Send arm home command."""
if code:
await self._satel.arm(code, [self._partition_id], self._arm_home_mode)
await self._satel.arm(code, [self._device_number], self._arm_home_mode)

View File

@@ -8,25 +8,22 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.const import CONF_NAME
from homeassistant.config_entries import ConfigSubentry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_OUTPUT_NUMBER,
CONF_OUTPUTS,
CONF_ZONE_NUMBER,
CONF_ZONE_TYPE,
CONF_ZONES,
DOMAIN,
SIGNAL_OUTPUTS_UPDATED,
SIGNAL_ZONES_UPDATED,
SUBENTRY_TYPE_OUTPUT,
SUBENTRY_TYPE_ZONE,
SatelConfigEntry,
)
from .entity import SatelIntegraEntity
async def async_setup_entry(
@@ -46,18 +43,16 @@ async def async_setup_entry(
for subentry in zone_subentries:
zone_num: int = subentry.data[CONF_ZONE_NUMBER]
zone_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
zone_name: str = subentry.data[CONF_NAME]
async_add_entities(
[
SatelIntegraBinarySensor(
controller,
zone_num,
zone_name,
zone_type,
CONF_ZONES,
SIGNAL_ZONES_UPDATED,
config_entry.entry_id,
subentry,
zone_num,
zone_type,
SIGNAL_ZONES_UPDATED,
)
],
config_subentry_id=subentry.subentry_id,
@@ -71,51 +66,44 @@ async def async_setup_entry(
for subentry in output_subentries:
output_num: int = subentry.data[CONF_OUTPUT_NUMBER]
ouput_type: BinarySensorDeviceClass = subentry.data[CONF_ZONE_TYPE]
output_name: str = subentry.data[CONF_NAME]
async_add_entities(
[
SatelIntegraBinarySensor(
controller,
output_num,
output_name,
ouput_type,
CONF_OUTPUTS,
SIGNAL_OUTPUTS_UPDATED,
config_entry.entry_id,
subentry,
output_num,
ouput_type,
SIGNAL_OUTPUTS_UPDATED,
)
],
config_subentry_id=subentry.subentry_id,
)
class SatelIntegraBinarySensor(BinarySensorEntity):
class SatelIntegraBinarySensor(SatelIntegraEntity, BinarySensorEntity):
"""Representation of an Satel Integra binary sensor."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
controller: AsyncSatel,
device_number: int,
device_name: str,
device_class: BinarySensorDeviceClass,
sensor_type: str,
react_to_signal: str,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
device_class: BinarySensorDeviceClass,
react_to_signal: str,
) -> None:
"""Initialize the binary_sensor."""
self._device_number = device_number
self._attr_unique_id = f"{config_entry_id}_{sensor_type}_{device_number}"
self._react_to_signal = react_to_signal
self._satel = controller
super().__init__(
controller,
config_entry_id,
subentry,
device_number,
)
self._attr_device_class = device_class
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
)
self._react_to_signal = react_to_signal
async def async_added_to_hass(self) -> None:
"""Register callbacks."""

View File

@@ -0,0 +1,58 @@
"""Satel Integra base entity."""
from __future__ import annotations
from typing import TYPE_CHECKING
from satel_integra.satel_integra import AsyncSatel
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_NAME
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import (
DOMAIN,
SUBENTRY_TYPE_OUTPUT,
SUBENTRY_TYPE_PARTITION,
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
SUBENTRY_TYPE_ZONE,
)
SubentryTypeToEntityType: dict[str, str] = {
SUBENTRY_TYPE_PARTITION: "alarm_panel",
SUBENTRY_TYPE_SWITCHABLE_OUTPUT: "switch",
SUBENTRY_TYPE_ZONE: "zones",
SUBENTRY_TYPE_OUTPUT: "outputs",
}
class SatelIntegraEntity(Entity):
"""Defines a base Satel Integra entity."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
def __init__(
self,
controller: AsyncSatel,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
) -> None:
"""Initialize the Satel Integra entity."""
self._satel = controller
self._device_number = device_number
entity_type = SubentryTypeToEntityType[subentry.subentry_type]
if TYPE_CHECKING:
assert entity_type is not None
self._attr_unique_id = f"{config_entry_id}_{entity_type}_{device_number}"
self._attr_device_info = DeviceInfo(
name=subentry.data[CONF_NAME], identifiers={(DOMAIN, self._attr_unique_id)}
)

View File

@@ -7,19 +7,19 @@ from typing import Any
from satel_integra.satel_integra import AsyncSatel
from homeassistant.components.switch import SwitchEntity
from homeassistant.const import CONF_CODE, CONF_NAME
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_CODE
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_SWITCHABLE_OUTPUT_NUMBER,
DOMAIN,
SIGNAL_OUTPUTS_UPDATED,
SUBENTRY_TYPE_SWITCHABLE_OUTPUT,
SatelConfigEntry,
)
from .entity import SatelIntegraEntity
async def async_setup_entry(
@@ -38,47 +38,42 @@ async def async_setup_entry(
for subentry in switchable_output_subentries:
switchable_output_num: int = subentry.data[CONF_SWITCHABLE_OUTPUT_NUMBER]
switchable_output_name: str = subentry.data[CONF_NAME]
async_add_entities(
[
SatelIntegraSwitch(
controller,
switchable_output_num,
switchable_output_name,
config_entry.options.get(CONF_CODE),
config_entry.entry_id,
subentry,
switchable_output_num,
config_entry.options.get(CONF_CODE),
),
],
config_subentry_id=subentry.subentry_id,
)
class SatelIntegraSwitch(SwitchEntity):
"""Representation of an Satel switch."""
_attr_should_poll = False
_attr_has_entity_name = True
_attr_name = None
class SatelIntegraSwitch(SatelIntegraEntity, SwitchEntity):
"""Representation of an Satel Integra switch."""
def __init__(
self,
controller: AsyncSatel,
device_number: int,
device_name: str,
code: str | None,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
code: str | None,
) -> None:
"""Initialize the switch."""
self._device_number = device_number
self._attr_unique_id = f"{config_entry_id}_switch_{device_number}"
self._code = code
self._satel = controller
self._attr_device_info = DeviceInfo(
name=device_name, identifiers={(DOMAIN, self._attr_unique_id)}
super().__init__(
controller,
config_entry_id,
subentry,
device_number,
)
self._code = code
async def async_added_to_hass(self) -> None:
"""Register callbacks."""
self._attr_is_on = self._device_number in self._satel.violated_outputs

View File

@@ -7,6 +7,7 @@ import logging
from aiosenz import SENZAPI, Thermostat
from httpx import RequestError
import jwt
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
@@ -82,3 +83,27 @@ async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool
async def async_unload_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_entry(
hass: HomeAssistant, config_entry: SENZConfigEntry
) -> bool:
"""Migrate old entry."""
# Use sub(ject) from access_token as unique_id
if config_entry.version == 1 and config_entry.minor_version == 1:
token = jwt.decode(
config_entry.data["token"]["access_token"],
options={"verify_signature": False},
)
uid = token["sub"]
hass.config_entries.async_update_entry(
config_entry, unique_id=uid, minor_version=2
)
_LOGGER.info(
"Migration to version %s.%s successful",
config_entry.version,
config_entry.minor_version,
)
return True

View File

@@ -2,6 +2,9 @@
import logging
import jwt
from homeassistant.config_entries import ConfigFlowResult
from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN
@@ -12,6 +15,8 @@ class OAuth2FlowHandler(
):
"""Config flow to handle SENZ OAuth2 authentication."""
VERSION = 1
MINOR_VERSION = 2
DOMAIN = DOMAIN
@property
@@ -23,3 +28,15 @@ class OAuth2FlowHandler(
def extra_authorize_data(self) -> dict:
"""Extra data that needs to be appended to the authorize url."""
return {"scope": "restapi offline_access"}
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Create or update the config entry."""
token = jwt.decode(
data["token"]["access_token"], options={"verify_signature": False}
)
uid = token["sub"]
await self.async_set_unique_id(uid)
self._abort_if_unique_id_configured()
return await super().async_oauth_create_entry(data)

View File

@@ -219,7 +219,6 @@ class AbstractTemplateAlarmControlPanel(
self._attr_code_arm_required: bool = config[CONF_CODE_ARM_REQUIRED]
self._attr_code_format = config[CONF_CODE_FORMAT].value
self._state: AlarmControlPanelState | None = None
self._attr_supported_features: AlarmControlPanelEntityFeature = (
AlarmControlPanelEntityFeature(0)
)
@@ -244,11 +243,6 @@ class AbstractTemplateAlarmControlPanel(
if (action_config := config.get(action_id)) is not None:
yield (action_id, action_config, supported_feature)
@property
def alarm_state(self) -> AlarmControlPanelState | None:
"""Return the state of the device."""
return self._state
async def _async_handle_restored_state(self) -> None:
if (
(last_state := await self.async_get_last_state()) is not None
@@ -256,14 +250,14 @@ class AbstractTemplateAlarmControlPanel(
and last_state.state in _VALID_STATES
# The trigger might have fired already while we waited for stored data,
# then we should not restore state
and self._state is None
and self._attr_alarm_state is None
):
self._state = AlarmControlPanelState(last_state.state)
self._attr_alarm_state = AlarmControlPanelState(last_state.state)
def _handle_state(self, result: Any) -> None:
# Validate state
if result in _VALID_STATES:
self._state = result
self._attr_alarm_state = result
_LOGGER.debug("Valid state - %s", result)
return
@@ -273,7 +267,7 @@ class AbstractTemplateAlarmControlPanel(
self.entity_id,
", ".join(_VALID_STATES),
)
self._state = None
self._attr_alarm_state = None
async def _async_alarm_arm(self, state: Any, script: Script | None, code: Any):
"""Arm the panel to specified state with supplied script."""
@@ -284,7 +278,7 @@ class AbstractTemplateAlarmControlPanel(
)
if self._attr_assumed_state:
self._state = state
self._attr_alarm_state = state
self.async_write_ha_state()
async def async_alarm_arm_away(self, code: str | None = None) -> None:
@@ -376,7 +370,7 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP
@callback
def _update_state(self, result):
if isinstance(result, TemplateError):
self._state = None
self._attr_alarm_state = None
return
self._handle_state(result)
@@ -386,7 +380,7 @@ class StateAlarmControlPanelEntity(TemplateEntity, AbstractTemplateAlarmControlP
"""Set up templates."""
if self._template:
self.add_template_attribute(
"_state", self._template, None, self._update_state
"_attr_alarm_state", self._template, None, self._update_state
)
super()._async_setup_templates()

View File

@@ -709,6 +709,7 @@ class DPCode(StrEnum):
DEW_POINT_TEMP = "dew_point_temp"
DISINFECTION = "disinfection"
DO_NOT_DISTURB = "do_not_disturb"
DOORBELL_PIC = "doorbell_pic"
DOORCONTACT_STATE = "doorcontact_state" # Status of door window sensor
DOORCONTACT_STATE_2 = "doorcontact_state_2"
DOORCONTACT_STATE_3 = "doorcontact_state_3"

View File

@@ -15,6 +15,13 @@ from homeassistant.util import dt as dt_util
from . import TuyaConfigEntry
from .const import DOMAIN, DPCode
_REDACTED_DPCODES = {
DPCode.ALARM_MESSAGE,
DPCode.ALARM_MSG,
DPCode.DOORBELL_PIC,
DPCode.MOVEMENT_DETECT_PIC,
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: TuyaConfigEntry
@@ -95,7 +102,7 @@ def _async_device_as_dict(
# Gather Tuya states
for dpcode, value in device.status.items():
# These statuses may contain sensitive information, redact these..
if dpcode in {DPCode.ALARM_MESSAGE, DPCode.MOVEMENT_DETECT_PIC}:
if dpcode in _REDACTED_DPCODES:
data["status"][dpcode] = REDACTED
continue

View File

@@ -56,37 +56,32 @@ class VeluxCover(VeluxEntity, CoverEntity):
def __init__(self, node: OpeningDevice, config_entry_id: str) -> None:
"""Initialize VeluxCover."""
super().__init__(node, config_entry_id)
# Features common to all covers
self._attr_supported_features = (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.SET_POSITION
| CoverEntityFeature.STOP
)
# Window is the default device class for covers
self._attr_device_class = CoverDeviceClass.WINDOW
if isinstance(node, Awning):
self._attr_device_class = CoverDeviceClass.AWNING
if isinstance(node, Blind):
self._attr_device_class = CoverDeviceClass.BLIND
self._is_blind = True
if isinstance(node, GarageDoor):
self._attr_device_class = CoverDeviceClass.GARAGE
if isinstance(node, Gate):
self._attr_device_class = CoverDeviceClass.GATE
if isinstance(node, RollerShutter):
self._attr_device_class = CoverDeviceClass.SHUTTER
@property
def supported_features(self) -> CoverEntityFeature:
"""Flag supported features."""
supported_features = (
CoverEntityFeature.OPEN
| CoverEntityFeature.CLOSE
| CoverEntityFeature.SET_POSITION
| CoverEntityFeature.STOP
)
if self.current_cover_tilt_position is not None:
supported_features |= (
if isinstance(node, Blind):
self._attr_device_class = CoverDeviceClass.BLIND
self._is_blind = True
self._attr_supported_features |= (
CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
| CoverEntityFeature.SET_TILT_POSITION
| CoverEntityFeature.STOP_TILT
)
return supported_features
@property
def current_cover_position(self) -> int:

View File

@@ -59,7 +59,7 @@ from .utils import (
get_burners,
get_circuits,
get_compressors,
get_condensors,
get_condensers,
get_device_serial,
get_evaporators,
is_supported,
@@ -1237,10 +1237,10 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
),
)
CONDENSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
CONDENSER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
ViCareSensorEntityDescription(
key="condensor_liquid_temperature",
translation_key="condensor_liquid_temperature",
key="condenser_liquid_temperature",
translation_key="condenser_liquid_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_getter=lambda api: api.getCondensorLiquidTemperature(),
@@ -1248,8 +1248,8 @@ CONDENSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="condensor_subcooling_temperature",
translation_key="condensor_subcooling_temperature",
key="condenser_subcooling_temperature",
translation_key="condenser_subcooling_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_getter=lambda api: api.getCondensorSubcoolingTemperature(),
@@ -1303,7 +1303,7 @@ def _build_entities(
(get_circuits(device.api), CIRCUIT_SENSORS),
(get_burners(device.api), BURNER_SENSORS),
(get_compressors(device.api), COMPRESSOR_SENSORS),
(get_condensors(device.api), CONDENSOR_SENSORS),
(get_condensers(device.api), CONDENSER_SENSORS),
(get_evaporators(device.api), EVAPORATOR_SENSORS),
):
entities.extend(

View File

@@ -244,11 +244,11 @@
"compressor_starts": {
"name": "Compressor starts"
},
"condensor_liquid_temperature": {
"name": "Condensor liquid temperature"
"condenser_liquid_temperature": {
"name": "Condenser liquid temperature"
},
"condensor_subcooling_temperature": {
"name": "Condensor subcooling temperature"
"condenser_subcooling_temperature": {
"name": "Condenser subcooling temperature"
},
"dhw_storage_bottom_temperature": {
"name": "DHW storage bottom temperature"

View File

@@ -130,14 +130,14 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone
return []
def get_condensors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]:
"""Return the list of condensors."""
def get_condensers(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]:
"""Return the list of condensers."""
try:
return device.condensors
except PyViCareNotSupportedFeatureError:
_LOGGER.debug("No condensors found")
_LOGGER.debug("No condensers found")
except AttributeError as error:
_LOGGER.debug("No condensors found: %s", error)
_LOGGER.debug("No condensers found: %s", error)
return []

View File

@@ -71,8 +71,6 @@ class ZWaveBaseEntity(Entity):
)
# Entity class attributes
self._attr_name = self.generate_name()
self._attr_unique_id = get_unique_id(driver, self.info.primary_value.value_id)
if isinstance(info, NewZwaveDiscoveryInfo):
self.entity_description = info.entity_description
else:
@@ -80,6 +78,8 @@ class ZWaveBaseEntity(Entity):
self._attr_entity_registry_enabled_default = enabled_default
if (entity_category := info.entity_category) is not None:
self._attr_entity_category = entity_category
self._attr_name = self.generate_name()
self._attr_unique_id = get_unique_id(driver, self.info.primary_value.value_id)
self._attr_assumed_state = self.info.assumed_state
# device is precreated in main handler
self._attr_device_info = DeviceInfo(

View File

@@ -5689,7 +5689,7 @@
"integration_type": "hub",
"config_flow": true,
"iot_class": "local_push",
"name": "RuuviTag BLE"
"name": "Ruuvi BLE"
}
}
},

View File

@@ -25,7 +25,6 @@ from homeassistant.const import (
ATTR_ASSUMED_STATE,
ATTR_ATTRIBUTION,
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_ENTITY_PICTURE,
ATTR_FRIENDLY_NAME,
ATTR_ICON,
@@ -418,7 +417,6 @@ CACHED_PROPERTIES_WITH_ATTR_ = {
"extra_state_attributes",
"force_update",
"icon",
"included_unique_ids",
"name",
"should_poll",
"state",
@@ -526,9 +524,6 @@ class Entity(
__capabilities_updated_at_reported: bool = False
__remove_future: asyncio.Future[None] | None = None
# A list of included entity IDs in case the entity represents a group
_included_entities: list[str] | None = None
# Entity Properties
_attr_assumed_state: bool = False
_attr_attribution: str | None = None
@@ -544,7 +539,6 @@ class Entity(
_attr_extra_state_attributes: dict[str, Any]
_attr_force_update: bool
_attr_icon: str | None
_attr_included_unique_ids: list[str]
_attr_name: str | None
_attr_should_poll: bool = True
_attr_state: StateType = STATE_UNKNOWN
@@ -1091,21 +1085,6 @@ class Entity(
available = self.available # only call self.available once per update cycle
state = self._stringify_state(available)
if available:
if self.included_unique_ids is not None:
entity_registry = er.async_get(self.hass)
self._included_entities = [
entity_id
for included_id in self.included_unique_ids
if (
entity_id := entity_registry.async_get_entity_id(
self.platform.domain,
self.platform.platform_name,
included_id,
)
)
is not None
]
attr[ATTR_ENTITY_ID] = self._included_entities.copy()
if state_attributes := self.state_attributes:
attr |= state_attributes
if extra_state_attributes := self.extra_state_attributes:
@@ -1395,30 +1374,6 @@ class Entity(
async def add_to_platform_finish(self) -> None:
"""Finish adding an entity to a platform."""
entity_registry = er.async_get(self.hass)
async def _handle_entity_registry_updated(event: Event[Any]) -> None:
"""Handle registry create or update event."""
if (
event.data["action"] in {"create", "update"}
and (entry := entity_registry.async_get(event.data["entity_id"]))
and self.included_unique_ids is not None
and entry.unique_id in self.included_unique_ids
) or (
event.data["action"] == "remove"
and self._included_entities is not None
and event.data["entity_id"] in self._included_entities
):
self.async_write_ha_state()
if self.included_unique_ids is not None:
self.async_on_remove(
self.hass.bus.async_listen(
er.EVENT_ENTITY_REGISTRY_UPDATED,
_handle_entity_registry_updated,
)
)
await self.async_internal_added_to_hass()
await self.async_added_to_hass()
self._platform_state = EntityPlatformState.ADDED
@@ -1678,16 +1633,6 @@ class Entity(
self.hass, integration_domain=platform_name, module=type(self).__module__
)
@cached_property
def included_unique_ids(self) -> list[str] | None:
"""Return the list of unique IDs if the entity represents a group.
The corresponding entities will be shown as members in the UI.
"""
if hasattr(self, "_attr_included_unique_ids"):
return self._attr_included_unique_ids
return None
class ToggleEntityDescription(EntityDescription, frozen_or_thawed=True):
"""A class that describes toggle entities."""

2
requirements_all.txt generated
View File

@@ -2128,7 +2128,7 @@ pykwb==0.0.8
pylacrosse==0.4
# homeassistant.components.lamarzocco
pylamarzocco==2.1.2
pylamarzocco==2.1.3
# homeassistant.components.lastfm
pylast==5.1.0

View File

@@ -1772,7 +1772,7 @@ pykrakenapi==0.1.8
pykulersky==0.5.8
# homeassistant.components.lamarzocco
pylamarzocco==2.1.2
pylamarzocco==2.1.3
# homeassistant.components.lastfm
pylast==5.1.0

View File

@@ -32,6 +32,7 @@ FIELD_SCHEMA = vol.Schema(
vol.Optional("default"): exists,
vol.Optional("required"): bool,
vol.Optional(CONF_SELECTOR): selector.validate_selector,
vol.Optional("context"): {str: str},
}
)

View File

@@ -1,4 +1,7 @@
"""The tests for LG NEtcast device triggers."""
"""The tests for LG Netcast device triggers."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
@@ -19,6 +22,13 @@ from . import ENTITY_ID, UNIQUE_ID, setup_lgnetcast
from tests.common import MockConfigEntry, async_get_device_automations
@pytest.fixture(autouse=True)
def mock_lg_netcast() -> Generator[None]:
"""Mock LG Netcast library."""
with patch("homeassistant.components.lg_netcast.LgNetCastClient"):
yield
async def test_get_triggers(
hass: HomeAssistant, device_registry: dr.DeviceRegistry
) -> None:

View File

@@ -1,5 +1,6 @@
"""The tests for LG Netcast device triggers."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
@@ -17,6 +18,13 @@ from . import ENTITY_ID, UNIQUE_ID, setup_lgnetcast
from tests.common import MockEntity, MockEntityPlatform
@pytest.fixture(autouse=True)
def mock_lg_netcast() -> Generator[None]:
"""Mock LG Netcast library."""
with patch("homeassistant.components.lg_netcast.LgNetCastClient"):
yield
async def test_lg_netcast_turn_on_trigger_device_id(
hass: HomeAssistant,
service_calls: list[ServiceCall],

View File

@@ -1 +1 @@
"""Test package for RuuviTag BLE sensor integration."""
"""Test package for Ruuvi BLE sensor integration."""

View File

@@ -1,4 +1,4 @@
"""Fixtures for testing RuuviTag BLE."""
"""Fixtures for testing Ruuvi BLE."""
from homeassistant.helpers.service_info.bluetooth import BluetoothServiceInfo

View File

@@ -1,4 +1,4 @@
"""Test the Ruuvitag BLE sensors."""
"""Test the Ruuvi BLE sensors."""
from __future__ import annotations
@@ -35,7 +35,7 @@ async def test_sensors(
snapshot: SnapshotAssertion,
service_info: BluetoothServiceInfo,
) -> None:
"""Test the RuuviTag BLE sensors."""
"""Test the Ruuvi BLE sensors."""
entry = MockConfigEntry(domain=DOMAIN, unique_id=service_info.address)
entry.add_to_hass(hass)

View File

@@ -14,9 +14,10 @@ from homeassistant.components.application_credentials import (
)
from homeassistant.components.senz.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.setup import async_setup_component
from .const import CLIENT_ID, CLIENT_SECRET
from .const import CLIENT_ID, CLIENT_SECRET, ENTRY_UNIQUE_ID
from tests.common import (
MockConfigEntry,
@@ -63,7 +64,7 @@ def mock_expires_at() -> float:
def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry:
"""Return the default mocked config entry."""
config_entry = MockConfigEntry(
minor_version=1,
minor_version=2,
domain=DOMAIN,
title="Senz test",
data={
@@ -77,6 +78,7 @@ def mock_config_entry(hass: HomeAssistant, expires_at: float) -> MockConfigEntry
},
},
entry_id="senz_test",
unique_id=ENTRY_UNIQUE_ID,
)
config_entry.add_to_hass(hass)
return config_entry
@@ -109,3 +111,20 @@ async def setup_credentials(hass: HomeAssistant) -> None:
),
DOMAIN,
)
@pytest.fixture
async def access_token(hass: HomeAssistant) -> str:
"""Return a valid access token."""
return config_entry_oauth2_flow._encode_jwt(
hass,
{
"sub": ENTRY_UNIQUE_ID,
"aud": [],
"scp": [
"rest_api",
"offline_access",
],
"ou_code": "NA",
},
)

View File

@@ -2,3 +2,5 @@
CLIENT_ID = "test_client_id"
CLIENT_SECRET = "test_client_secret"
ENTRY_UNIQUE_ID = "test_unique_id"

View File

@@ -12,11 +12,13 @@ from homeassistant.components.application_credentials import (
)
from homeassistant.components.senz.const import DOMAIN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_entry_oauth2_flow
from homeassistant.setup import async_setup_component
from .const import CLIENT_ID, CLIENT_SECRET
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@@ -26,6 +28,7 @@ async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
access_token: str,
) -> None:
"""Check full flow."""
await async_setup_component(hass, DOMAIN, {})
@@ -61,7 +64,7 @@ async def test_full_flow(
TOKEN_ENDPOINT,
json={
"refresh_token": "mock-refresh-token",
"access_token": "mock-access-token",
"access_token": access_token,
"type": "Bearer",
"expires_in": 60,
},
@@ -74,3 +77,52 @@ async def test_full_flow(
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
@pytest.mark.usefixtures("current_request_with_host")
async def test_duplicate_flow(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
mock_config_entry: MockConfigEntry,
access_token: str,
) -> None:
"""Check full flow with duplicate entry."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
state = config_entry_oauth2_flow._encode_jwt(
hass,
{
"flow_id": result["flow_id"],
"redirect_uri": "https://example.com/auth/external/callback",
},
)
assert result["url"] == (
f"{AUTHORIZATION_ENDPOINT}?response_type=code&client_id={CLIENT_ID}"
"&redirect_uri=https://example.com/auth/external/callback"
f"&state={state}&scope=restapi+offline_access"
)
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == 200
assert resp.headers["content-type"] == "text/html; charset=utf-8"
aioclient_mock.post(
TOKEN_ENDPOINT,
json={
"refresh_token": "mock-refresh-token",
"access_token": access_token,
"type": "Bearer",
"expires_in": 60,
},
)
with patch("homeassistant.components.senz.async_setup_entry", return_value=True):
result2 = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result2["type"] is FlowResultType.ABORT
assert result2["reason"] == "already_configured"

View File

@@ -2,6 +2,7 @@
from unittest.mock import MagicMock, patch
from homeassistant.components.senz.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers.config_entry_oauth2_flow import (
@@ -9,6 +10,7 @@ from homeassistant.helpers.config_entry_oauth2_flow import (
)
from . import setup_integration
from .const import ENTRY_UNIQUE_ID
from tests.common import MockConfigEntry
@@ -43,3 +45,36 @@ async def test_oauth_implementation_not_available(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_migrate_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_senz_client: MagicMock,
expires_at: float,
access_token: str,
) -> None:
"""Test migration of config entry."""
mock_entry_v1_1 = MockConfigEntry(
version=1,
minor_version=1,
domain=DOMAIN,
title="SENZ test",
data={
"auth_implementation": DOMAIN,
"token": {
"access_token": access_token,
"scope": "rest_api offline_access",
"expires_in": 86399,
"refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f",
"token_type": "Bearer",
"expires_at": expires_at,
},
},
entry_id="senz_test",
)
await setup_integration(hass, mock_entry_v1_1)
assert mock_entry_v1_1.version == 1
assert mock_entry_v1_1.minor_version == 2
assert mock_entry_v1_1.unique_id == ENTRY_UNIQUE_ID

View File

@@ -192,7 +192,7 @@ async def _create_device(hass: HomeAssistant, mock_device_code: str) -> Customer
device.function = {
key: DeviceFunction(
code=value.get("code"),
code=key,
type=value["type"],
values=json_dumps(value["value"]),
)
@@ -200,7 +200,7 @@ async def _create_device(hass: HomeAssistant, mock_device_code: str) -> Customer
}
device.status_range = {
key: DeviceStatusRange(
code=value.get("code"),
code=key,
type=value["type"],
values=json_dumps(value["value"]),
)

View File

@@ -211,7 +211,7 @@
"switch_kb_light": false,
"telnet_state": "sim_card_no",
"muffling": false,
"alarm_msg": "AFMAZQBuAHMAbwByACAATABvAHcAIABCAGEAdAB0AGUAcgB5AAoAWgBvAG4AZQA6ADAAMAA1AEUAbgB0AHIAYQBuAGMAZQ==",
"alarm_msg": "**REDACTED**",
"switch_alarm_propel": true,
"alarm_delay_time": 20,
"master_state": "normal",

View File

@@ -217,7 +217,7 @@
"wireless_lowpower": 10,
"wireless_awake": false,
"pir_switch": 3,
"doorbell_pic": "",
"doorbell_pic": "**REDACTED**",
"basic_device_volume": 51,
"humanoid_filter": false,
"alarm_message": "**REDACTED**",

View File

@@ -120,8 +120,8 @@
"sd_format_state": 0,
"motion_switch": false,
"doorbell_active": "",
"doorbell_pic": "aHR0cHM6Ly90eS1ldS1zdG9yYWdlMzAtcGljLnMzLmV1LWNlbnRyYWwtMS5hbWF6b25hd3MuY29tL2U0ODYwMy0yMjU2NjYxOC1zempzYjU0ZDE2ZGI0ZTQ3OTAxYS9kZXRlY3QvMTc2MjE5OTIyMS5qcGVnP1gtQW16LVNlY3VyaXR5LVRva2VuPUZ3b0daWEl2WVhkekVLMyUyRiUyRiUyRiUyRiUyRiUyRiUyRiUyRiUyRiUyRndFYURDUmJiZDNWWldORmtsWUliQ0tDQXZCZCUyQnEwY2EzRURkZzdONTJqUDhmWUI3WVNSS0huSDNnRXZDRjh6OHpMSU92bkZrdG1UQWFLVldSNkxsMDlMMTJ6b09wR2ptekwwRGIyR1NRSG1uSmJNZXRhSm9nWlRQeGI4eGdMbTRwVkhidTkyZndib29UVVllMUwycmhNJTJCdiUyQkFtVG9DTVdwWE9sNThXUDVwZDAwSmdIWGlBUzVGWnhndVR5UWNJcmxFeG5JeW4wYzgwa0VRMjlVa3d2VThMRVpDeUtwTFlIRjJlYTElMkYlMkZPaUk2b1hrdVF3TU0lMkZCWHMlMkJYMWVYYWdnJTJGaW1oRUVhJTJCQ1REODUlMkYlMkZlSHVqZm1KRSUyQnIyeERkdmgwSUJPTFMwYWc1Zm9EbyUyRjZpRHpXMHNKZE1tTjdPNVhiMnMwRnM4MUxwWG5wTXdKRFRxbUklMkJFSDVyYzlxT0NHemY1SUZqbnZZMGF3TjY1blVsMWlpeWphVElCaklwZWVva2htU1F6WlBVJTJGdERzRHlGYUJRRXFWNjkyemlGdVluWHozdnlqdHlzOU5JWG1aJTJGd1hRaTglM0QmWC1BbXotQWxnb3JpdGhtPUFXUzQtSE1BQy1TSEEyNTYmWC1BbXotQ3JlZGVudGlhbD1BU0lBVVRQTVVKSkpRTlVFUEozSCUyRjIwMjUxMTAzJTJGZXUtY2VudHJhbC0xJTJGczMlMkZhd3M0X3JlcXVlc3QmWC1BbXotRGF0ZT0yMDI1MTEwM1QxOTQ3MDRaJlgtQW16LUV4cGlyZXM9NjAmWC1BbXotU2lnbmVkSGVhZGVycz1ob3N0JmJ1Y2tldD10eS1ldS1zdG9yYWdlMzAtcGljJnY9MS4wJlgtQW16LVNpZ25hdHVyZT05YTFlZTYyNWVlMGM5NmQ5NzViMjg2OGQxOGNlOTA3YzU0YTExNjgyNGMzZjkwYzI3YTlmNTNjYjNhN2E0MjA0",
"doorbell_pic": "**REDACTED**",
"device_restart": false,
"alarm_message": "eyJ2IjoiNS4wIiwiZmlsZXMiOlt7ImRhdGEiOiJhMThiNDM0YmJmZDY1NGM3N2UzNTc2MWRlMDgyZTc2OGZjM2JmYmQ2NThlZDAyMGIwZGJhZjQ2OTE1YTEwY2NjZDI5YjUxZTY1YjBkNjJiMzAxNmVlZDU0YjU1MTU1ZjE1NzkwNTk2ZDc2YzgwYWFlOWU3ODQ0N2QwYzFlOWNmNmIzMWRlN2ZiOWQyOWU4ZWEwODhlYzAxOGJhYTRhNWMzZjBlMDFmYThiOTRiNGQzYWVkNDk4ZGIwOTUyOTc1ZWQ5ODY2OTNlNmM1NDMyYWY3YTE5N2FiYTA3ZWE3YjJkZGNmZDRjMzQ2N2Q5ZDAwMmJkMDc4OWQ0OTYzNWI1NzkyIiwia2V5SWQiOiJkZWZhdWx0IiwiaXYiOiJjN2JiMTk2Mjc1MWRmOThhZWRiM2VjMGU3Mjk4MWVmMCJ9XSwiY21kIjoiaXBjX2Rvb3JiZWxsIiwidHlwZSI6ImltYWdlIn0="
"alarm_message": "**REDACTED**"
}
}

View File

@@ -364,7 +364,7 @@
"record_switch": true,
"record_mode": 1,
"pir_switch": 2,
"doorbell_pic": "",
"doorbell_pic": "**REDACTED**",
"siren_switch": false,
"basic_device_volume": 1,
"motion_tracking": true,

View File

@@ -1,4 +1,305 @@
# serializer version: 1
# name: test_device_diagnostics[mal_gyitctrjj1kefxp2]
dict({
'active_time': '2024-12-02T20:08:56+00:00',
'category': 'mal',
'create_time': '2024-12-02T20:08:56+00:00',
'disabled_by': None,
'disabled_polling': False,
'endpoint': 'https://apigw.tuyaeu.com',
'function': dict({
'alarm_delay_time': dict({
'type': 'Integer',
'value': '{"unit":"s","min":0,"max":999,"scale":0,"step":1}',
}),
'alarm_time': dict({
'type': 'Integer',
'value': '{"unit":"min","min":0,"max":999,"scale":0,"step":1}',
}),
'delay_set': dict({
'type': 'Integer',
'value': '{"unit":"s","min":0,"max":999,"scale":0,"step":1}',
}),
'master_mode': dict({
'type': 'Enum',
'value': '{"range":["disarmed","arm","home","sos"]}',
}),
'master_state': dict({
'type': 'Enum',
'value': '{"range":["normal","alarm"]}',
}),
'muffling': dict({
'type': 'Boolean',
'value': '{}',
}),
'sub_admin': dict({
'type': 'Raw',
'value': '{}',
}),
'sub_class': dict({
'type': 'Enum',
'value': '{"range":["remote_controller","detector"]}',
}),
'switch_alarm_light': dict({
'type': 'Boolean',
'value': '{}',
}),
'switch_alarm_propel': dict({
'type': 'Boolean',
'value': '{}',
}),
'switch_alarm_sound': dict({
'type': 'Boolean',
'value': '{}',
}),
'switch_kb_light': dict({
'type': 'Boolean',
'value': '{}',
}),
'switch_kb_sound': dict({
'type': 'Boolean',
'value': '{}',
}),
'switch_mode_sound': dict({
'type': 'Boolean',
'value': '{}',
}),
}),
'home_assistant': dict({
'disabled': False,
'disabled_by': None,
'entities': list([
dict({
'device_class': None,
'disabled': False,
'disabled_by': None,
'entity_category': None,
'icon': None,
'original_device_class': None,
'original_icon': None,
'state': dict({
'attributes': dict({
'changed_by': None,
'code_arm_required': False,
'code_format': None,
'friendly_name': 'Multifunction alarm',
'supported_features': 11,
}),
'entity_id': 'alarm_control_panel.multifunction_alarm',
'state': 'disarmed',
}),
'unit_of_measurement': None,
}),
dict({
'device_class': None,
'disabled': False,
'disabled_by': None,
'entity_category': 'config',
'icon': None,
'original_device_class': 'duration',
'original_icon': None,
'state': dict({
'attributes': dict({
'device_class': 'duration',
'friendly_name': 'Multifunction alarm Arm delay',
'max': 999.0,
'min': 0.0,
'mode': 'auto',
'step': 1.0,
'unit_of_measurement': 's',
}),
'entity_id': 'number.multifunction_alarm_arm_delay',
'state': '15.0',
}),
'unit_of_measurement': 's',
}),
dict({
'device_class': None,
'disabled': False,
'disabled_by': None,
'entity_category': 'config',
'icon': None,
'original_device_class': 'duration',
'original_icon': None,
'state': dict({
'attributes': dict({
'device_class': 'duration',
'friendly_name': 'Multifunction alarm Alarm delay',
'max': 999.0,
'min': 0.0,
'mode': 'auto',
'step': 1.0,
'unit_of_measurement': 's',
}),
'entity_id': 'number.multifunction_alarm_alarm_delay',
'state': '20.0',
}),
'unit_of_measurement': 's',
}),
dict({
'device_class': None,
'disabled': False,
'disabled_by': None,
'entity_category': 'config',
'icon': None,
'original_device_class': 'duration',
'original_icon': None,
'state': dict({
'attributes': dict({
'device_class': 'duration',
'friendly_name': 'Multifunction alarm Siren duration',
'max': 999.0,
'min': 0.0,
'mode': 'auto',
'step': 1.0,
'unit_of_measurement': 'min',
}),
'entity_id': 'number.multifunction_alarm_siren_duration',
'state': '3.0',
}),
'unit_of_measurement': 'min',
}),
dict({
'device_class': None,
'disabled': False,
'disabled_by': None,
'entity_category': 'config',
'icon': None,
'original_device_class': None,
'original_icon': None,
'state': dict({
'attributes': dict({
'friendly_name': 'Multifunction alarm Arm beep',
}),
'entity_id': 'switch.multifunction_alarm_arm_beep',
'state': 'on',
}),
'unit_of_measurement': None,
}),
dict({
'device_class': None,
'disabled': False,
'disabled_by': None,
'entity_category': 'config',
'icon': None,
'original_device_class': None,
'original_icon': None,
'state': dict({
'attributes': dict({
'friendly_name': 'Multifunction alarm Siren',
}),
'entity_id': 'switch.multifunction_alarm_siren',
'state': 'on',
}),
'unit_of_measurement': None,
}),
]),
'name': 'Multifunction alarm',
'name_by_user': None,
}),
'id': '2pxfek1jjrtctiyglam',
'mqtt_connected': True,
'name': 'Multifunction alarm',
'online': True,
'product_id': 'gyitctrjj1kefxp2',
'product_name': 'Multifunction alarm',
'set_up': True,
'status': dict({
'alarm_delay_time': 20,
'alarm_msg': '**REDACTED**',
'alarm_time': 3,
'delay_set': 15,
'master_mode': 'disarmed',
'master_state': 'normal',
'muffling': False,
'sub_admin': 'AgEFCggC////HABLAGkAdABjAGgAZQBuACAAUwBtAG8AawBlACBjAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADFkAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADJlAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADNmAAL///8gAHUAbgBkAGUAbABlAHQAYQBiAGwAZQA6AEUATwBMADQ=',
'sub_class': 'remote_controller',
'sub_state': 'normal',
'switch_alarm_light': True,
'switch_alarm_propel': True,
'switch_alarm_sound': True,
'switch_kb_light': False,
'switch_kb_sound': False,
'switch_mode_sound': True,
'telnet_state': 'sim_card_no',
}),
'status_range': dict({
'alarm_delay_time': dict({
'type': 'Integer',
'value': '{"unit":"s","min":0,"max":999,"scale":0,"step":1}',
}),
'alarm_msg': dict({
'type': 'Raw',
'value': '{}',
}),
'alarm_time': dict({
'type': 'Integer',
'value': '{"unit":"min","min":0,"max":999,"scale":0,"step":1}',
}),
'delay_set': dict({
'type': 'Integer',
'value': '{"unit":"s","min":0,"max":999,"scale":0,"step":1}',
}),
'master_mode': dict({
'type': 'Enum',
'value': '{"range":["disarmed","arm","home","sos"]}',
}),
'master_state': dict({
'type': 'Enum',
'value': '{"range":["normal","alarm"]}',
}),
'muffling': dict({
'type': 'Boolean',
'value': '{}',
}),
'sub_admin': dict({
'type': 'Raw',
'value': '{}',
}),
'sub_class': dict({
'type': 'Enum',
'value': '{"range":["remote_controller","detector"]}',
}),
'sub_state': dict({
'type': 'Enum',
'value': '{"range":["normal","alarm","fault","others"]}',
}),
'switch_alarm_light': dict({
'type': 'Boolean',
'value': '{}',
}),
'switch_alarm_propel': dict({
'type': 'Boolean',
'value': '{}',
}),
'switch_alarm_sound': dict({
'type': 'Boolean',
'value': '{}',
}),
'switch_kb_light': dict({
'type': 'Boolean',
'value': '{}',
}),
'switch_kb_sound': dict({
'type': 'Boolean',
'value': '{}',
}),
'switch_mode_sound': dict({
'type': 'Boolean',
'value': '{}',
}),
'telnet_state': dict({
'type': 'Enum',
'value': '{"range":["normal","network_no","phone_no","sim_card_no","network_search","signal_level_1","signal_level_2","signal_level_3","signal_level_4","signal_level_5"]}',
}),
}),
'sub': False,
'support_local': True,
'terminal_id': '7cd96aff-6ec8-4006-b093-3dbff7947591',
'time_zone': '+02:00',
'update_time': '2024-12-02T20:08:56+00:00',
})
# ---
# name: test_device_diagnostics[rqbj_4iqe2hsfyd86kwwc]
dict({
'active_time': '2025-06-24T20:33:10+00:00',
@@ -8,7 +309,15 @@
'disabled_polling': False,
'endpoint': 'https://apigw.tuyaeu.com',
'function': dict({
'null': dict({
'alarm_time': dict({
'type': 'Integer',
'value': '{"unit":"s","min":0,"max":3600,"scale":0,"step":1}',
}),
'muffling': dict({
'type': 'Boolean',
'value': '{}',
}),
'self_checking': dict({
'type': 'Boolean',
'value': '{}',
}),
@@ -74,7 +383,27 @@
'self_checking': False,
}),
'status_range': dict({
'null': dict({
'alarm_time': dict({
'type': 'Integer',
'value': '{"unit":"s","min":0,"max":3600,"scale":0,"step":1}',
}),
'checking_result': dict({
'type': 'Enum',
'value': '{"range":["checking","check_success","check_failure","others"]}',
}),
'gas_sensor_status': dict({
'type': 'Enum',
'value': '{"range":["alarm","normal"]}',
}),
'gas_sensor_value': dict({
'type': 'Integer',
'value': '{"unit":"ppm","min":0,"max":999,"scale":0,"step":1}',
}),
'muffling': dict({
'type': 'Boolean',
'value': '{}',
}),
'self_checking': dict({
'type': 'Boolean',
'value': '{}',
}),
@@ -94,7 +423,15 @@
'category': 'rqbj',
'create_time': '2025-06-24T20:33:10+00:00',
'function': dict({
'null': dict({
'alarm_time': dict({
'type': 'Integer',
'value': '{"unit":"s","min":0,"max":3600,"scale":0,"step":1}',
}),
'muffling': dict({
'type': 'Boolean',
'value': '{}',
}),
'self_checking': dict({
'type': 'Boolean',
'value': '{}',
}),
@@ -159,7 +496,27 @@
'self_checking': False,
}),
'status_range': dict({
'null': dict({
'alarm_time': dict({
'type': 'Integer',
'value': '{"unit":"s","min":0,"max":3600,"scale":0,"step":1}',
}),
'checking_result': dict({
'type': 'Enum',
'value': '{"range":["checking","check_success","check_failure","others"]}',
}),
'gas_sensor_status': dict({
'type': 'Enum',
'value': '{"range":["alarm","normal"]}',
}),
'gas_sensor_value': dict({
'type': 'Integer',
'value': '{"unit":"ppm","min":0,"max":999,"scale":0,"step":1}',
}),
'muffling': dict({
'type': 'Boolean',
'value': '{}',
}),
'self_checking': dict({
'type': 'Boolean',
'value': '{}',
}),

View File

@@ -80,54 +80,63 @@ async def test_service(
mock_manager.send_commands.assert_called_once_with(mock_device.id, [command])
@patch("homeassistant.components.tuya.PLATFORMS", [Platform.ALARM_CONTROL_PANEL])
@pytest.mark.parametrize(
"mock_device_code",
["mal_gyitctrjj1kefxp2"],
)
async def test_alarm_state_triggered(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
) -> None:
"""Test alarm state returns TRIGGERED for non-battery alarms."""
entity_id = "alarm_control_panel.multifunction_alarm"
# Set up alarm state without battery warning
mock_device.status["master_state"] = "alarm"
mock_device.status["alarm_msg"] = (
"AFQAZQBzAHQAIABTAGUAbgBzAG8Acg==" # "Test Sensor" in UTF-16BE
)
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
assert state.state == AlarmControlPanelState.TRIGGERED
@pytest.mark.parametrize(
"mock_device_code",
["mal_gyitctrjj1kefxp2"],
("status_updates", "expected_state"),
[
(
{"master_mode": "disarmed"},
AlarmControlPanelState.DISARMED,
),
(
{"master_mode": "arm"},
AlarmControlPanelState.ARMED_AWAY,
),
(
{"master_mode": "home"},
AlarmControlPanelState.ARMED_HOME,
),
(
{"master_mode": "sos"},
AlarmControlPanelState.TRIGGERED,
),
(
{
"master_mode": "home",
"master_state": "alarm",
# "Test Sensor" in UTF-16BE
"alarm_msg": "AFQAZQBzAHQAIABTAGUAbgBzAG8Acg==",
},
AlarmControlPanelState.TRIGGERED,
),
(
{
"master_mode": "home",
"master_state": "alarm",
# "Sensor Low Battery Test Sensor" in UTF-16BE
"alarm_msg": "AFMAZQBuAHMAbwByACAATABvAHcAIABCAGEAdAB0AGUAcgB5ACAAVABlAHMAdAAgAFMAZQBuAHMAbwBy",
},
AlarmControlPanelState.ARMED_HOME,
),
],
)
async def test_alarm_state_battery_warning(
async def test_state(
hass: HomeAssistant,
mock_manager: Manager,
mock_config_entry: MockConfigEntry,
mock_device: CustomerDevice,
status_updates: dict[str, Any],
expected_state: str,
) -> None:
"""Test alarm state ignores battery warnings."""
"""Test state."""
entity_id = "alarm_control_panel.multifunction_alarm"
# Set up alarm state with battery warning
mock_device.status["master_state"] = "alarm"
mock_device.status["alarm_msg"] = (
"AFMAZQBuAHMAbwByACAATABvAHcAIABCAGEAdAB0AGUAcgB5ACAAVABlAHMAdAAgAFMAZQBuAHMAbwBy" # "Sensor Low Battery Test Sensor" in UTF-16BE
)
mock_device.status.update(status_updates)
await initialize_entry(hass, mock_manager, mock_config_entry, mock_device)
state = hass.states.get(entity_id)
assert state is not None, f"{entity_id} does not exist"
# Should not be triggered for battery warnings
assert state.state != AlarmControlPanelState.TRIGGERED
assert state.state == expected_state

View File

@@ -42,7 +42,13 @@ async def test_entry_diagnostics(
)
@pytest.mark.parametrize("mock_device_code", ["rqbj_4iqe2hsfyd86kwwc"])
@pytest.mark.parametrize(
"mock_device_code",
[
"mal_gyitctrjj1kefxp2",
"rqbj_4iqe2hsfyd86kwwc",
],
)
async def test_device_diagnostics(
hass: HomeAssistant,
mock_manager: Manager,

View File

@@ -6,6 +6,7 @@ from syrupy.assertion import SnapshotAssertion
from tuya_sharing import CustomerDevice, Manager
from homeassistant.components.tuya.const import DOMAIN
from homeassistant.components.tuya.diagnostics import _REDACTED_DPCODES
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
@@ -65,3 +66,11 @@ async def test_fixtures_valid(hass: HomeAssistant) -> None:
assert key not in details, (
f"Please remove data[`'{key}']` from {device_code}.json"
)
if "status" in details:
statuses = details["status"]
for key in statuses:
if key in _REDACTED_DPCODES:
assert statuses[key] == "**REDACTED**", (
f"Please mark `data['status']['{key}']` as `**REDACTED**`"
f" in {device_code}.json"
)

View File

@@ -0,0 +1,52 @@
# serializer version: 1
# name: test_cover_setup[cover.test_window-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'cover',
'entity_category': None,
'entity_id': 'cover.test_window',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <CoverDeviceClass.WINDOW: 'window'>,
'original_icon': None,
'original_name': None,
'platform': 'velux',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <CoverEntityFeature: 15>,
'translation_key': None,
'unique_id': '123456789',
'unit_of_measurement': None,
})
# ---
# name: test_cover_setup[cover.test_window-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'current_position': 70,
'device_class': 'window',
'friendly_name': 'Test Window',
'supported_features': <CoverEntityFeature: 15>,
}),
'context': <ANY>,
'entity_id': 'cover.test_window',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'open',
})
# ---

View File

@@ -1,32 +1,69 @@
"""Tests for the Velux cover platform."""
from unittest.mock import AsyncMock, patch
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.velux import DOMAIN
from homeassistant.const import STATE_CLOSED, STATE_OPEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from . import update_callback_entity
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform
@pytest.mark.usefixtures("mock_pyvlx")
@pytest.fixture
def platform() -> Platform:
"""Fixture to specify platform to test."""
return Platform.COVER
@pytest.mark.usefixtures("setup_integration")
async def test_cover_setup(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
device_registry: dr.DeviceRegistry,
snapshot: SnapshotAssertion,
) -> None:
"""Snapshot the cover entity (registry + state)."""
await snapshot_platform(
hass,
entity_registry,
snapshot,
mock_config_entry.entry_id,
)
# Get the cover entity setup and test device association
entity_entries = er.async_entries_for_config_entry(
entity_registry, mock_config_entry.entry_id
)
assert len(entity_entries) == 1
entry = entity_entries[0]
assert entry.device_id is not None
device_entry = device_registry.async_get(entry.device_id)
assert device_entry is not None
assert (DOMAIN, f"{123456789}") in device_entry.identifiers
assert device_entry.via_device_id is not None
via_device_entry = device_registry.async_get(device_entry.via_device_id)
assert via_device_entry is not None
assert (
DOMAIN,
f"gateway_{mock_config_entry.entry_id}",
) in via_device_entry.identifiers
@pytest.mark.usefixtures("setup_integration")
async def test_cover_closed(
hass: HomeAssistant,
mock_window: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test the cover closed state."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.velux.PLATFORMS", [Platform.COVER]):
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
test_entity_id = "cover.test_window"
# Initial state should be open

View File

@@ -3283,7 +3283,7 @@
'state': '5067',
})
# ---
# name: test_all_entities[sensor.model2_condensor_subcooling_temperature-entry]
# name: test_all_entities[sensor.model2_condenser_subcooling_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
@@ -3296,7 +3296,7 @@
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.model2_condensor_subcooling_temperature',
'entity_id': 'sensor.model2_condenser_subcooling_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
@@ -3311,25 +3311,25 @@
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Condensor subcooling temperature',
'original_name': 'Condenser subcooling temperature',
'platform': 'vicare',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'condensor_subcooling_temperature',
'unique_id': 'gateway2_################-condensor_subcooling_temperature-0',
'translation_key': 'condenser_subcooling_temperature',
'unique_id': 'gateway2_################-condenser_subcooling_temperature-0',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_all_entities[sensor.model2_condensor_subcooling_temperature-state]
# name: test_all_entities[sensor.model2_condenser_subcooling_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'temperature',
'friendly_name': 'model2 Condensor subcooling temperature',
'friendly_name': 'model2 Condenser subcooling temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.model2_condensor_subcooling_temperature',
'entity_id': 'sensor.model2_condenser_subcooling_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,

View File

@@ -6,7 +6,7 @@ import dataclasses
from datetime import timedelta
import logging
import threading
from typing import Any, final
from typing import Any
from unittest.mock import MagicMock, PropertyMock, patch
from freezegun.api import FrozenDateTimeFactory
@@ -20,7 +20,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
ATTR_ATTRIBUTION,
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_FRIENDLY_NAME,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
@@ -1879,7 +1878,6 @@ async def test_change_entity_id(
self.remove_calls = []
async def async_added_to_hass(self):
await super().async_added_to_hass()
self.added_calls.append(None)
self.async_on_remove(lambda: result.append(1))
@@ -2898,103 +2896,3 @@ async def test_platform_state_write_from_init_unique_id(
# The early attempt to write is interpreted as a unique ID collision
assert "Platform test_platform does not generate unique IDs." in caplog.text
assert "Entity id already exists - ignoring: test.test" not in caplog.text
async def test_included_entities(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
) -> None:
"""Test included entities are exposed via the entity_id attribute."""
entity_registry.async_get_or_create(
domain="hello",
platform="test",
unique_id="very_unique_oceans",
suggested_object_id="oceans",
)
entity_registry.async_get_or_create(
domain="hello",
platform="test",
unique_id="very_unique_continents",
suggested_object_id="continents",
)
entity_registry.async_get_or_create(
domain="hello",
platform="test",
unique_id="very_unique_moon",
suggested_object_id="moon",
)
class MockHelloBaseClass(entity.Entity):
"""Domain base entity platform domain Hello."""
@final
@property
def state_attributes(self) -> dict[str, Any]:
"""Return the state attributes."""
return {"extra": "beer"}
class MockHelloIncludedEntitiesClass(MockHelloBaseClass, entity.Entity):
"""Mock hello grouped entity class for a test integration."""
platform = MockEntityPlatform(hass, domain="hello", platform_name="test")
mock_entity = MockHelloIncludedEntitiesClass()
mock_entity.hass = hass
mock_entity.entity_id = "hello.universe"
mock_entity.unique_id = "very_unique_universe"
mock_entity._attr_included_unique_ids = [
"very_unique_continents",
"very_unique_oceans",
]
await platform.async_add_entities([mock_entity])
# Initiate mock grouped entity for hello domain
mock_entity.async_schedule_update_ha_state(True)
await hass.async_block_till_done()
state = hass.states.get(mock_entity.entity_id)
assert state.attributes.get(ATTR_ENTITY_ID) == ["hello.continents", "hello.oceans"]
# Add an entity to the group of included entities
mock_entity._attr_included_unique_ids = [
"very_unique_continents",
"very_unique_moon",
"very_unique_oceans",
]
mock_entity.async_write_ha_state()
await hass.async_block_till_done()
state = hass.states.get(mock_entity.entity_id)
assert state.attributes.get("extra") == "beer"
assert state.attributes.get(ATTR_ENTITY_ID) == [
"hello.continents",
"hello.moon",
"hello.oceans",
]
# Remove an entity from the group of included entities
mock_entity._attr_included_unique_ids = ["very_unique_moon", "very_unique_oceans"]
mock_entity.async_write_ha_state()
await hass.async_block_till_done()
state = hass.states.get(mock_entity.entity_id)
assert state.attributes.get(ATTR_ENTITY_ID) == ["hello.moon", "hello.oceans"]
# Rename an included entity via the registry entity
entity_registry.async_update_entity(
entity_id="hello.moon", new_entity_id="hello.moon_light"
)
await hass.async_block_till_done()
state = hass.states.get(mock_entity.entity_id)
assert state.attributes.get(ATTR_ENTITY_ID) == ["hello.moon_light", "hello.oceans"]
# Remove an included entity from the registry entity
entity_registry.async_remove(entity_id="hello.oceans")
await hass.async_block_till_done()
state = hass.states.get(mock_entity.entity_id)
assert state.attributes.get(ATTR_ENTITY_ID) == ["hello.moon_light"]