Compare commits

...

66 Commits

Author SHA1 Message Date
Paul Bottein
cdb14510bb Use delay save to avoid unnecessary writes 2025-11-17 09:20:40 +01:00
Paul Bottein
16391ab47e Don't allow optional key and simplify system store creation 2025-11-17 09:14:07 +01:00
Paul Bottein
d84607f234 Use singleton for system storage 2025-11-13 18:13:46 +01:00
Paul Bottein
bddf4ea82d remove unused check for admin 2025-11-13 18:09:41 +01:00
Paul Bottein
6495a57c3e Fix tests 2025-11-13 14:53:12 +01:00
Paul Bottein
ce061a998f Add admin decorator 2025-11-13 14:53:11 +01:00
Paul Bottein
3d792ba45e Revert "Type system storage"
This reverts commit c1afba419c.
2025-11-13 14:53:11 +01:00
Paul Bottein
794aec194d Revert "Add admin decorator"
This reverts commit 19b732bc24.
2025-11-13 14:53:11 +01:00
Paul Bottein
9bbb0d9a65 Add admin decorator 2025-11-13 14:53:11 +01:00
Paul Bottein
5ac046568e Type system storage 2025-11-13 14:53:11 +01:00
Paul Bottein
360a0e0c46 Add frontend system storage 2025-11-13 14:53:11 +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
TheJulianJES
3cb414511b Migrate Z-Wave event entity to new discovery schema (#156320) 2025-11-13 07:22:37 +01:00
karwosts
f55c36d42d Update ical to 11.1.0 (#156487) 2025-11-12 20:24:04 -08:00
Erik Montnemery
26bb301cc0 Fix lifx tests opening sockets (#156460) 2025-11-12 21:51:54 +02:00
Erik Montnemery
4159e483ee Fix wiz tests opening sockets (#156468) 2025-11-12 20:11:15 +01:00
Erik Montnemery
7eb6f7cc07 Fix romy tests opening sockets (#156466) 2025-11-12 20:10:46 +01:00
epenet
a7d01b0b03 Use json_loads_object in tuya models (#156455) 2025-11-12 20:08:28 +01:00
epenet
1e5cfddf83 Use json_loads_object in Tuya light (#156452) 2025-11-12 19:34:17 +01:00
epenet
006fc5b10a Remove JSON parsing from tuya diagnostics (#156451) 2025-11-12 19:32:40 +01:00
Erik Montnemery
35a4b685b3 Fix steamist tests opening sockets (#156467) 2025-11-12 12:01:21 -06:00
Janez Urevc
b166818ef4 Bump tesla-wall-connector to 1.1.0 (#156438) 2025-11-12 17:45:08 +01:00
Erik Montnemery
34cd9f11d0 Fix onkyo tests opening sockets (#156461) 2025-11-12 17:32:58 +01:00
Erik Montnemery
0711d62085 Change collation to utf8mb4_bin for MySQL and MariaDB databases (#156297)
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-11-12 16:54:58 +01:00
J. Diego Rodríguez Royo
f70aeafb5f Bump aiohomeconnect to version 0.23.1 (#156454) 2025-11-12 15:59:20 +01:00
MoonDevLT
e2279b3589 Bump lunatone-rest-api-client to 0.5.7 (#156356) 2025-11-12 14:44:52 +01:00
Christopher Fenner
87b68e99ec Add compressor, condensor and evaporator sensors in ViCare integration (#156411) 2025-11-12 14:42:26 +01:00
Manu
b6c8b787e8 Add device storage sensor entities to Xbox (#155657) 2025-11-12 13:53:42 +01:00
Franck Nijhof
78f26edc29 Extend base jinja2 extension with limited template errors (#156431) 2025-11-12 13:52:15 +01:00
ehendrix23
5e6a72de90 Bump pyecobee to 0.3.2 (#156421)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-12 13:40:08 +01:00
Erik Montnemery
dcc559f8b6 Fix progress step bugs (#155923) 2025-11-12 13:14:53 +01:00
Manu
eda49cced0 Code quality improvements for Xbox integration (#156395) 2025-11-12 14:09:53 +02:00
Josef Zweck
14e41ab119 Fix lamarzocco update status (#156442) 2025-11-12 13:10:23 +02:00
Timothy
46151456d8 Make sure to clean register callbacks when mobile_app reloads (#156028) 2025-11-12 12:03:05 +01:00
cdnninja
39773a022a Bump pyvesync to 3.2.2 (#156423)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Josef Zweck <josef@zweck.dev>
2025-11-12 11:59:45 +01:00
Christopher Fenner
5f49a6450f Add air quality sensors in ViCare integration (#156417) 2025-11-12 11:45:04 +01:00
Christopher Fenner
dc8425c580 Add icon for pm4 sensor (#156432) 2025-11-12 11:38:33 +01:00
Josef Zweck
910bd371e4 Remove wsproto from exceptions (#156434) 2025-11-12 11:16:36 +01:00
Tom Matheussen
802a225e11 Clean alarm control panel platform for Satel Integra (#156357) 2025-11-12 11:09:48 +01:00
Josef Zweck
84f66fa689 Fix aussie-broadband tests (#156441) 2025-11-12 10:54:23 +01:00
wollew
0b7e88d0e0 add parallel_updates for button entity (#156437) 2025-11-12 11:49:32 +02:00
puddly
1fcaf95df5 Bump universal-silabs-flasher to v0.1.0 (#156291)
Co-authored-by: TheJulianJES <TheJulianJES@users.noreply.github.com>
2025-11-12 10:44:33 +01:00
Erik Montnemery
6c7434531f Fix tado tests opening sockets (#156386) 2025-11-12 10:08:15 +01:00
Åke Strandberg
5ec1c2b68b Use runtime_data in Senz (#156408) 2025-11-12 10:06:45 +01:00
Christopher Fenner
d8636d8346 Bump PyViCare to 2.55.0 (#156426) 2025-11-12 09:57:49 +01:00
Brett Adams
434763c74d Fix update progress in Teslemetry (#156422) 2025-11-12 09:55:09 +01:00
Petar Petrov
8cd2c1b43b Add power configuration to Energy dashboard (#153809)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-11-12 09:21:33 +01:00
Daniel Hjelseth Høyer
44711787a4 Update pyMill to 0.14.1 (#156396) 2025-11-12 09:15:59 +01:00
TheJulianJES
98fd0ee683 Exempt wsproto from license check (#156418) 2025-11-12 08:45:11 +01:00
Joost Lekkerkerker
303e4ce961 Add mac address to Velux device (#156376) 2025-11-12 09:45:02 +02:00
Paul Bottein
76f29298cd Add home panel (#156269) 2025-11-12 09:09:39 +02:00
Will Moss
17f5d0a69f Use common string for the remaining oauth2 error messages (#156407) 2025-11-12 04:43:12 +01:00
johanzander
90561de438 Refactor Growatt Server integration tests (#156413)
Co-authored-by: Claude <noreply@anthropic.com>
2025-11-12 00:32:25 +01:00
177 changed files with 10948 additions and 7384 deletions

View File

@@ -37,7 +37,7 @@ on:
type: boolean
env:
CACHE_VERSION: 1
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2025.12"

View File

@@ -9,7 +9,7 @@
},
"iot_class": "cloud_polling",
"loggers": ["pyecobee"],
"requirements": ["python-ecobee-api==0.2.20"],
"requirements": ["python-ecobee-api==0.3.2"],
"single_config_entry": true,
"zeroconf": [
{

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
import asyncio
from collections import Counter
from collections.abc import Awaitable, Callable
from typing import Literal, TypedDict
from typing import Literal, NotRequired, TypedDict
import voluptuous as vol
@@ -29,7 +29,7 @@ async def async_get_manager(hass: HomeAssistant) -> EnergyManager:
class FlowFromGridSourceType(TypedDict):
"""Dictionary describing the 'from' stat for the grid source."""
# statistic_id of a an energy meter (kWh)
# statistic_id of an energy meter (kWh)
stat_energy_from: str
# statistic_id of costs ($) incurred from the energy meter
@@ -58,6 +58,14 @@ class FlowToGridSourceType(TypedDict):
number_energy_price: float | None # Price for energy ($/kWh)
class GridPowerSourceType(TypedDict):
"""Dictionary holding the source of grid power consumption."""
# statistic_id of a power meter (kW)
# negative values indicate grid return
stat_rate: str
class GridSourceType(TypedDict):
"""Dictionary holding the source of grid energy consumption."""
@@ -65,6 +73,7 @@ class GridSourceType(TypedDict):
flow_from: list[FlowFromGridSourceType]
flow_to: list[FlowToGridSourceType]
power: NotRequired[list[GridPowerSourceType]]
cost_adjustment_day: float
@@ -75,6 +84,7 @@ class SolarSourceType(TypedDict):
type: Literal["solar"]
stat_energy_from: str
stat_rate: NotRequired[str]
config_entry_solar_forecast: list[str] | None
@@ -85,6 +95,8 @@ class BatterySourceType(TypedDict):
stat_energy_from: str
stat_energy_to: str
# positive when discharging, negative when charging
stat_rate: NotRequired[str]
class GasSourceType(TypedDict):
@@ -136,12 +148,15 @@ class DeviceConsumption(TypedDict):
# This is an ever increasing value
stat_consumption: str
# Instantaneous rate of flow: W, L/min or m³/h
stat_rate: NotRequired[str]
# An optional custom name for display in energy graphs
name: str | None
# An optional statistic_id identifying a device
# that includes this device's consumption in its total
included_in_stat: str | None
included_in_stat: NotRequired[str]
class EnergyPreferences(TypedDict):
@@ -194,6 +209,12 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
}
)
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("stat_rate"): str,
}
)
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
"""Generate a validator that ensures a value is only used once."""
@@ -224,6 +245,10 @@ GRID_SOURCE_SCHEMA = vol.Schema(
[FLOW_TO_GRID_SOURCE_SCHEMA],
_generate_unique_value_validator("stat_energy_to"),
),
vol.Optional("power"): vol.All(
[GRID_POWER_SOURCE_SCHEMA],
_generate_unique_value_validator("stat_rate"),
),
vol.Required("cost_adjustment_day"): vol.Coerce(float),
}
)
@@ -231,6 +256,7 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "solar",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
}
)
@@ -239,6 +265,7 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Required("type"): "battery",
vol.Required("stat_energy_from"): str,
vol.Required("stat_energy_to"): str,
vol.Optional("stat_rate"): str,
}
)
GAS_SOURCE_SCHEMA = vol.Schema(
@@ -294,6 +321,7 @@ ENERGY_SOURCE_SCHEMA = vol.All(
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
{
vol.Required("stat_consumption"): str,
vol.Optional("stat_rate"): str,
vol.Optional("name"): str,
vol.Optional("included_in_stat"): str,
}

View File

@@ -12,6 +12,7 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback, valid_entity_id
@@ -23,12 +24,17 @@ ENERGY_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.ENERGY,)
ENERGY_USAGE_UNITS: dict[str, tuple[UnitOfEnergy, ...]] = {
sensor.SensorDeviceClass.ENERGY: tuple(UnitOfEnergy)
}
POWER_USAGE_DEVICE_CLASSES = (sensor.SensorDeviceClass.POWER,)
POWER_USAGE_UNITS: dict[str, tuple[UnitOfPower, ...]] = {
sensor.SensorDeviceClass.POWER: tuple(UnitOfPower)
}
ENERGY_PRICE_UNITS = tuple(
f"/{unit}" for units in ENERGY_USAGE_UNITS.values() for unit in units
)
ENERGY_UNIT_ERROR = "entity_unexpected_unit_energy"
ENERGY_PRICE_UNIT_ERROR = "entity_unexpected_unit_energy_price"
POWER_UNIT_ERROR = "entity_unexpected_unit_power"
GAS_USAGE_DEVICE_CLASSES = (
sensor.SensorDeviceClass.ENERGY,
sensor.SensorDeviceClass.GAS,
@@ -82,6 +88,10 @@ def _get_placeholders(hass: HomeAssistant, issue_type: str) -> dict[str, str] |
f"{currency}{unit}" for unit in ENERGY_PRICE_UNITS
),
}
if issue_type == POWER_UNIT_ERROR:
return {
"power_units": ", ".join(POWER_USAGE_UNITS[sensor.SensorDeviceClass.POWER]),
}
if issue_type == GAS_UNIT_ERROR:
return {
"energy_units": ", ".join(GAS_USAGE_UNITS[sensor.SensorDeviceClass.ENERGY]),
@@ -159,7 +169,7 @@ class EnergyPreferencesValidation:
@callback
def _async_validate_usage_stat(
def _async_validate_stat_common(
hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str,
@@ -167,37 +177,41 @@ def _async_validate_usage_stat(
allowed_units: Mapping[str, Sequence[str]],
unit_error: str,
issues: ValidationIssues,
) -> None:
"""Validate a statistic."""
check_negative: bool = False,
) -> str | None:
"""Validate common aspects of a statistic.
Returns the entity_id if validation succeeds, None otherwise.
"""
if stat_id not in metadata:
issues.add_issue(hass, "statistics_not_defined", stat_id)
has_entity_source = valid_entity_id(stat_id)
if not has_entity_source:
return
return None
entity_id = stat_id
if not recorder.is_entity_recorded(hass, entity_id):
issues.add_issue(hass, "recorder_untracked", entity_id)
return
return None
if (state := hass.states.get(entity_id)) is None:
issues.add_issue(hass, "entity_not_defined", entity_id)
return
return None
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
return
return None
try:
current_value: float | None = float(state.state)
except ValueError:
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
return
return None
if current_value is not None and current_value < 0:
if check_negative and current_value is not None and current_value < 0:
issues.add_issue(hass, "entity_negative_state", entity_id, current_value)
device_class = state.attributes.get(ATTR_DEVICE_CLASS)
@@ -211,6 +225,36 @@ def _async_validate_usage_stat(
if device_class and unit not in allowed_units.get(device_class, []):
issues.add_issue(hass, unit_error, entity_id, unit)
return entity_id
@callback
def _async_validate_usage_stat(
hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str,
allowed_device_classes: Sequence[str],
allowed_units: Mapping[str, Sequence[str]],
unit_error: str,
issues: ValidationIssues,
) -> None:
"""Validate a statistic."""
entity_id = _async_validate_stat_common(
hass,
metadata,
stat_id,
allowed_device_classes,
allowed_units,
unit_error,
issues,
check_negative=True,
)
if entity_id is None:
return
state = hass.states.get(entity_id)
assert state is not None
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
allowed_state_classes = [
@@ -255,6 +299,39 @@ def _async_validate_price_entity(
issues.add_issue(hass, unit_error, entity_id, unit)
@callback
def _async_validate_power_stat(
hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str,
allowed_device_classes: Sequence[str],
allowed_units: Mapping[str, Sequence[str]],
unit_error: str,
issues: ValidationIssues,
) -> None:
"""Validate a power statistic."""
entity_id = _async_validate_stat_common(
hass,
metadata,
stat_id,
allowed_device_classes,
allowed_units,
unit_error,
issues,
check_negative=False,
)
if entity_id is None:
return
state = hass.states.get(entity_id)
assert state is not None
state_class = state.attributes.get(sensor.ATTR_STATE_CLASS)
if state_class != sensor.SensorStateClass.MEASUREMENT:
issues.add_issue(hass, "entity_unexpected_state_class", entity_id, state_class)
@callback
def _async_validate_cost_stat(
hass: HomeAssistant,
@@ -309,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()
@@ -327,215 +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,
)
)
_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

@@ -481,6 +481,13 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
sidebar_title="climate",
sidebar_default_visible=False,
)
async_register_built_in_panel(
hass,
"home",
sidebar_icon="mdi:home",
sidebar_title="home",
sidebar_default_visible=False,
)
async_register_built_in_panel(hass, "profile")

View File

@@ -11,11 +11,14 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.websocket_api import ActiveConnection
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import singleton
from homeassistant.helpers.storage import Store
from homeassistant.util.hass_dict import HassKey
DATA_STORAGE: HassKey[dict[str, UserStore]] = HassKey("frontend_storage")
DATA_SYSTEM_STORAGE: HassKey[SystemStore] = HassKey("frontend_system_storage")
STORAGE_VERSION_USER_DATA = 1
STORAGE_VERSION_SYSTEM_DATA = 1
async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
@@ -23,6 +26,9 @@ async def async_setup_frontend_storage(hass: HomeAssistant) -> None:
websocket_api.async_register_command(hass, websocket_set_user_data)
websocket_api.async_register_command(hass, websocket_get_user_data)
websocket_api.async_register_command(hass, websocket_subscribe_user_data)
websocket_api.async_register_command(hass, websocket_set_system_data)
websocket_api.async_register_command(hass, websocket_get_system_data)
websocket_api.async_register_command(hass, websocket_subscribe_system_data)
async def async_user_store(hass: HomeAssistant, user_id: str) -> UserStore:
@@ -83,6 +89,52 @@ class _UserStore(Store[dict[str, Any]]):
)
@singleton.singleton(DATA_SYSTEM_STORAGE, async_=True)
async def async_system_store(hass: HomeAssistant) -> SystemStore:
"""Access the system store."""
store = SystemStore(hass)
await store.async_load()
return store
class SystemStore:
"""System store for frontend data."""
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the system store."""
self._store: Store[dict[str, Any]] = Store(
hass,
STORAGE_VERSION_SYSTEM_DATA,
"frontend.system_data",
)
self.data: dict[str, Any] = {}
self.subscriptions: dict[str, list[Callable[[], None]]] = {}
async def async_load(self) -> None:
"""Load the data from the store."""
self.data = await self._store.async_load() or {}
async def async_set_item(self, key: str, value: Any) -> None:
"""Set an item and save the store."""
self.data[key] = value
self._store.async_delay_save(lambda: self.data, 1.0)
for cb in self.subscriptions.get(key, []):
cb()
@callback
def async_subscribe(
self, key: str, on_update_callback: Callable[[], None]
) -> Callable[[], None]:
"""Subscribe to store updates."""
self.subscriptions.setdefault(key, []).append(on_update_callback)
def unsubscribe() -> None:
"""Unsubscribe from the store."""
self.subscriptions[key].remove(on_update_callback)
return unsubscribe
def with_user_store(
orig_func: Callable[
[HomeAssistant, ActiveConnection, dict[str, Any], UserStore],
@@ -107,6 +159,28 @@ def with_user_store(
return with_user_store_func
def with_system_store(
orig_func: Callable[
[HomeAssistant, ActiveConnection, dict[str, Any], SystemStore],
Coroutine[Any, Any, None],
],
) -> Callable[
[HomeAssistant, ActiveConnection, dict[str, Any]], Coroutine[Any, Any, None]
]:
"""Decorate function to provide system store."""
@wraps(orig_func)
async def with_system_store_func(
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
) -> None:
"""Provide system store to function."""
store = await async_system_store(hass)
await orig_func(hass, connection, msg, store)
return with_system_store_func
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/set_user_data",
@@ -169,3 +243,65 @@ async def websocket_subscribe_user_data(
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
on_data_update()
connection.send_result(msg["id"])
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/set_system_data",
vol.Required("key"): str,
vol.Required("value"): vol.Any(bool, str, int, float, dict, list, None),
}
)
@websocket_api.require_admin
@websocket_api.async_response
@with_system_store
async def websocket_set_system_data(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
store: SystemStore,
) -> None:
"""Handle set system data command."""
await store.async_set_item(msg["key"], msg["value"])
connection.send_result(msg["id"])
@websocket_api.websocket_command(
{vol.Required("type"): "frontend/get_system_data", vol.Required("key"): str}
)
@websocket_api.async_response
@with_system_store
async def websocket_get_system_data(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
store: SystemStore,
) -> None:
"""Handle get system data command."""
connection.send_result(msg["id"], {"value": store.data.get(msg["key"])})
@websocket_api.websocket_command(
{
vol.Required("type"): "frontend/subscribe_system_data",
vol.Required("key"): str,
}
)
@websocket_api.async_response
@with_system_store
async def websocket_subscribe_system_data(
hass: HomeAssistant,
connection: ActiveConnection,
msg: dict[str, Any],
store: SystemStore,
) -> None:
"""Handle subscribe to system data command."""
key: str = msg["key"]
def on_data_update() -> None:
"""Handle system data update."""
connection.send_event(msg["id"], {"value": store.data.get(key)})
connection.subscriptions[msg["id"]] = store.async_subscribe(key, on_data_update)
on_data_update()
connection.send_result(msg["id"])

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/google",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.0.0"]
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"]
}

View File

@@ -22,6 +22,6 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.23.0"],
"requirements": ["aiohomeconnect==0.23.1"],
"zeroconf": ["_homeconnect._tcp.local."]
}

View File

@@ -412,8 +412,8 @@ class HomeConnectProgramSelectEntity(HomeConnectEntity, SelectEntity):
"""Set the program value."""
event = self.appliance.events.get(cast(EventKey, self.bsh_key))
self._attr_current_option = (
PROGRAMS_TRANSLATION_KEYS_MAP.get(cast(ProgramKey, event.value))
if event
PROGRAMS_TRANSLATION_KEYS_MAP.get(ProgramKey(event_value))
if event and isinstance(event_value := event.value, str)
else None
)

View File

@@ -556,8 +556,11 @@ class HomeConnectSensor(HomeConnectEntity, SensorEntity):
status = self.appliance.status[cast(StatusKey, self.bsh_key)].value
self._update_native_value(status)
def _update_native_value(self, status: str | float) -> None:
def _update_native_value(self, status: str | float | None) -> None:
"""Set the value of the sensor based on the given value."""
if status is None:
self._attr_native_value = None
return
match self.device_class:
case SensorDeviceClass.TIMESTAMP:
self._attr_native_value = dt_util.utcnow() + timedelta(

View File

@@ -76,9 +76,18 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Connect ZBT-2 firmware methods."""
context: ConfigFlowContext
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
ZIGBEE_BAUDRATE = 460800
# Early ZBT-2 samples used RTS/DTR to trigger the bootloader, later ones use the
# baudrate method. Since the two are mutually exclusive we just use both.
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
]
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

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

@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import EntityCategory
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantConnectZBT2ConfigEntry
from .config_flow import ZBT2FirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
_LOGGER = logging.getLogger(__name__)
@@ -134,7 +134,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Connect ZBT-2 firmware update entity."""
bootloader_reset_methods = [ResetTarget.RTS_DTR]
BOOTLOADER_RESET_METHODS = ZBT2FirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = ZBT2FirmwareMixin.APPLICATION_PROBE_METHODS
def __init__(
self,

View File

@@ -81,6 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]] = []
_picked_firmware_type: PickedFirmwareType
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
@@ -230,7 +231,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# Installing new firmware is only truly required if the wrong type is
# installed: upgrading to the latest release of the current firmware type
# isn't strictly necessary for functionality.
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
)
firmware_install_required = self._probed_firmware_info is None or (
self._probed_firmware_info.firmware_type != expected_installed_firmware_type
@@ -295,6 +300,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
fw_data=fw_data,
expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=lambda offset, total: self.async_update_progress(
offset / total
),

View File

@@ -7,7 +7,7 @@
"documentation": "https://www.home-assistant.io/integrations/homeassistant_hardware",
"integration_type": "system",
"requirements": [
"universal-silabs-flasher==0.0.37",
"universal-silabs-flasher==0.1.0",
"ha-silabs-firmware-client==0.3.0"
]
}

View File

@@ -86,7 +86,8 @@ class BaseFirmwareUpdateEntity(
# Subclasses provide the mapping between firmware types and entity descriptions
entity_description: FirmwareUpdateEntityDescription
bootloader_reset_methods: list[ResetTarget] = []
BOOTLOADER_RESET_METHODS: list[ResetTarget]
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]]
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
@@ -278,7 +279,8 @@ class BaseFirmwareUpdateEntity(
device=self._current_device,
fw_data=fw_data,
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_methods=self.bootloader_reset_methods,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=self._update_progress,
domain=self._config_entry.domain,
)

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import asyncio
from collections import defaultdict
from collections.abc import AsyncIterator, Callable, Iterable, Sequence
from collections.abc import AsyncIterator, Callable, Sequence
from contextlib import AsyncExitStack, asynccontextmanager
from dataclasses import dataclass
from enum import StrEnum
@@ -309,15 +309,20 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
async def probe_silabs_firmware_info(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
device: str,
*,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
) -> FirmwareInfo | None:
"""Probe the running firmware on a SiLabs device."""
flasher = Flasher(
device=device,
**(
{"probe_methods": [m.as_flasher_application_type() for m in probe_methods]}
if probe_methods
else {}
probe_methods=tuple(
(m.as_flasher_application_type(), baudrate)
for m, baudrate in application_probe_methods
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)
@@ -343,11 +348,18 @@ async def probe_silabs_firmware_info(
async def probe_silabs_firmware_type(
device: str, *, probe_methods: Iterable[ApplicationType] | None = None
device: str,
*,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
) -> ApplicationType | None:
"""Probe the running firmware type on a SiLabs device."""
fw_info = await probe_silabs_firmware_info(device, probe_methods=probe_methods)
fw_info = await probe_silabs_firmware_info(
device,
bootloader_reset_methods=bootloader_reset_methods,
application_probe_methods=application_probe_methods,
)
if fw_info is None:
return None
@@ -359,12 +371,22 @@ async def async_flash_silabs_firmware(
device: str,
fw_data: bytes,
expected_installed_firmware_type: ApplicationType,
bootloader_reset_methods: Sequence[ResetTarget] = (),
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
progress_callback: Callable[[int, int], None] | None = None,
*,
domain: str = DOMAIN,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device."""
if not any(
method == expected_installed_firmware_type
for method, _ in application_probe_methods
):
raise ValueError(
f"Expected installed firmware type {expected_installed_firmware_type!r}"
f" not in application probe methods {application_probe_methods!r}"
)
async with async_firmware_update_context(hass, device, domain):
firmware_info = await guess_firmware_info(hass, device)
_LOGGER.debug("Identified firmware info: %s", firmware_info)
@@ -373,11 +395,9 @@ async def async_flash_silabs_firmware(
flasher = Flasher(
device=device,
probe_methods=(
ApplicationType.GECKO_BOOTLOADER.as_flasher_application_type(),
ApplicationType.EZSP.as_flasher_application_type(),
ApplicationType.SPINEL.as_flasher_application_type(),
ApplicationType.CPC.as_flasher_application_type(),
probe_methods=tuple(
(m.as_flasher_application_type(), baudrate)
for m, baudrate in application_probe_methods
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
@@ -401,7 +421,13 @@ async def async_flash_silabs_firmware(
probed_firmware_info = await probe_silabs_firmware_info(
device,
probe_methods=(expected_installed_firmware_type,),
bootloader_reset_methods=bootloader_reset_methods,
# Only probe for the expected installed firmware type
application_probe_methods=[
(method, baudrate)
for method, baudrate in application_probe_methods
if method == expected_installed_firmware_type
],
)
if probed_firmware_info is None:

View File

@@ -16,6 +16,7 @@ from homeassistant.components.homeassistant_hardware.helpers import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.usb import (
usb_service_info_from_device,
@@ -79,6 +80,20 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
context: ConfigFlowContext
ZIGBEE_BAUDRATE = 115200
# There is no hardware bootloader trigger
BOOTLOADER_RESET_METHODS: list[ResetTarget] = []
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
# CPC baudrates can be removed once multiprotocol is removed
(ApplicationType.CPC, 115200),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 460800),
(ApplicationType.ROUTER, 115200),
]
def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders."""
placeholders = {

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

@@ -23,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantSkyConnectConfigEntry
from .config_flow import SkyConnectFirmwareMixin
from .const import (
DOMAIN,
FIRMWARE,
@@ -151,8 +152,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""SkyConnect firmware update entity."""
# The ZBT-1 does not have a hardware bootloader trigger
bootloader_reset_methods = []
BOOTLOADER_RESET_METHODS = SkyConnectFirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = SkyConnectFirmwareMixin.APPLICATION_PROBE_METHODS
def __init__(
self,

View File

@@ -82,7 +82,18 @@ else:
class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods."""
ZIGBEE_BAUDRATE = 115200
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
# CPC baudrates can be removed once multiprotocol is removed
(ApplicationType.CPC, 115200),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 460800),
(ApplicationType.ROUTER, 115200),
]
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
@@ -146,7 +157,11 @@ class HomeAssistantYellowConfigFlow(
assert self._device is not None
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
self._probed_firmware_info = await probe_silabs_firmware_info(self._device)
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
)
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running
if (

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

@@ -14,7 +14,6 @@ from homeassistant.components.homeassistant_hardware.update import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.update import UpdateDeviceClass
from homeassistant.const import EntityCategory
@@ -24,6 +23,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantYellowConfigEntry
from .config_flow import YellowFirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
_LOGGER = logging.getLogger(__name__)
@@ -150,7 +150,8 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Yellow firmware update entity."""
bootloader_reset_methods = [ResetTarget.YELLOW] # Triggers a GPIO reset
BOOTLOADER_RESET_METHODS = YellowFirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = YellowFirmwareMixin.APPLICATION_PROBE_METHODS
def __init__(
self,

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

@@ -125,7 +125,7 @@ class LaMarzoccoUpdateEntity(LaMarzoccoEntity, UpdateEntity):
await self.coordinator.device.update_firmware()
while (
update_progress := await self.coordinator.device.get_firmware()
).command_status is UpdateStatus.IN_PROGRESS:
).command_status is not UpdateStatus.UPDATED:
if counter >= MAX_UPDATE_WAIT:
_raise_timeout_error()
self._attr_update_percentage = update_progress.progress_percentage

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],
"requirements": ["ical==11.0.0"]
"requirements": ["ical==11.1.0"]
}

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling",
"requirements": ["ical==11.0.0"]
"requirements": ["ical==11.1.0"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["lunatone-rest-api-client==0.5.3"]
"requirements": ["lunatone-rest-api-client==0.5.7"]
}

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/mill",
"iot_class": "local_polling",
"loggers": ["mill", "mill_local"],
"requirements": ["millheater==0.14.0", "mill-local==0.3.0"]
"requirements": ["millheater==0.14.1", "mill-local==0.3.0"]
}

View File

@@ -61,10 +61,12 @@ async def async_setup_entry(
async_add_entities([MobileAppBinarySensor(data, config_entry)])
async_dispatcher_connect(
hass,
f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
)
)

View File

@@ -72,10 +72,12 @@ async def async_setup_entry(
async_add_entities([MobileAppSensor(data, config_entry)])
async_dispatcher_connect(
hass,
f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
config_entry.async_on_unload(
async_dispatcher_connect(
hass,
f"{DOMAIN}_{ENTITY_TYPE}_register",
handle_sensor_registration,
)
)

View File

@@ -109,7 +109,7 @@
"message": "Cannot read {filename}, no access to path; `allowlist_external_dirs` may need to be adjusted in `configuration.yaml`"
},
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"update_failed": {
"message": "Failed to update drive state"

View File

@@ -36,7 +36,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -12,7 +12,7 @@ from sqlalchemy.orm import DeclarativeBase
from sqlalchemy.orm.attributes import InstrumentedAttribute
from ..const import SupportedDialect
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE
from ..db_schema import DOUBLE_PRECISION_TYPE_SQL, DOUBLE_TYPE, MYSQL_COLLATE
from ..util import session_scope
if TYPE_CHECKING:
@@ -105,12 +105,13 @@ def _validate_table_schema_has_correct_collation(
or dialect_kwargs.get("mariadb_collate")
or connection.dialect._fetch_setting(connection, "collation_server") # type: ignore[attr-defined] # noqa: SLF001
)
if collate and collate != "utf8mb4_unicode_ci":
if collate and collate != MYSQL_COLLATE:
_LOGGER.debug(
"Database %s collation is not utf8mb4_unicode_ci",
"Database %s collation is not %s",
table,
MYSQL_COLLATE,
)
schema_errors.add(f"{table}.utf8mb4_unicode_ci")
schema_errors.add(f"{table}.{MYSQL_COLLATE}")
return schema_errors
@@ -240,7 +241,7 @@ def correct_db_schema_utf8(
table_name = table_object.__tablename__
if (
f"{table_name}.4-byte UTF-8" in schema_errors
or f"{table_name}.utf8mb4_unicode_ci" in schema_errors
or f"{table_name}.{MYSQL_COLLATE}" in schema_errors
):
from ..migration import ( # noqa: PLC0415
_correct_table_character_set_and_collation,

View File

@@ -71,7 +71,7 @@ class LegacyBase(DeclarativeBase):
"""Base class for tables, used for schema migration."""
SCHEMA_VERSION = 52
SCHEMA_VERSION = 53
_LOGGER = logging.getLogger(__name__)
@@ -128,7 +128,7 @@ LEGACY_STATES_ENTITY_ID_LAST_UPDATED_TS_INDEX = "ix_states_entity_id_last_update
LEGACY_MAX_LENGTH_EVENT_CONTEXT_ID: Final = 36
CONTEXT_ID_BIN_MAX_LENGTH = 16
MYSQL_COLLATE = "utf8mb4_unicode_ci"
MYSQL_COLLATE = "utf8mb4_bin"
MYSQL_DEFAULT_CHARSET = "utf8mb4"
MYSQL_ENGINE = "InnoDB"

View File

@@ -1361,7 +1361,7 @@ class _SchemaVersion20Migrator(_SchemaVersionMigrator, target_version=20):
class _SchemaVersion21Migrator(_SchemaVersionMigrator, target_version=21):
def _apply_update(self) -> None:
"""Version specific update method."""
# Try to change the character set of the statistic_meta table
# Try to change the character set of events, states and statistics_meta tables
if self.engine.dialect.name == SupportedDialect.MYSQL:
for table in ("events", "states", "statistics_meta"):
_correct_table_character_set_and_collation(table, self.session_maker)
@@ -2125,6 +2125,23 @@ class _SchemaVersion52Migrator(_SchemaVersionMigrator, target_version=52):
)
class _SchemaVersion53Migrator(_SchemaVersionMigrator, target_version=53):
def _apply_update(self) -> None:
"""Version specific update method."""
# Try to change the character set of events, states and statistics_meta tables
if self.engine.dialect.name == SupportedDialect.MYSQL:
for table in (
"events",
"event_data",
"states",
"state_attributes",
"statistics",
"statistics_meta",
"statistics_short_term",
):
_correct_table_character_set_and_collation(table, self.session_maker)
def _migrate_statistics_columns_to_timestamp_removing_duplicates(
hass: HomeAssistant,
instance: Recorder,
@@ -2167,8 +2184,10 @@ def _correct_table_character_set_and_collation(
"""Correct issues detected by validate_db_schema."""
# Attempt to convert the table to utf8mb4
_LOGGER.warning(
"Updating character set and collation of table %s to utf8mb4. %s",
"Updating table %s to character set %s and collation %s. %s",
table,
MYSQL_DEFAULT_CHARSET,
MYSQL_COLLATE,
MIGRATION_NOTE_MINUTES,
)
with (

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["ical"],
"quality_scale": "silver",
"requirements": ["ical==11.0.0"]
"requirements": ["ical==11.1.0"]
}

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

@@ -3,10 +3,9 @@
from __future__ import annotations
import asyncio
from collections import OrderedDict
import logging
from satel_integra.satel_integra import AlarmState
from satel_integra.satel_integra import AlarmState, AsyncSatel
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntity,
@@ -14,7 +13,7 @@ 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.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -26,6 +25,19 @@ from .const import (
SUBENTRY_TYPE_PARTITION,
SatelConfigEntry,
)
from .entity import SatelIntegraEntity
ALARM_STATE_MAP = {
AlarmState.TRIGGERED: AlarmControlPanelState.TRIGGERED,
AlarmState.TRIGGERED_FIRE: AlarmControlPanelState.TRIGGERED,
AlarmState.ENTRY_TIME: AlarmControlPanelState.PENDING,
AlarmState.ARMED_MODE3: AlarmControlPanelState.ARMED_HOME,
AlarmState.ARMED_MODE2: AlarmControlPanelState.ARMED_HOME,
AlarmState.ARMED_MODE1: AlarmControlPanelState.ARMED_HOME,
AlarmState.ARMED_MODE0: AlarmControlPanelState.ARMED_AWAY,
AlarmState.EXIT_COUNTDOWN_OVER_10: AlarmControlPanelState.ARMING,
AlarmState.EXIT_COUNTDOWN_UNDER_10: AlarmControlPanelState.ARMING,
}
_LOGGER = logging.getLogger(__name__)
@@ -45,48 +57,54 @@ async def async_setup_entry(
)
for subentry in partition_subentries:
partition_num = subentry.data[CONF_PARTITION_NUMBER]
zone_name = subentry.data[CONF_NAME]
arm_home_mode = subentry.data[CONF_ARM_HOME_MODE]
partition_num: int = subentry.data[CONF_PARTITION_NUMBER]
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
)
def __init__(
self, controller, name, arm_home_mode, partition_id, config_entry_id
self,
controller: AsyncSatel,
config_entry_id: str,
subentry: ConfigSubentry,
device_number: int,
arm_home_mode: int,
) -> None:
"""Initialize the alarm panel."""
self._attr_name = name
self._attr_unique_id = f"{config_entry_id}_alarm_panel_{partition_id}"
super().__init__(
controller,
config_entry_id,
subentry,
device_number,
)
self._arm_home_mode = arm_home_mode
self._partition_id = partition_id
self._satel = controller
async def async_added_to_hass(self) -> None:
"""Update alarm status and register callbacks for future updates."""
_LOGGER.debug("Starts listening for panel messages")
self._update_alarm_status()
self._attr_alarm_state = self._read_alarm_state()
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status
@@ -94,55 +112,29 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
)
@callback
def _update_alarm_status(self):
def _update_alarm_status(self) -> None:
"""Handle alarm status update."""
state = self._read_alarm_state()
_LOGGER.debug("Got status update, current status: %s", state)
if state != self._attr_alarm_state:
self._attr_alarm_state = state
self.async_write_ha_state()
else:
_LOGGER.debug("Ignoring alarm status message, same state")
def _read_alarm_state(self):
def _read_alarm_state(self) -> AlarmControlPanelState | None:
"""Read current status of the alarm and translate it into HA status."""
# Default - disarmed:
hass_alarm_status = AlarmControlPanelState.DISARMED
if not self._satel.connected:
_LOGGER.debug("Alarm panel not connected")
return None
state_map = OrderedDict(
[
(AlarmState.TRIGGERED, AlarmControlPanelState.TRIGGERED),
(AlarmState.TRIGGERED_FIRE, AlarmControlPanelState.TRIGGERED),
(AlarmState.ENTRY_TIME, AlarmControlPanelState.PENDING),
(AlarmState.ARMED_MODE3, AlarmControlPanelState.ARMED_HOME),
(AlarmState.ARMED_MODE2, AlarmControlPanelState.ARMED_HOME),
(AlarmState.ARMED_MODE1, AlarmControlPanelState.ARMED_HOME),
(AlarmState.ARMED_MODE0, AlarmControlPanelState.ARMED_AWAY),
(
AlarmState.EXIT_COUNTDOWN_OVER_10,
AlarmControlPanelState.PENDING,
),
(
AlarmState.EXIT_COUNTDOWN_UNDER_10,
AlarmControlPanelState.PENDING,
),
]
)
_LOGGER.debug("State map of Satel: %s", self._satel.partition_states)
for satel_state, ha_state in state_map.items():
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]
):
hass_alarm_status = ha_state
break
return ha_state
return hass_alarm_status
return AlarmControlPanelState.DISARMED
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Send disarm command."""
@@ -154,25 +146,21 @@ class SatelIntegraAlarmPanel(AlarmControlPanelEntity):
self._attr_alarm_state == AlarmControlPanelState.TRIGGERED
)
_LOGGER.debug("Disarming, self._attr_alarm_state: %s", self._attr_alarm_state)
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."""
_LOGGER.debug("Arming away")
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."""
_LOGGER.debug("Arming home")
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

@@ -118,6 +118,9 @@
"pm25": {
"default": "mdi:molecule"
},
"pm4": {
"default": "mdi:molecule"
},
"power": {
"default": "mdi:flash"
},

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
@@ -32,9 +33,10 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
type SENZDataUpdateCoordinator = DataUpdateCoordinator[dict[str, Thermostat]]
type SENZConfigEntry = ConfigEntry[SENZDataUpdateCoordinator]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
"""Set up SENZ from a config entry."""
try:
implementation = await async_get_config_entry_implementation(hass, entry)
@@ -71,16 +73,37 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: SENZConfigEntry) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
return unload_ok
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

@@ -12,24 +12,23 @@ from homeassistant.components.climate import (
HVACAction,
HVACMode,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TEMPERATURE, PRECISION_TENTHS, UnitOfTemperature
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SENZDataUpdateCoordinator
from . import SENZConfigEntry, SENZDataUpdateCoordinator
from .const import DOMAIN
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: SENZConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SENZ climate entities from a config entry."""
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
SENZClimate(thermostat, coordinator) for thermostat in coordinator.data.values()
)

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

@@ -3,10 +3,9 @@
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from . import SENZConfigEntry
TO_REDACT = [
"access_token",
@@ -15,13 +14,11 @@ TO_REDACT = [
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: ConfigEntry
hass: HomeAssistant, entry: SENZConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
raw_data = (
[device.raw_data for device in hass.data[DOMAIN][entry.entry_id].data.values()],
)
raw_data = ([device.raw_data for device in entry.runtime_data.data.values()],)
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),

View File

@@ -13,14 +13,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import SENZDataUpdateCoordinator
from . import SENZConfigEntry, SENZDataUpdateCoordinator
from .const import DOMAIN
@@ -45,11 +44,11 @@ SENSORS: tuple[SenzSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: SENZConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the SENZ sensor entities from a config entry."""
coordinator: SENZDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
SENZSensor(thermostat, coordinator, description)
for description in SENSORS

View File

@@ -663,7 +663,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"issues": {

View File

@@ -34,7 +34,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"system_health": {

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

@@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/tesla_wall_connector",
"iot_class": "local_polling",
"loggers": ["tesla_wall_connector"],
"requirements": ["tesla-wall-connector==1.0.2"]
"requirements": ["tesla-wall-connector==1.1.0"]
}

View File

@@ -237,7 +237,7 @@ class TeslemetryStreamingUpdateEntity(
if self._download_percentage > 1 and self._download_percentage < 100:
self._attr_in_progress = True
self._attr_update_percentage = self._download_percentage
elif self._install_percentage > 1:
elif self._install_percentage > 10:
self._attr_in_progress = True
self._attr_update_percentage = self._install_percentage
else:

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

@@ -2,9 +2,7 @@
from __future__ import annotations
from contextlib import suppress
import json
from typing import Any, cast
from typing import Any
from tuya_sharing import CustomerDevice
@@ -17,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
@@ -97,34 +102,24 @@ 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
with suppress(ValueError, TypeError):
value = json.loads(value)
data["status"][dpcode] = value
# Gather Tuya functions
for function in device.function.values():
value = function.values
with suppress(ValueError, TypeError, AttributeError):
value = json.loads(cast(str, function.values))
data["function"][function.code] = {
"type": function.type,
"value": value,
"value": function.values,
}
# Gather Tuya status ranges
for status_range in device.status_range.values():
value = status_range.values
with suppress(ValueError, TypeError, AttributeError):
value = json.loads(status_range.values)
data["status_range"][status_range.code] = {
"type": status_range.type,
"value": value,
"value": status_range.values,
}
# Gather information how this Tuya device is represented in Home Assistant

View File

@@ -24,6 +24,7 @@ from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from homeassistant.util.json import json_loads_object
from . import TuyaConfigEntry
from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode
@@ -499,11 +500,11 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
values = self.device.status_range[dpcode].values
# Fetch color data type information
if function_data := json.loads(values):
if function_data := json_loads_object(values):
self._color_data_type = ColorTypeData(
h_type=IntegerTypeData(dpcode, **function_data["h"]),
s_type=IntegerTypeData(dpcode, **function_data["s"]),
v_type=IntegerTypeData(dpcode, **function_data["v"]),
h_type=IntegerTypeData(dpcode, **cast(dict, function_data["h"])),
s_type=IntegerTypeData(dpcode, **cast(dict, function_data["s"])),
v_type=IntegerTypeData(dpcode, **cast(dict, function_data["v"])),
)
else:
# If no type is found, use a default one
@@ -770,12 +771,12 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
if not (status_data := self.device.status[self._color_data_dpcode]):
return None
if not (status := json.loads(status_data)):
if not (status := json_loads_object(status_data)):
return None
return ColorData(
type_data=self._color_data_type,
h_value=status["h"],
s_value=status["s"],
v_value=status["v"],
h_value=cast(int, status["h"]),
s_value=cast(int, status["s"]),
v_value=cast(int, status["v"]),
)

View File

@@ -5,12 +5,11 @@ from __future__ import annotations
from abc import ABC, abstractmethod
import base64
from dataclasses import dataclass
import json
from typing import Any, Literal, Self, overload
from typing import Any, Literal, Self, cast, overload
from tuya_sharing import CustomerDevice
from homeassistant.util.json import json_loads
from homeassistant.util.json import json_loads, json_loads_object
from .const import DPCode, DPType
from .util import parse_dptype, remap_value
@@ -88,7 +87,7 @@ class IntegerTypeData(TypeInformation):
@classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a IntegerTypeData object."""
if not (parsed := json.loads(data)):
if not (parsed := cast(dict[str, Any] | None, json_loads_object(data))):
return None
return cls(
@@ -111,9 +110,9 @@ class BitmapTypeInformation(TypeInformation):
@classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a BitmapTypeInformation object."""
if not (parsed := json.loads(data)):
if not (parsed := json_loads_object(data)):
return None
return cls(dpcode, **parsed)
return cls(dpcode, **cast(dict[str, list[str]], parsed))
@dataclass
@@ -125,9 +124,9 @@ class EnumTypeData(TypeInformation):
@classmethod
def from_json(cls, dpcode: DPCode, data: str) -> Self | None:
"""Load JSON string and return a EnumTypeData object."""
if not (parsed := json.loads(data)):
if not (parsed := json_loads_object(data)):
return None
return cls(dpcode, **parsed)
return cls(dpcode, **cast(dict[str, list[str]], parsed))
_TYPE_INFORMATION_MAPPINGS: dict[DPType, type[TypeInformation]] = {

View File

@@ -61,7 +61,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -5,7 +5,12 @@ from __future__ import annotations
from pyvlx import PyVLX, PyVLXException
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
CONF_PASSWORD,
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import device_registry as dr, issue_registry as ir
@@ -30,6 +35,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
entry.runtime_data = pyvlx
connections = None
if (mac := entry.data.get(CONF_MAC)) is not None:
connections = {(dr.CONNECTION_NETWORK_MAC, mac)}
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
@@ -43,6 +52,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
sw_version=(
str(pyvlx.klf200.version.softwareversion) if pyvlx.klf200.version else None
),
connections=connections,
)
async def on_hass_stop(event):

View File

@@ -14,6 +14,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import VeluxConfigEntry
from .const import DOMAIN
PARALLEL_UPDATES = 1
async def async_setup_entry(
hass: HomeAssistant,

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

@@ -37,9 +37,7 @@ rules:
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates:
status: todo
comment: button still needs it
parallel-updates: done
reauthentication-flow: todo
test-coverage:
status: todo

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/vesync",
"iot_class": "cloud_polling",
"loggers": ["pyvesync"],
"requirements": ["pyvesync==3.2.1"]
"requirements": ["pyvesync==3.2.2"]
}

View File

@@ -144,6 +144,11 @@ GLOBAL_SENSORS: tuple[ViCareBinarySensorEntityDescription, ...] = (
device_class=BinarySensorDeviceClass.DOOR,
value_getter=lambda api: api.isValveOpen(),
),
ViCareBinarySensorEntityDescription(
key="ventilation_frost_protection",
translation_key="ventilation_frost_protection",
value_getter=lambda api: api.getHeatExchangerFrostProtectionActive(),
),
)

View File

@@ -35,6 +35,7 @@ CONF_HEATING_TYPE = "heating_type"
DEFAULT_CACHE_DURATION = 60
VICARE_BAR = "bar"
VICARE_CELSIUS = "celsius"
VICARE_CUBIC_METER = "cubicMeter"
VICARE_KW = "kilowatt"
VICARE_KWH = "kilowattHour"

View File

@@ -16,6 +16,15 @@
"domestic_hot_water_pump": {
"default": "mdi:pump"
},
"filter_hours": {
"default": "mdi:counter"
},
"filter_overdue_hours": {
"default": "mdi:counter"
},
"filter_remaining_hours": {
"default": "mdi:counter"
},
"frost_protection": {
"default": "mdi:snowflake"
},
@@ -28,6 +37,12 @@
"solar_pump": {
"default": "mdi:pump"
},
"supply_fan_hours": {
"default": "mdi:counter"
},
"supply_fan_speed": {
"default": "mdi:rotate-right"
},
"valve": {
"default": "mdi:pipe-valve"
}
@@ -101,6 +116,12 @@
"ess_state_of_charge": {
"default": "mdi:home-battery"
},
"heating_rod_hours": {
"default": "mdi:counter"
},
"heating_rod_starts": {
"default": "mdi:counter"
},
"pcc_energy_consumption": {
"default": "mdi:transmission-tower-export"
},
@@ -116,9 +137,15 @@
"valve_position": {
"default": "mdi:pipe-valve"
},
"ventilation_input_volumeflow": {
"default": "mdi:air-filter"
},
"ventilation_level": {
"default": "mdi:fan"
},
"ventilation_output_volumeflow": {
"default": "mdi:air-filter"
},
"volumetric_flow": {
"default": "mdi:gauge"
},

View File

@@ -12,5 +12,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["PyViCare"],
"requirements": ["PyViCare==2.54.0"]
"requirements": ["PyViCare==2.55.0"]
}

View File

@@ -26,7 +26,9 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
EntityCategory,
UnitOfEnergy,
UnitOfMass,
@@ -42,6 +44,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
VICARE_BAR,
VICARE_CELSIUS,
VICARE_CUBIC_METER,
VICARE_KW,
VICARE_KWH,
@@ -56,7 +59,9 @@ from .utils import (
get_burners,
get_circuits,
get_compressors,
get_condensers,
get_device_serial,
get_evaporators,
is_supported,
normalize_state,
)
@@ -74,6 +79,7 @@ VICARE_UNIT_TO_DEVICE_CLASS = {
VICARE_UNIT_TO_HA_UNIT = {
VICARE_BAR: UnitOfPressure.BAR,
VICARE_CELSIUS: UnitOfTemperature.CELSIUS,
VICARE_CUBIC_METER: UnitOfVolume.CUBIC_METERS,
VICARE_KW: UnitOfPower.KILO_WATT,
VICARE_KWH: UnitOfEnergy.KILO_WATT_HOUR,
@@ -111,6 +117,14 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
),
ViCareSensorEntityDescription(
key="outside_humidity",
translation_key="outside_humidity",
native_unit_of_measurement=PERCENTAGE,
value_getter=lambda api: api.getOutsideHumidity(),
device_class=SensorDeviceClass.HUMIDITY,
state_class=SensorStateClass.MEASUREMENT,
),
ViCareSensorEntityDescription(
key="return_temperature",
translation_key="return_temperature",
@@ -992,6 +1006,101 @@ GLOBAL_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
value_getter=lambda api: api.getHydraulicSeparatorTemperature(),
),
SUPPLY_TEMPERATURE_SENSOR,
ViCareSensorEntityDescription(
key="supply_humidity",
translation_key="supply_humidity",
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getSupplyHumidity(),
),
ViCareSensorEntityDescription(
key="supply_fan_hours",
translation_key="supply_fan_hours",
native_unit_of_measurement=UnitOfTime.HOURS,
value_getter=lambda api: api.getSupplyFanHours(),
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="supply_fan_speed",
translation_key="supply_fan_speed",
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
value_getter=lambda api: api.getSupplyFanSpeed(),
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="filter_hours",
translation_key="filter_hours",
native_unit_of_measurement=UnitOfTime.HOURS,
value_getter=lambda api: api.getFilterHours(),
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="filter_remaining_hours",
translation_key="filter_remaining_hours",
native_unit_of_measurement=UnitOfTime.HOURS,
value_getter=lambda api: api.getFilterRemainingHours(),
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="filter_overdue_hours",
translation_key="filter_overdue_hours",
native_unit_of_measurement=UnitOfTime.HOURS,
value_getter=lambda api: api.getFilterOverdueHours(),
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="pm01",
device_class=SensorDeviceClass.PM1,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getAirborneDustPM1(),
),
ViCareSensorEntityDescription(
key="pm02",
device_class=SensorDeviceClass.PM25,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getAirborneDustPM2d5(),
),
ViCareSensorEntityDescription(
key="pm04",
device_class=SensorDeviceClass.PM4,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getAirborneDustPM4(),
),
ViCareSensorEntityDescription(
key="pm10",
device_class=SensorDeviceClass.PM10,
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
value_getter=lambda api: api.getAirborneDustPM10(),
),
ViCareSensorEntityDescription(
key="ventilation_input_volumeflow",
translation_key="ventilation_input_volumeflow",
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
value_getter=lambda api: api.getSupplyVolumeFlow(),
state_class=SensorStateClass.MEASUREMENT,
),
ViCareSensorEntityDescription(
key="ventilation_output_volumeflow",
translation_key="ventilation_output_volumeflow",
native_unit_of_measurement=UnitOfVolumeFlowRate.CUBIC_METERS_PER_HOUR,
value_getter=lambda api: api.getExhaustVolumeFlow(),
state_class=SensorStateClass.MEASUREMENT,
),
)
CIRCUIT_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
@@ -1090,6 +1199,84 @@ COMPRESSOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
value_getter=lambda api: normalize_state(api.getPhase()),
entity_category=EntityCategory.DIAGNOSTIC,
),
ViCareSensorEntityDescription(
key="compressor_inlet_temperature",
translation_key="compressor_inlet_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_getter=lambda api: api.getCompressorInletTemperature(),
unit_getter=lambda api: api.getCompressorInletTemperatureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="compressor_outlet_temperature",
translation_key="compressor_outlet_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_getter=lambda api: api.getCompressorOutletTemperature(),
unit_getter=lambda api: api.getCompressorOutletTemperatureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="compressor_inlet_pressure",
translation_key="compressor_inlet_pressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.BAR,
value_getter=lambda api: api.getCompressorInletPressure(),
unit_getter=lambda api: api.getCompressorInletPressureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="compressor_outlet_pressure",
translation_key="compressor_outlet_pressure",
device_class=SensorDeviceClass.PRESSURE,
native_unit_of_measurement=UnitOfPressure.BAR,
value_getter=lambda api: api.getCompressorOutletPressure(),
unit_getter=lambda api: api.getCompressorOutletPressureUnit(),
entity_registry_enabled_default=False,
),
)
CONDENSER_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
ViCareSensorEntityDescription(
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(),
unit_getter=lambda api: api.getCondensorLiquidTemperatureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
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(),
unit_getter=lambda api: api.getCondensorSubcoolingTemperatureUnit(),
entity_registry_enabled_default=False,
),
)
EVAPORATOR_SENSORS: tuple[ViCareSensorEntityDescription, ...] = (
ViCareSensorEntityDescription(
key="evaporator_overheat_temperature",
translation_key="evaporator_overheat_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_getter=lambda api: api.getEvaporatorOverheatTemperature(),
unit_getter=lambda api: api.getEvaporatorOverheatTemperatureUnit(),
entity_registry_enabled_default=False,
),
ViCareSensorEntityDescription(
key="evaporator_liquid_temperature",
translation_key="evaporator_liquid_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_getter=lambda api: api.getEvaporatorLiquidTemperature(),
unit_getter=lambda api: api.getEvaporatorLiquidTemperatureUnit(),
entity_registry_enabled_default=False,
),
)
@@ -1116,6 +1303,8 @@ def _build_entities(
(get_circuits(device.api), CIRCUIT_SENSORS),
(get_burners(device.api), BURNER_SENSORS),
(get_compressors(device.api), COMPRESSOR_SENSORS),
(get_condensers(device.api), CONDENSER_SENSORS),
(get_evaporators(device.api), EVAPORATOR_SENSORS),
):
entities.extend(
ViCareSensor(

View File

@@ -78,6 +78,9 @@
},
"valve": {
"name": "Valve"
},
"ventilation_frost_protection": {
"name": "Ventilation frost protection"
}
},
"button": {
@@ -212,6 +215,18 @@
"compressor_hours_loadclass5": {
"name": "Compressor hours load class 5"
},
"compressor_inlet_pressure": {
"name": "Compressor inlet pressure"
},
"compressor_inlet_temperature": {
"name": "Compressor inlet temperature"
},
"compressor_outlet_pressure": {
"name": "Compressor outlet pressure"
},
"compressor_outlet_temperature": {
"name": "Compressor outlet temperature"
},
"compressor_phase": {
"name": "Compressor phase",
"state": {
@@ -229,6 +244,12 @@
"compressor_starts": {
"name": "Compressor starts"
},
"condenser_liquid_temperature": {
"name": "Condenser liquid temperature"
},
"condenser_subcooling_temperature": {
"name": "Condenser subcooling temperature"
},
"dhw_storage_bottom_temperature": {
"name": "DHW storage bottom temperature"
},
@@ -303,6 +324,21 @@
"standby": "[%key:common::state::standby%]"
}
},
"evaporator_liquid_temperature": {
"name": "Evaporator liquid temperature"
},
"evaporator_overheat_temperature": {
"name": "Evaporator overheat temperature"
},
"filter_hours": {
"name": "Filter hours"
},
"filter_overdue_hours": {
"name": "Filter overdue hours"
},
"filter_remaining_hours": {
"name": "Filter remaining hours"
},
"fuel_need": {
"name": "Fuel need"
},
@@ -396,6 +432,9 @@
"hydraulic_separator_temperature": {
"name": "Hydraulic separator temperature"
},
"outside_humidity": {
"name": "Outside humidity"
},
"outside_temperature": {
"name": "Outside temperature"
},
@@ -499,6 +538,15 @@
"spf_total": {
"name": "Seasonal performance factor"
},
"supply_fan_hours": {
"name": "Supply fan hours"
},
"supply_fan_speed": {
"name": "Supply fan speed"
},
"supply_humidity": {
"name": "Supply humidity"
},
"supply_pressure": {
"name": "Supply pressure"
},
@@ -508,6 +556,9 @@
"valve_position": {
"name": "Valve position"
},
"ventilation_input_volumeflow": {
"name": "Ventilation input volume flow"
},
"ventilation_level": {
"name": "Ventilation level",
"state": {
@@ -518,6 +569,9 @@
"standby": "[%key:common::state::standby%]"
}
},
"ventilation_output_volumeflow": {
"name": "Ventilation output volume flow"
},
"ventilation_reason": {
"name": "Ventilation reason",
"state": {

View File

@@ -130,6 +130,28 @@ def get_compressors(device: PyViCareDevice) -> list[PyViCareHeatingDeviceCompone
return []
def get_condensers(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]:
"""Return the list of condensers."""
try:
return device.condensors
except PyViCareNotSupportedFeatureError:
_LOGGER.debug("No condensers found")
except AttributeError as error:
_LOGGER.debug("No condensers found: %s", error)
return []
def get_evaporators(device: PyViCareDevice) -> list[PyViCareHeatingDeviceComponent]:
"""Return the list of evaporators."""
try:
return device.evaporators
except PyViCareNotSupportedFeatureError:
_LOGGER.debug("No evaporators found")
except AttributeError as error:
_LOGGER.debug("No evaporators found: %s", error)
return []
def filter_state(state: str) -> str | None:
"""Return the state if not 'nothing' or 'unknown'."""
return None if state in ("nothing", "unknown") else state

View File

@@ -363,7 +363,7 @@
"message": "Unable to retrieve vehicle details."
},
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"unauthorized": {
"message": "Authentication failed. {message}"

View File

@@ -334,7 +334,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
}
}

View File

@@ -9,7 +9,12 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
from .coordinator import XboxConfigEntry, XboxUpdateCoordinator
from .coordinator import (
XboxConfigEntry,
XboxConsolesCoordinator,
XboxCoordinators,
XboxUpdateCoordinator,
)
_LOGGER = logging.getLogger(__name__)
@@ -30,7 +35,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool
coordinator = XboxUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
consoles = XboxConsolesCoordinator(hass, entry, coordinator)
entry.runtime_data = XboxCoordinators(coordinator, consoles)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
@@ -53,16 +60,14 @@ async def async_migrate_unique_id(hass: HomeAssistant, entry: XboxConfigEntry) -
if entry.version == 1 and entry.minor_version < 2:
# Migrate unique_id from `xbox` to account xuid and
# change generic entry name to user's gamertag
coordinator = entry.runtime_data.status
xuid = coordinator.client.xuid
gamertag = coordinator.data.presence[xuid].gamertag
return hass.config_entries.async_update_entry(
entry,
unique_id=entry.runtime_data.client.xuid,
title=(
entry.runtime_data.data.presence[
entry.runtime_data.client.xuid
].gamertag
if entry.title == "Home Assistant Cloud"
else entry.title
),
unique_id=xuid,
title=(gamertag if entry.title == "Home Assistant Cloud" else entry.title),
minor_version=2,
)

View File

@@ -44,7 +44,6 @@ class XboxBinarySensorEntityDescription(
"""Xbox binary sensor description."""
is_on_fn: Callable[[Person], bool | None]
deprecated: bool | None = None
def profile_attributes(person: Person, _: Title | None) -> dict[str, Any]:
@@ -112,7 +111,7 @@ async def async_setup_entry(
) -> None:
"""Set up Xbox Live friends."""
xuids_added: set[str] = set()
coordinator = entry.runtime_data
coordinator = entry.runtime_data.status
@callback
def add_entities() -> None:
@@ -120,16 +119,16 @@ async def async_setup_entry(
current_xuids = set(coordinator.data.presence)
if new_xuids := current_xuids - xuids_added:
for xuid in new_xuids:
async_add_entities(
[
XboxBinarySensorEntity(coordinator, xuid, description)
for description in SENSOR_DESCRIPTIONS
if check_deprecated_entity(
hass, xuid, description, BINARY_SENSOR_DOMAIN
)
]
)
async_add_entities(
[
XboxBinarySensorEntity(coordinator, xuid, description)
for xuid in new_xuids
for description in SENSOR_DESCRIPTIONS
if check_deprecated_entity(
hass, xuid, description, BINARY_SENSOR_DOMAIN
)
]
)
xuids_added |= new_xuids
xuids_added &= current_xuids

View File

@@ -4,5 +4,3 @@ DOMAIN = "xbox"
OAUTH2_AUTHORIZE = "https://login.live.com/oauth20_authorize.srf"
OAUTH2_TOKEN = "https://login.live.com/oauth20_token.srf"
EVENT_NEW_FAVORITE = "xbox/new_favorite"

View File

@@ -35,7 +35,7 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type XboxConfigEntry = ConfigEntry[XboxUpdateCoordinator]
type XboxConfigEntry = ConfigEntry[XboxCoordinators]
@dataclass
@@ -55,17 +55,25 @@ class XboxData:
title_info: dict[str, Title] = field(default_factory=dict)
@dataclass
class XboxCoordinators:
"""Xbox coordinators."""
status: XboxUpdateCoordinator
consoles: XboxConsolesCoordinator
class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]):
"""Store Xbox Console Status."""
config_entry: ConfigEntry
config_entry: XboxConfigEntry
consoles: SmartglassConsoleList
client: XboxLiveClient
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: XboxConfigEntry,
) -> None:
"""Initialize."""
super().__init__(
@@ -280,3 +288,43 @@ class XboxUpdateCoordinator(DataUpdateCoordinator[XboxData]):
for entry in self.hass.config_entries.async_entries(DOMAIN)
if entry.unique_id is not None
}
class XboxConsolesCoordinator(DataUpdateCoordinator[SmartglassConsoleList]):
"""Update list of Xbox consoles."""
config_entry: XboxConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: XboxConfigEntry,
coordinator: XboxUpdateCoordinator,
) -> None:
"""Initialize."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(minutes=10),
)
self.client = coordinator.client
self.async_set_updated_data(coordinator.consoles)
async def _async_update_data(self) -> SmartglassConsoleList:
"""Fetch console data."""
try:
return await self.client.smartglass.get_console_list()
except TimeoutException as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="timeout_exception",
) from e
except (RequestError, HTTPStatusError) as e:
_LOGGER.debug("Xbox exception:", exc_info=True)
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="request_exception",
) from e

View File

@@ -94,8 +94,7 @@ class XboxBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]):
"""Return entity specific state attributes."""
return (
fn(self.data, self.title_info)
if hasattr(self.entity_description, "attributes_fn")
and (fn := self.entity_description.attributes_fn)
if (fn := self.entity_description.attributes_fn)
else super().extra_state_attributes
)
@@ -122,7 +121,7 @@ class XboxConsoleBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]):
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, console.id)},
manufacturer="Microsoft",
model=MAP_MODEL.get(self._console.console_type, "Unknown"),
model=MAP_MODEL.get(self._console.console_type),
name=console.name,
)
@@ -135,11 +134,11 @@ class XboxConsoleBaseEntity(CoordinatorEntity[XboxUpdateCoordinator]):
def check_deprecated_entity(
hass: HomeAssistant,
xuid: str,
entity_description: EntityDescription,
entity_description: XboxBaseEntityDescription,
entity_domain: str,
) -> bool:
"""Check for deprecated entity and remove it."""
if not getattr(entity_description, "deprecated", False):
if not entity_description.deprecated:
return True
ent_reg = er.async_get(hass)
if entity_id := ent_reg.async_get_entity_id(

View File

@@ -64,7 +64,7 @@ async def async_setup_entry(
) -> None:
"""Set up Xbox images."""
coordinator = config_entry.runtime_data
coordinator = config_entry.runtime_data.status
xuids_added: set[str] = set()

View File

@@ -3,7 +3,7 @@
"name": "Xbox",
"codeowners": ["@hunterjm", "@tr4nt0r"],
"config_flow": true,
"dependencies": ["auth", "application_credentials"],
"dependencies": ["application_credentials"],
"dhcp": [
{
"hostname": "xbox*"

View File

@@ -56,7 +56,7 @@ async def async_setup_entry(
) -> None:
"""Set up Xbox media_player from a config entry."""
coordinator = entry.runtime_data
coordinator = entry.runtime_data.status
async_add_entities(
[

View File

@@ -112,7 +112,7 @@ class XboxSource(MediaSource):
translation_key="account_not_configured",
) from e
client = entry.runtime_data.client
client = entry.runtime_data.status.client
if identifier.media_type in (ATTR_GAMECLIPS, ATTR_COMMUNITY_GAMECLIPS):
try:
@@ -302,7 +302,7 @@ class XboxSource(MediaSource):
async def _build_games(self, entry: XboxConfigEntry) -> list[BrowseMediaSource]:
"""List Xbox games for the selected account."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
if TYPE_CHECKING:
assert entry.unique_id
fields = [
@@ -346,7 +346,7 @@ class XboxSource(MediaSource):
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> BrowseMediaSource:
"""Display game title."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
try:
game = (await client.titlehub.get_title_info(identifier.title_id)).titles[0]
except TimeoutException as e:
@@ -402,7 +402,7 @@ class XboxSource(MediaSource):
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> BrowseMediaSource:
"""List game media."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
try:
game = (await client.titlehub.get_title_info(identifier.title_id)).titles[0]
except TimeoutException as e:
@@ -439,7 +439,7 @@ class XboxSource(MediaSource):
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media items."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
if identifier.media_type != ATTR_GAMECLIPS:
return []
@@ -483,7 +483,7 @@ class XboxSource(MediaSource):
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media items."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
if identifier.media_type != ATTR_COMMUNITY_GAMECLIPS:
return []
@@ -527,7 +527,7 @@ class XboxSource(MediaSource):
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media items."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
if identifier.media_type != ATTR_SCREENSHOTS:
return []
@@ -571,7 +571,7 @@ class XboxSource(MediaSource):
self, entry: XboxConfigEntry, identifier: XboxMediaSourceIdentifier
) -> list[BrowseMediaSource]:
"""List media items."""
client = entry.runtime_data.client
client = entry.runtime_data.status.client
if identifier.media_type != ATTR_COMMUNITY_SCREENSHOTS:
return []
@@ -640,7 +640,7 @@ class XboxSource(MediaSource):
def gamerpic(config_entry: XboxConfigEntry) -> str | None:
"""Return gamerpic."""
coordinator = config_entry.runtime_data
coordinator = config_entry.runtime_data.status
if TYPE_CHECKING:
assert config_entry.unique_id
person = coordinator.data.presence[coordinator.client.xuid]

View File

@@ -27,7 +27,7 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Xbox media_player from a config entry."""
coordinator = entry.runtime_data
coordinator = entry.runtime_data.status
async_add_entities(
[XboxRemote(console, coordinator) for console in coordinator.consoles.result]

View File

@@ -9,20 +9,32 @@ from enum import StrEnum
from typing import Any
from pythonxbox.api.provider.people.models import Person
from pythonxbox.api.provider.smartglass.models import SmartglassConsole, StorageDevice
from pythonxbox.api.provider.titlehub.models import Title
from homeassistant.components.sensor import (
DOMAIN as SENSOR_DOMAIN,
EntityCategory,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import CONF_NAME, UnitOfInformation
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import XboxConfigEntry
from .entity import XboxBaseEntity, XboxBaseEntityDescription, check_deprecated_entity
from .const import DOMAIN
from .coordinator import XboxConfigEntry, XboxConsolesCoordinator
from .entity import (
MAP_MODEL,
XboxBaseEntity,
XboxBaseEntityDescription,
check_deprecated_entity,
)
MAP_JOIN_RESTRICTIONS = {
"local": "invite_only",
@@ -44,6 +56,8 @@ class XboxSensor(StrEnum):
FRIENDS = "friends"
IN_PARTY = "in_party"
JOIN_RESTRICTIONS = "join_restrictions"
TOTAL_STORAGE = "total_storage"
FREE_STORAGE = "free_storage"
@dataclass(kw_only=True, frozen=True)
@@ -51,7 +65,15 @@ class XboxSensorEntityDescription(XboxBaseEntityDescription, SensorEntityDescrip
"""Xbox sensor description."""
value_fn: Callable[[Person, Title | None], StateType | datetime]
deprecated: bool | None = None
@dataclass(kw_only=True, frozen=True)
class XboxStorageDeviceSensorEntityDescription(
XboxBaseEntityDescription, SensorEntityDescription
):
"""Xbox console sensor description."""
value_fn: Callable[[StorageDevice], StateType]
def now_playing_attributes(_: Person, title: Title | None) -> dict[str, Any]:
@@ -196,6 +218,31 @@ SENSOR_DESCRIPTIONS: tuple[XboxSensorEntityDescription, ...] = (
),
)
STORAGE_SENSOR_DESCRIPTIONS: tuple[XboxStorageDeviceSensorEntityDescription, ...] = (
XboxStorageDeviceSensorEntityDescription(
key=XboxSensor.TOTAL_STORAGE,
translation_key=XboxSensor.TOTAL_STORAGE,
value_fn=lambda x: x.total_space_bytes,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=0,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
XboxStorageDeviceSensorEntityDescription(
key=XboxSensor.FREE_STORAGE,
translation_key=XboxSensor.FREE_STORAGE,
value_fn=lambda x: x.free_space_bytes,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=0,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -204,7 +251,7 @@ async def async_setup_entry(
) -> None:
"""Set up Xbox Live friends."""
xuids_added: set[str] = set()
coordinator = config_entry.runtime_data
coordinator = config_entry.runtime_data.status
@callback
def add_entities() -> None:
@@ -212,22 +259,34 @@ async def async_setup_entry(
current_xuids = set(coordinator.data.presence)
if new_xuids := current_xuids - xuids_added:
for xuid in new_xuids:
async_add_entities(
[
XboxSensorEntity(coordinator, xuid, description)
for description in SENSOR_DESCRIPTIONS
if check_deprecated_entity(
hass, xuid, description, SENSOR_DOMAIN
)
]
)
async_add_entities(
[
XboxSensorEntity(coordinator, xuid, description)
for xuid in new_xuids
for description in SENSOR_DESCRIPTIONS
if check_deprecated_entity(hass, xuid, description, SENSOR_DOMAIN)
]
)
xuids_added |= new_xuids
xuids_added &= current_xuids
coordinator.async_add_listener(add_entities)
add_entities()
consoles_coordinator = config_entry.runtime_data.consoles
async_add_entities(
[
XboxStorageDeviceSensorEntity(
console, storage_device, consoles_coordinator, description
)
for description in STORAGE_SENSOR_DESCRIPTIONS
for console in coordinator.consoles.result
if console.storage_devices
for storage_device in console.storage_devices
]
)
class XboxSensorEntity(XboxBaseEntity, SensorEntity):
"""Representation of a Xbox presence state."""
@@ -238,3 +297,62 @@ class XboxSensorEntity(XboxBaseEntity, SensorEntity):
def native_value(self) -> StateType | datetime:
"""Return the state of the requested attribute."""
return self.entity_description.value_fn(self.data, self.title_info)
class XboxStorageDeviceSensorEntity(
CoordinatorEntity[XboxConsolesCoordinator], SensorEntity
):
"""Console storage device entity for the Xbox integration."""
_attr_has_entity_name = True
entity_description: XboxStorageDeviceSensorEntityDescription
def __init__(
self,
console: SmartglassConsole,
storage_device: StorageDevice,
coordinator: XboxConsolesCoordinator,
entity_description: XboxStorageDeviceSensorEntityDescription,
) -> None:
"""Initialize the Xbox Console entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self.client = coordinator.client
self._console = console
self._storage_device = storage_device
self._attr_unique_id = (
f"{console.id}_{storage_device.storage_device_id}_{entity_description.key}"
)
self._attr_translation_placeholders = {
CONF_NAME: storage_device.storage_device_name
}
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, console.id)},
manufacturer="Microsoft",
model=MAP_MODEL.get(self._console.console_type, "Unknown"),
name=console.name,
)
@property
def data(self):
"""Storage device data."""
consoles = self.coordinator.data.result
console = next((c for c in consoles if c.id == self._console.id), None)
if not console or not console.storage_devices:
return None
return next(
(
d
for d in console.storage_devices
if d.storage_device_id == self._storage_device.storage_device_id
),
None,
)
@property
def native_value(self) -> StateType:
"""Return the state of the requested attribute."""
return self.entity_description.value_fn(self.data) if self.data else None

View File

@@ -65,6 +65,9 @@
"name": "Following",
"unit_of_measurement": "people"
},
"free_storage": {
"name": "Free space - {name}"
},
"friends": {
"name": "Friends",
"unit_of_measurement": "[%key:component::xbox::entity::sensor::following::unit_of_measurement%]"
@@ -105,6 +108,9 @@
},
"status": {
"name": "Status"
},
"total_storage": {
"name": "Total space - {name}"
}
}
},

View File

@@ -43,7 +43,7 @@
},
"exceptions": {
"oauth2_implementation_unavailable": {
"message": "OAuth2 implementation unavailable, will retry"
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
}
},
"options": {

View File

@@ -71,7 +71,20 @@ async def warn_on_wrong_silabs_firmware(hass: HomeAssistant, device: str) -> boo
if device.startswith("socket://"):
return False
app_type = await probe_silabs_firmware_type(device)
app_type = await probe_silabs_firmware_type(
device,
bootloader_reset_methods=(),
application_probe_methods=[
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, 115200),
(ApplicationType.EZSP, 460800),
(ApplicationType.SPINEL, 460800),
(ApplicationType.CPC, 460800),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 115200),
(ApplicationType.ROUTER, 115200),
],
)
if app_type is None:
# Failed to probe, we can't tell if the wrong firmware is installed

View File

@@ -66,6 +66,7 @@ from .discovery_data_template import (
NumericSensorDataTemplate,
)
from .entity import NewZwaveDiscoveryInfo
from .event import DISCOVERY_SCHEMAS as EVENT_SCHEMAS
from .models import (
FirmwareVersionRange,
NewZWaveDiscoverySchema,
@@ -77,6 +78,7 @@ from .models import (
NEW_DISCOVERY_SCHEMAS: dict[Platform, list[NewZWaveDiscoverySchema]] = {
Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS,
Platform.EVENT: EVENT_SCHEMAS,
}
SUPPORTED_PLATFORMS = tuple(NEW_DISCOVERY_SCHEMAS)
@@ -1164,15 +1166,6 @@ DISCOVERY_SCHEMAS = [
allow_multi=True,
entity_registry_enabled_default=False,
),
# event
# stateful = False
ZWaveDiscoverySchema(
platform=Platform.EVENT,
hint="stateless",
primary_value=ZWaveValueDiscoverySchema(
stateful=False,
),
),
# button
# Meter CC idle
ZWaveDiscoverySchema(

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

@@ -2,22 +2,37 @@
from __future__ import annotations
from dataclasses import dataclass
from zwave_js_server.model.driver import Driver
from zwave_js_server.model.value import Value, ValueNotification
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventEntity
from homeassistant.components.event import (
DOMAIN as EVENT_DOMAIN,
EventEntity,
EventEntityDescription,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_VALUE, DOMAIN
from .discovery import ZwaveDiscoveryInfo
from .entity import ZWaveBaseEntity
from .models import ZwaveJSConfigEntry
from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity
from .models import (
NewZWaveDiscoverySchema,
ZwaveJSConfigEntry,
ZWaveValueDiscoverySchema,
)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class ValueNotificationZWaveJSEntityDescription(EventEntityDescription):
"""Represent a Z-Wave JS event entity description."""
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ZwaveJSConfigEntry,
@@ -27,11 +42,13 @@ async def async_setup_entry(
client = config_entry.runtime_data.client
@callback
def async_add_event(info: ZwaveDiscoveryInfo) -> None:
def async_add_event(info: NewZwaveDiscoveryInfo) -> None:
"""Add Z-Wave event entity."""
driver = client.driver
assert driver is not None # Driver is ready before platforms are loaded.
entities: list[ZWaveBaseEntity] = [ZwaveEventEntity(config_entry, driver, info)]
entities: list[ZWaveBaseEntity] = [
info.entity_class(config_entry, driver, info)
]
async_add_entities(entities)
config_entry.async_on_unload(
@@ -55,7 +72,10 @@ class ZwaveEventEntity(ZWaveBaseEntity, EventEntity):
"""Representation of a Z-Wave event entity."""
def __init__(
self, config_entry: ZwaveJSConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo
self,
config_entry: ZwaveJSConfigEntry,
driver: Driver,
info: NewZwaveDiscoveryInfo,
) -> None:
"""Initialize a ZwaveEventEntity entity."""
super().__init__(config_entry, driver, info)
@@ -96,3 +116,17 @@ class ZwaveEventEntity(ZWaveBaseEntity, EventEntity):
lambda event: self._async_handle_event(event["value_notification"]),
)
)
DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
NewZWaveDiscoverySchema(
platform=Platform.EVENT,
primary_value=ZWaveValueDiscoverySchema(
stateful=False,
),
entity_description=ValueNotificationZWaveJSEntityDescription(
key="value_notification",
),
entity_class=ZwaveEventEntity,
),
]

View File

@@ -645,12 +645,24 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
__progress_task: asyncio.Task[Any] | None = None
__no_progress_task_reported = False
deprecated_show_progress = False
_progress_step_data: ProgressStepData[_FlowResultT] = {
"tasks": {},
"abort_reason": "",
"abort_description_placeholders": MappingProxyType({}),
"next_step_result": None,
}
__progress_step_data: ProgressStepData[_FlowResultT] | None = None
@property
def _progress_step_data(self) -> ProgressStepData[_FlowResultT]:
"""Return progress step data.
A property is used instead of a simple attribute as derived classes
do not call super().__init__.
The property makes sure that the dict is initialized if needed.
"""
if not self.__progress_step_data:
self.__progress_step_data = {
"tasks": {},
"abort_reason": "",
"abort_description_placeholders": MappingProxyType({}),
"next_step_result": None,
}
return self.__progress_step_data
@property
def source(self) -> str | None:
@@ -777,9 +789,10 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
self, user_input: dict[str, Any] | None = None
) -> _FlowResultT:
"""Abort the flow."""
progress_step_data = self._progress_step_data
return self.async_abort(
reason=self._progress_step_data["abort_reason"],
description_placeholders=self._progress_step_data[
reason=progress_step_data["abort_reason"],
description_placeholders=progress_step_data[
"abort_description_placeholders"
],
)
@@ -795,14 +808,15 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
without using async_show_progress_done.
If no next step is set, abort the flow.
"""
if self._progress_step_data["next_step_result"] is None:
progress_step_data = self._progress_step_data
if (next_step_result := progress_step_data["next_step_result"]) is None:
return self.async_abort(
reason=self._progress_step_data["abort_reason"],
description_placeholders=self._progress_step_data[
reason=progress_step_data["abort_reason"],
description_placeholders=progress_step_data[
"abort_description_placeholders"
],
)
return self._progress_step_data["next_step_result"]
return next_step_result
@callback
def async_external_step(
@@ -1021,9 +1035,9 @@ def progress_step[
self: FlowHandler[Any, ResultT], *args: P.args, **kwargs: P.kwargs
) -> ResultT:
step_id = func.__name__.replace("async_step_", "")
progress_step_data = self._progress_step_data
# Check if we have a progress task running
progress_task = self._progress_step_data["tasks"].get(step_id)
progress_task = progress_step_data["tasks"].get(step_id)
if progress_task is None:
# First call - create and start the progress task
@@ -1031,30 +1045,30 @@ def progress_step[
func(self, *args, **kwargs), # type: ignore[arg-type]
f"Progress step {step_id}",
)
self._progress_step_data["tasks"][step_id] = progress_task
progress_step_data["tasks"][step_id] = progress_task
if not progress_task.done():
# Handle description placeholders
placeholders = None
if description_placeholders is not None:
if callable(description_placeholders):
placeholders = description_placeholders(self)
else:
placeholders = description_placeholders
if not progress_task.done():
# Handle description placeholders
placeholders = None
if description_placeholders is not None:
if callable(description_placeholders):
placeholders = description_placeholders(self)
else:
placeholders = description_placeholders
return self.async_show_progress(
step_id=step_id,
progress_action=step_id,
progress_task=progress_task,
description_placeholders=placeholders,
)
return self.async_show_progress(
step_id=step_id,
progress_action=step_id,
progress_task=progress_task,
description_placeholders=placeholders,
)
# Task is done or this is a subsequent call
try:
self._progress_step_data["next_step_result"] = await progress_task
progress_step_data["next_step_result"] = await progress_task
except AbortFlow as err:
self._progress_step_data["abort_reason"] = err.reason
self._progress_step_data["abort_description_placeholders"] = (
progress_step_data["abort_reason"] = err.reason
progress_step_data["abort_description_placeholders"] = (
err.description_placeholders or {}
)
return self.async_show_progress_done(
@@ -1062,7 +1076,7 @@ def progress_step[
)
finally:
# Clean up task reference
self._progress_step_data["tasks"].pop(step_id, None)
progress_step_data["tasks"].pop(step_id, None)
return self.async_show_progress_done(
next_step_id="_progress_step_progress_done"

View File

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

View File

@@ -4,12 +4,14 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, NoReturn
from jinja2.ext import Extension
from jinja2.nodes import Node
from jinja2.parser import Parser
from homeassistant.exceptions import TemplateError
if TYPE_CHECKING:
from homeassistant.core import HomeAssistant
from homeassistant.helpers.template import TemplateEnvironment
@@ -50,8 +52,17 @@ class BaseTemplateExtension(Extension):
if template_func.requires_hass and self.environment.hass is None:
continue
# Skip functions not allowed in limited environments
# Register unsupported stub for functions not allowed in limited environments
if self.environment.limited and not template_func.limited_ok:
unsupported_func = self._create_unsupported_function(
template_func.name
)
if template_func.as_global:
environment.globals[template_func.name] = unsupported_func
if template_func.as_filter:
environment.filters[template_func.name] = unsupported_func
if template_func.as_test:
environment.tests[template_func.name] = unsupported_func
continue
if template_func.as_global:
@@ -61,6 +72,17 @@ class BaseTemplateExtension(Extension):
if template_func.as_test:
environment.tests[template_func.name] = template_func.func
@staticmethod
def _create_unsupported_function(name: str) -> Callable[[], NoReturn]:
"""Create a function that raises an error for unsupported functions in limited templates."""
def unsupported(*args: Any, **kwargs: Any) -> NoReturn:
raise TemplateError(
f"Use of '{name}' is not supported in limited templates"
)
return unsupported
@property
def hass(self) -> HomeAssistant:
"""Return the Home Assistant instance.

20
requirements_all.txt generated
View File

@@ -99,7 +99,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.8.0
# homeassistant.components.vicare
PyViCare==2.54.0
PyViCare==2.55.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -273,7 +273,7 @@ aioharmony==0.5.3
aiohasupervisor==0.3.3
# homeassistant.components.home_connect
aiohomeconnect==0.23.0
aiohomeconnect==0.23.1
# homeassistant.components.homekit_controller
aiohomekit==3.2.20
@@ -1220,7 +1220,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
# homeassistant.components.remote_calendar
ical==11.0.0
ical==11.1.0
# homeassistant.components.caldav
icalendar==6.3.1
@@ -1395,7 +1395,7 @@ loqedAPI==2.1.10
luftdaten==0.7.4
# homeassistant.components.lunatone
lunatone-rest-api-client==0.5.3
lunatone-rest-api-client==0.5.7
# homeassistant.components.lupusec
lupupy==0.3.2
@@ -1456,7 +1456,7 @@ microBeesPy==0.3.5
mill-local==0.3.0
# homeassistant.components.mill
millheater==0.14.0
millheater==0.14.1
# homeassistant.components.minio
minio==7.1.12
@@ -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
@@ -2449,7 +2449,7 @@ python-clementine-remote==1.0.1
python-digitalocean==1.13.2
# homeassistant.components.ecobee
python-ecobee-api==0.2.20
python-ecobee-api==0.3.2
# homeassistant.components.etherscan
python-etherscan-api==0.0.3
@@ -2604,7 +2604,7 @@ pyvera==0.3.16
pyversasense==0.0.6
# homeassistant.components.vesync
pyvesync==3.2.1
pyvesync==3.2.2
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2948,7 +2948,7 @@ tesla-fleet-api==1.2.5
tesla-powerwall==0.5.2
# homeassistant.components.tesla_wall_connector
tesla-wall-connector==1.0.2
tesla-wall-connector==1.1.0
# homeassistant.components.teslemetry
teslemetry-stream==0.7.10
@@ -3047,7 +3047,7 @@ unifi_ap==0.0.2
unifiled==0.11
# homeassistant.components.homeassistant_hardware
universal-silabs-flasher==0.0.37
universal-silabs-flasher==0.1.0
# homeassistant.components.upb
upb-lib==0.6.1

View File

@@ -93,7 +93,7 @@ PyTransportNSW==0.1.1
PyTurboJPEG==1.8.0
# homeassistant.components.vicare
PyViCare==2.54.0
PyViCare==2.55.0
# homeassistant.components.xiaomi_aqara
PyXiaomiGateway==0.14.3
@@ -258,7 +258,7 @@ aioharmony==0.5.3
aiohasupervisor==0.3.3
# homeassistant.components.home_connect
aiohomeconnect==0.23.0
aiohomeconnect==0.23.1
# homeassistant.components.homekit_controller
aiohomekit==3.2.20
@@ -1063,7 +1063,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
# homeassistant.components.remote_calendar
ical==11.0.0
ical==11.1.0
# homeassistant.components.caldav
icalendar==6.3.1
@@ -1199,7 +1199,7 @@ loqedAPI==2.1.10
luftdaten==0.7.4
# homeassistant.components.lunatone
lunatone-rest-api-client==0.5.3
lunatone-rest-api-client==0.5.7
# homeassistant.components.lupusec
lupupy==0.3.2
@@ -1251,7 +1251,7 @@ microBeesPy==0.3.5
mill-local==0.3.0
# homeassistant.components.mill
millheater==0.14.0
millheater==0.14.1
# homeassistant.components.minio
minio==7.1.12
@@ -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
@@ -2036,7 +2036,7 @@ python-awair==0.2.4
python-bsblan==3.1.0
# homeassistant.components.ecobee
python-ecobee-api==0.2.20
python-ecobee-api==0.3.2
# homeassistant.components.fully_kiosk
python-fullykiosk==0.0.14
@@ -2158,7 +2158,7 @@ pyuptimerobot==22.2.0
pyvera==0.3.16
# homeassistant.components.vesync
pyvesync==3.2.1
pyvesync==3.2.2
# homeassistant.components.vizio
pyvizio==0.1.61
@@ -2433,7 +2433,7 @@ tesla-fleet-api==1.2.5
tesla-powerwall==0.5.2
# homeassistant.components.tesla_wall_connector
tesla-wall-connector==1.0.2
tesla-wall-connector==1.1.0
# homeassistant.components.teslemetry
teslemetry-stream==0.7.10
@@ -2514,7 +2514,7 @@ ultraheat-api==0.5.7
unifi-discovery==1.2.0
# homeassistant.components.homeassistant_hardware
universal-silabs-flasher==0.0.37
universal-silabs-flasher==0.1.0
# homeassistant.components.upb
upb-lib==0.6.1

View File

@@ -28,6 +28,8 @@ async def test_auth_failure(hass: HomeAssistant) -> None:
"type": FlowResultType.FORM,
"flow_id": "mock_flow",
"step_id": "reauth_confirm",
"description_placeholders": {"username": "test", "name": "test"},
"data_schema": None,
},
) as mock_async_step_reauth:
await setup_platform(hass, side_effect=AuthenticationException())

View File

@@ -0,0 +1,56 @@
"""Fixtures for energy component tests."""
from unittest.mock import patch
import pytest
from homeassistant.components.energy import async_get_manager
from homeassistant.components.energy.data import EnergyManager
from homeassistant.components.recorder import Recorder
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@pytest.fixture
def mock_is_entity_recorded():
"""Mock recorder.is_entity_recorded."""
mocks = {}
with patch(
"homeassistant.components.recorder.is_entity_recorded",
side_effect=lambda hass, entity_id: mocks.get(entity_id, True),
):
yield mocks
@pytest.fixture
def mock_get_metadata():
"""Mock recorder.statistics.get_metadata."""
mocks = {}
def _get_metadata(_hass, *, statistic_ids):
result = {}
for statistic_id in statistic_ids:
if statistic_id in mocks:
if mocks[statistic_id] is not None:
result[statistic_id] = mocks[statistic_id]
else:
result[statistic_id] = (1, {})
return result
with patch(
"homeassistant.components.recorder.statistics.get_metadata",
wraps=_get_metadata,
):
yield mocks
@pytest.fixture
async def mock_energy_manager(
recorder_mock: Recorder, hass: HomeAssistant
) -> EnergyManager:
"""Set up energy."""
assert await async_setup_component(hass, "energy", {"energy": {}})
manager = await async_get_manager(hass)
manager.data = manager.default_preferences()
return manager

View File

@@ -1,65 +1,24 @@
"""Test that validation works."""
from unittest.mock import patch
import pytest
from homeassistant.components.energy import async_get_manager, validate
from homeassistant.components.energy import validate
from homeassistant.components.energy.data import EnergyManager
from homeassistant.components.recorder import Recorder
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSON_DUMP
from homeassistant.setup import async_setup_component
ENERGY_UNITS_STRING = ", ".join(tuple(UnitOfEnergy))
ENERGY_PRICE_UNITS_STRING = ", ".join(f"EUR/{unit}" for unit in tuple(UnitOfEnergy))
@pytest.fixture
def mock_is_entity_recorded():
"""Mock recorder.is_entity_recorded."""
mocks = {}
with patch(
"homeassistant.components.recorder.is_entity_recorded",
side_effect=lambda hass, entity_id: mocks.get(entity_id, True),
):
yield mocks
@pytest.fixture
def mock_get_metadata():
"""Mock recorder.statistics.get_metadata."""
mocks = {}
def _get_metadata(_hass, *, statistic_ids):
result = {}
for statistic_id in statistic_ids:
if statistic_id in mocks:
if mocks[statistic_id] is not None:
result[statistic_id] = mocks[statistic_id]
else:
result[statistic_id] = (1, {})
return result
with patch(
"homeassistant.components.recorder.statistics.get_metadata",
wraps=_get_metadata,
):
yield mocks
@pytest.fixture(autouse=True)
async def mock_energy_manager(
recorder_mock: Recorder, hass: HomeAssistant
async def setup_energy_for_validation(
mock_energy_manager: EnergyManager,
) -> EnergyManager:
"""Set up energy."""
assert await async_setup_component(hass, "energy", {"energy": {}})
manager = await async_get_manager(hass)
manager.data = manager.default_preferences()
return manager
"""Ensure energy manager is set up for validation tests."""
return mock_energy_manager
async def test_validation_empty_config(hass: HomeAssistant) -> None:
@@ -413,6 +372,7 @@ async def test_validation_grid(
"stat_compensation": "sensor.grid_compensation_1",
}
],
"power": [],
}
]
}
@@ -504,6 +464,7 @@ async def test_validation_grid_external_cost_compensation(
"stat_compensation": "external:grid_compensation_1",
}
],
"power": [],
}
]
}
@@ -742,6 +703,7 @@ async def test_validation_grid_price_errors(
}
],
"flow_to": [],
"power": [],
}
]
}
@@ -947,6 +909,7 @@ async def test_validation_grid_no_costs_tracking(
"number_energy_price": None,
},
],
"power": [],
"cost_adjustment_day": 0.0,
}
]

Some files were not shown because too many files have changed in this diff Show More