Compare commits

..

37 Commits

Author SHA1 Message Date
epenet
7b2e4dbe16 Ruff 2025-10-17 13:46:52 +00:00
epenet
1f812e9f89 Fix motor reverse mode logic 2025-10-17 14:54:20 +02:00
johanzander
b182d5ce87 Add additional unit tests for Growatt Server integration (#154644) 2025-10-17 14:22:16 +02:00
Thomas55555
175365bdea Add integration_type to Husqvarna Automower (#154642) 2025-10-17 14:18:32 +02:00
Bouwe Westerdijk
cbe52cbfca Bump plugwise to v1.8.1 (#154679) 2025-10-17 15:13:35 +03:00
Felipe Santos
9251dde2c6 Add OpenRGB reconfiguration flow (#154478) 2025-10-17 12:27:11 +02:00
Andrew Jackson
24d77cc453 Bump aiomealie to 1.0.1 (#154672) 2025-10-17 12:23:55 +03:00
johanzander
a1f98abe49 Add CODEOWNERS entry for Growatt Server integration (#154647) 2025-10-17 11:20:11 +03:00
cdnninja
d25dde1d11 Bump pyvesync version to 3.1.2 (#154650)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-10-17 10:19:48 +02:00
hanwg
8ec483b38b Fix Telegram bot bug where message is sent to wrong recipient (#154658) 2025-10-17 11:15:41 +03:00
epenet
bf14caca69 Fix behavior spelling for public facing strings (#154665) 2025-10-17 11:07:05 +03:00
Ludovic BOUÉ
e5fb6b2fb2 Remove duplicated Matter powersource cluster from Mock device fixture files (#154668) 2025-10-17 11:06:01 +03:00
epenet
7dfeb3a3f6 Improve metoffice typing (#154670) 2025-10-17 10:05:27 +02:00
epenet
9d3b1562c4 Remove more components from _IGNORE_ROOT_IMPORT in pylint plugin (#154667) 2025-10-17 09:46:53 +02:00
epenet
e14407f066 Remove HomeAssistantRemoteScanner from __all__ in bluetooth (#154669) 2025-10-17 09:31:30 +02:00
epenet
67872e3746 Adjust onewire strings (#154664) 2025-10-17 09:28:37 +02:00
Manu
06bd1a2003 Migrate Xbox to runtime_data (#154652) 2025-10-17 09:25:49 +02:00
dependabot[bot]
37ea360304 Bump sigstore/cosign-installer from 3.10.0 to 4.0.0 (#154661)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-10-17 09:15:41 +02:00
epenet
25ce57424c Remove more components from _IGNORE_ROOT_IMPORT in pylint plugin (#154660) 2025-10-17 08:35:18 +02:00
Thomas55555
3d46ab549d Add serial number to IPP (#154648) 2025-10-16 23:58:57 +01:00
Thomas55555
567cc9f842 Bump colorlog to 6.10.1 (#154643) 2025-10-16 23:57:24 +01:00
Shay Levy
b5457a5abd Fix demo cover set position action (#154641) 2025-10-16 21:21:32 +03:00
Marc Mueller
e4b5e35d1d Update Pillow to 12.0.0 (#154637) 2025-10-16 18:25:36 +01:00
Ludovic BOUÉ
12023c33b5 Rename Mock Door Lock with unbolt fixture (#154627) 2025-10-16 13:01:46 -04:00
Jan Čermák
a28749937c Allow ignored rapt_ble devices to be set up from the user flow (#154606) 2025-10-16 12:54:24 -04:00
Jan Čermák
3fe37d651f Update Home Assistant base image to 2025.10.1 (#154609)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-10-16 12:53:25 -04:00
epenet
cb3424cdf0 Remove more components from _IGNORE_ROOT_IMPORT in pylint plugin (#154622) 2025-10-16 12:52:51 -04:00
Thomas D
a799f7ff91 Add service warning sensor to Volvo integration (#154613) 2025-10-16 18:52:12 +02:00
Louis Pré
34ab725b75 LLM prefix caching optimization using new GetDateTime tool (#152408)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Denis Shulyaka <Shulyaka@gmail.com>
2025-10-16 12:47:12 -04:00
Manu
2dfc7f02ba Bump habiticalib to v0.4.6 (#154566) 2025-10-16 17:15:13 +01:00
Jan Čermák
c8919222bd Mock network calls in comfoconnect tests to fix timeouts (#154620) 2025-10-16 11:42:04 -04:00
Ludovic BOUÉ
a888264d2f Add Matter fixture for Aqara Smart Lock U200 (#154623) 2025-10-16 16:25:16 +02:00
Joost Lekkerkerker
ae84c7e15d Add subentries to WAQI (#148966)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-10-16 14:11:52 +01:00
epenet
415c8b490b Add device diagnostics to onewire (#154617)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-16 14:56:19 +02:00
Aviad Levy
6038f15406 Add support for Telegram message attachments (#153216) 2025-10-16 14:54:50 +02:00
Justus
a8758253c4 Add config flow exceptions to IOMeter (#154604)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-16 14:52:51 +02:00
epenet
fa4eb2e820 The 1-wire integration has now reached silver on the quality scale (#154614) 2025-10-16 14:52:11 +02:00
139 changed files with 15073 additions and 1212 deletions

View File

@@ -326,7 +326,7 @@ jobs:
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
- name: Install Cosign
uses: sigstore/cosign-installer@d7543c93d881b35a8faa02e8e3605f69b7a1ce62 # v3.10.0
uses: sigstore/cosign-installer@faadad0cce49287aee09b3a48701e75088a2c6ad # v4.0.0
with:
cosign-release: "v2.2.3"

2
CODEOWNERS generated
View File

@@ -619,6 +619,8 @@ build.json @home-assistant/supervisor
/tests/components/greeneye_monitor/ @jkeljo
/homeassistant/components/group/ @home-assistant/core
/tests/components/group/ @home-assistant/core
/homeassistant/components/growatt_server/ @johanzander
/tests/components/growatt_server/ @johanzander
/homeassistant/components/guardian/ @bachya
/tests/components/guardian/ @bachya
/homeassistant/components/habitica/ @tr4nt0r

View File

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

View File

@@ -29,7 +29,7 @@
},
"data_description": {
"return_average": "air-Q allows to poll both the noisy sensor readings as well as the values averaged on the device (default)",
"clip_negatives": "For baseline calibration purposes, certain sensor values may briefly become negative. The default behaviour is to clip such values to 0"
"clip_negatives": "For baseline calibration purposes, certain sensor values may briefly become negative. The default behavior is to clip such values to 0"
}
}
}

View File

@@ -146,7 +146,7 @@
},
"state": {
"title": "Add a Bayesian sensor",
"description": "Add an observation which evaluates to `True` when the value of the sensor exactly matches *'To state'*. When `False`, it will update the prior with probabilities that are the inverse of those set below. This behaviour can be overridden by adding observations for the same entity's other states.",
"description": "Add an observation which evaluates to `True` when the value of the sensor exactly matches *'To state'*. When `False`, it will update the prior with probabilities that are the inverse of those set below. This behavior can be overridden by adding observations for the same entity's other states.",
"data": {
"name": "[%key:common::config_flow::data::name%]",

View File

@@ -113,7 +113,6 @@ __all__ = [
"BluetoothServiceInfo",
"BluetoothServiceInfoBleak",
"HaBluetoothConnector",
"HomeAssistantRemoteScanner",
"async_address_present",
"async_ble_device_from_address",
"async_clear_address_from_match_history",

View File

@@ -74,7 +74,10 @@ from .const import (
StreamType,
)
from .helper import get_camera_from_entity_id
from .img_util import scale_jpeg_camera_image
from .img_util import (
TurboJPEGSingleton, # noqa: F401
scale_jpeg_camera_image,
)
from .prefs import (
CameraPreferences,
DynamicStreamSettings, # noqa: F401

View File

@@ -19,7 +19,7 @@ from homeassistant.components.alexa import (
errors as alexa_errors,
smart_home as alexa_smart_home,
)
from homeassistant.components.camera.webrtc import async_register_ice_servers
from homeassistant.components.camera import async_register_ice_servers
from homeassistant.components.google_assistant import smart_home as ga
from homeassistant.const import __version__ as HA_VERSION
from homeassistant.core import Context, HassJob, HomeAssistant, callback

View File

@@ -12,7 +12,9 @@ from hass_nabucasa.google_report_state import ErrorResponse
from homeassistant.components.binary_sensor import BinarySensorDeviceClass
from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN
from homeassistant.components.google_assistant.helpers import AbstractConfig
from homeassistant.components.google_assistant.helpers import ( # pylint: disable=hass-component-root-import
AbstractConfig,
)
from homeassistant.components.homeassistant.exposed_entities import (
async_expose_entity,
async_get_assistant_settings,

View File

@@ -11,7 +11,7 @@ from hass_nabucasa.voice import MAP_VOICE, Gender
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import User
from homeassistant.components import webhook
from homeassistant.components.google_assistant.http import (
from homeassistant.components.google_assistant.http import ( # pylint: disable=hass-component-root-import
async_get_users as async_get_google_assistant_users,
)
from homeassistant.core import HomeAssistant, callback

View File

@@ -6,7 +6,9 @@ from typing import Any
import uuid
from homeassistant.components.automation import DOMAIN as AUTOMATION_DOMAIN
from homeassistant.components.automation.config import async_validate_config_item
from homeassistant.components.automation.config import ( # pylint: disable=hass-component-root-import
async_validate_config_item,
)
from homeassistant.config import AUTOMATION_CONFIG_PATH
from homeassistant.const import CONF_ID, SERVICE_RELOAD
from homeassistant.core import HomeAssistant, callback

View File

@@ -5,7 +5,9 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.script import DOMAIN as SCRIPT_DOMAIN
from homeassistant.components.script.config import async_validate_config_item
from homeassistant.components.script.config import ( # pylint: disable=hass-component-root-import
async_validate_config_item,
)
from homeassistant.config import SCRIPT_CONFIG_PATH
from homeassistant.const import SERVICE_RELOAD
from homeassistant.core import HomeAssistant, callback

View File

@@ -569,14 +569,17 @@ class ChatLog:
if llm_api:
prompt_parts.append(llm_api.api_prompt)
prompt_parts.append(
await self._async_expand_prompt_template(
llm_context,
llm.BASE_PROMPT,
llm_context.language,
user_name,
# Append current date and time to the prompt if the corresponding tool is not provided
llm_tools: list[llm.Tool] = llm_api.tools if llm_api else []
if not any(tool.name.endswith("GetDateTime") for tool in llm_tools):
prompt_parts.append(
await self._async_expand_prompt_template(
llm_context,
llm.DATE_TIME_PROMPT,
llm_context.language,
user_name,
)
)
)
if extra_system_prompt := (
# Take new system prompt if one was given

View File

@@ -5,7 +5,9 @@ from __future__ import annotations
import datetime
from homeassistant.components.alarm_control_panel import AlarmControlPanelState
from homeassistant.components.manual.alarm_control_panel import ManualAlarm
from homeassistant.components.manual.alarm_control_panel import ( # pylint: disable=hass-component-root-import
ManualAlarm,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ARMING_TIME, CONF_DELAY_TIME, CONF_TRIGGER_TIME
from homeassistant.core import HomeAssistant

View File

@@ -139,6 +139,7 @@ class DemoCover(CoverEntity):
self.async_write_ha_state()
return
self._is_opening = False
self._is_closing = True
self._listen_cover()
self._requested_closing = True
@@ -162,6 +163,7 @@ class DemoCover(CoverEntity):
return
self._is_opening = True
self._is_closing = False
self._listen_cover()
self._requested_closing = False
self.async_write_ha_state()
@@ -181,10 +183,14 @@ class DemoCover(CoverEntity):
if self._position == position:
return
self._is_closing = position < (self._position or 0)
self._is_opening = not self._is_closing
self._listen_cover()
self._requested_closing = (
self._position is not None and position < self._position
)
self.async_write_ha_state()
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the cover til to a specific position."""

View File

@@ -6,5 +6,5 @@
"iot_class": "local_polling",
"loggers": ["pydoods"],
"quality_scale": "legacy",
"requirements": ["pydoods==1.0.2", "Pillow==11.3.0"]
"requirements": ["pydoods==1.0.2", "Pillow==12.0.0"]
}

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, NotRequired, TypedDict
from typing import Literal, 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 an energy meter (kWh)
# statistic_id of a an energy meter (kWh)
stat_energy_from: str
# statistic_id of costs ($) incurred from the energy meter
@@ -58,14 +58,6 @@ 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_power: str
class GridSourceType(TypedDict):
"""Dictionary holding the source of grid energy consumption."""
@@ -73,7 +65,6 @@ class GridSourceType(TypedDict):
flow_from: list[FlowFromGridSourceType]
flow_to: list[FlowToGridSourceType]
power: NotRequired[list[GridPowerSourceType]]
cost_adjustment_day: float
@@ -84,7 +75,6 @@ class SolarSourceType(TypedDict):
type: Literal["solar"]
stat_energy_from: str
stat_power: NotRequired[str]
config_entry_solar_forecast: list[str] | None
@@ -95,8 +85,6 @@ class BatterySourceType(TypedDict):
stat_energy_from: str
stat_energy_to: str
# positive when discharging, negative when charging
stat_power: NotRequired[str]
class GasSourceType(TypedDict):
@@ -148,15 +136,12 @@ class DeviceConsumption(TypedDict):
# This is an ever increasing value
stat_consumption: str
# optional power meter
stat_power: 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: NotRequired[str]
included_in_stat: str | None
class EnergyPreferences(TypedDict):
@@ -209,12 +194,6 @@ FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
}
)
GRID_POWER_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("stat_power"): str,
}
)
def _generate_unique_value_validator(key: str) -> Callable[[list[dict]], list[dict]]:
"""Generate a validator that ensures a value is only used once."""
@@ -245,10 +224,6 @@ 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_power"),
),
vol.Required("cost_adjustment_day"): vol.Coerce(float),
}
)
@@ -256,7 +231,6 @@ SOLAR_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "solar",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_power"): str,
vol.Optional("config_entry_solar_forecast"): vol.Any([str], None),
}
)
@@ -265,7 +239,6 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Required("type"): "battery",
vol.Required("stat_energy_from"): str,
vol.Required("stat_energy_to"): str,
vol.Optional("stat_power"): str,
}
)
GAS_SOURCE_SCHEMA = vol.Schema(
@@ -321,7 +294,6 @@ ENERGY_SOURCE_SCHEMA = vol.All(
DEVICE_CONSUMPTION_SCHEMA = vol.Schema(
{
vol.Required("stat_consumption"): str,
vol.Optional("stat_power"): str,
vol.Optional("name"): str,
vol.Optional("included_in_stat"): str,
}

View File

@@ -12,7 +12,6 @@ from homeassistant.const import (
STATE_UNAVAILABLE,
STATE_UNKNOWN,
UnitOfEnergy,
UnitOfPower,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant, callback, valid_entity_id
@@ -24,17 +23,12 @@ 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,
@@ -88,10 +82,6 @@ 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]),
@@ -169,7 +159,7 @@ class EnergyPreferencesValidation:
@callback
def _async_validate_stat_common(
def _async_validate_usage_stat(
hass: HomeAssistant,
metadata: dict[str, tuple[int, recorder.models.StatisticMetaData]],
stat_id: str,
@@ -177,41 +167,37 @@ def _async_validate_stat_common(
allowed_units: Mapping[str, Sequence[str]],
unit_error: str,
issues: ValidationIssues,
check_negative: bool = False,
) -> str | None:
"""Validate common aspects of a statistic.
Returns the entity_id if validation succeeds, None otherwise.
"""
) -> None:
"""Validate a statistic."""
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 None
return
entity_id = stat_id
if not recorder.is_entity_recorded(hass, entity_id):
issues.add_issue(hass, "recorder_untracked", entity_id)
return None
return
if (state := hass.states.get(entity_id)) is None:
issues.add_issue(hass, "entity_not_defined", entity_id)
return None
return
if state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
issues.add_issue(hass, "entity_unavailable", entity_id, state.state)
return None
return
try:
current_value: float | None = float(state.state)
except ValueError:
issues.add_issue(hass, "entity_state_non_numeric", entity_id, state.state)
return None
return
if check_negative and current_value is not None and current_value < 0:
if 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)
@@ -225,36 +211,6 @@ def _async_validate_stat_common(
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 = [
@@ -299,39 +255,6 @@ 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,
@@ -511,21 +434,6 @@ async def async_validate(hass: HomeAssistant) -> EnergyPreferencesValidation:
)
)
for power_stat in source.get("power", []):
wanted_statistics_metadata.add(power_stat["stat_power"])
validate_calls.append(
functools.partial(
_async_validate_power_stat,
hass,
statistics_metadata,
power_stat["stat_power"],
POWER_USAGE_DEVICE_CLASSES,
POWER_USAGE_UNITS,
POWER_UNIT_ERROR,
source_result,
)
)
elif source["type"] == "gas":
wanted_statistics_metadata.add(source["stat_energy_from"])
validate_calls.append(

View File

@@ -19,7 +19,9 @@ from homeassistant.components.ffmpeg import (
FFmpegManager,
get_ffmpeg_manager,
)
from homeassistant.components.ffmpeg_motion.binary_sensor import FFmpegBinarySensor
from homeassistant.components.ffmpeg_motion.binary_sensor import ( # pylint: disable=hass-component-root-import
FFmpegBinarySensor,
)
from homeassistant.const import CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv

View File

@@ -6,9 +6,8 @@ import logging
from typing import Any
from homeassistant.components.camera import CameraEntityFeature
from homeassistant.components.ffmpeg.camera import (
CONF_EXTRA_ARGUMENTS,
CONF_INPUT,
from homeassistant.components.ffmpeg import CONF_EXTRA_ARGUMENTS, CONF_INPUT
from homeassistant.components.ffmpeg.camera import ( # pylint: disable=hass-component-root-import
DEFAULT_ARGUMENTS,
FFmpegCamera,
)

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/generic",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["av==13.1.0", "Pillow==11.3.0"]
"requirements": ["av==13.1.0", "Pillow==12.0.0"]
}

View File

@@ -30,8 +30,8 @@ from homeassistant.components.camera import (
WebRTCMessage,
WebRTCSendMessage,
async_register_webrtc_provider,
get_dynamic_camera_stream_settings,
)
from homeassistant.components.camera.prefs import get_dynamic_camera_stream_settings
from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN
from homeassistant.components.stream import Orientation
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry

View File

@@ -1,7 +1,7 @@
{
"domain": "growatt_server",
"name": "Growatt",
"codeowners": [],
"codeowners": ["@johanzander"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/growatt_server",
"iot_class": "cloud_polling",

View File

@@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["habiticalib"],
"quality_scale": "platinum",
"requirements": ["habiticalib==0.4.5"]
"requirements": ["habiticalib==0.4.6"]
}

View File

@@ -27,7 +27,7 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
)
from homeassistant.components.camera import DOMAIN as CAMERA_DOMAIN
from homeassistant.components.device_automation.trigger import (
from homeassistant.components.device_automation.trigger import ( # pylint: disable=hass-component-root-import
async_validate_trigger_config,
)
from homeassistant.components.event import DOMAIN as EVENT_DOMAIN, EventDeviceClass

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/image_upload",
"integration_type": "system",
"quality_scale": "internal",
"requirements": ["Pillow==11.3.0"]
"requirements": ["Pillow==12.0.0"]
}

View File

@@ -2,7 +2,12 @@
from typing import Any, Final
from iometer import IOmeterClient, IOmeterConnectionError
from iometer import (
IOmeterClient,
IOmeterConnectionError,
IOmeterNoReadingsError,
IOmeterNoStatusError,
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
@@ -34,6 +39,11 @@ class IOMeterConfigFlow(ConfigFlow, domain=DOMAIN):
client = IOmeterClient(host=host, session=session)
try:
status = await client.get_current_status()
_ = await client.get_current_reading()
except IOmeterNoStatusError:
return self.async_abort(reason="no_status")
except IOmeterNoReadingsError:
return self.async_abort(reason="no_readings")
except IOmeterConnectionError:
return self.async_abort(reason="cannot_connect")
@@ -70,6 +80,11 @@ class IOMeterConfigFlow(ConfigFlow, domain=DOMAIN):
client = IOmeterClient(host=self._host, session=session)
try:
status = await client.get_current_status()
_ = await client.get_current_reading()
except IOmeterNoStatusError:
errors["base"] = "no_status"
except IOmeterNoReadingsError:
errors["base"] = "no_readings"
except IOmeterConnectionError:
errors["base"] = "cannot_connect"
else:

View File

@@ -20,6 +20,8 @@
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]"
},
"error": {
"no_status": "No status received from the IOmeter. Check your device status in the IOmeter app",
"no_readings": "No readings received from the IOmeter. Please attach the IOmeter Core to the electricity meter and wait for the first reading.",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
}

View File

@@ -31,6 +31,7 @@ class IPPEntity(CoordinatorEntity[IPPDataUpdateCoordinator]):
manufacturer=self.coordinator.data.info.manufacturer,
model=self.coordinator.data.info.model,
name=self.coordinator.data.info.name,
serial_number=self.coordinator.data.info.serial,
sw_version=self.coordinator.data.info.version,
configuration_url=self.coordinator.data.info.more_info,
)

View File

@@ -358,7 +358,7 @@
"entity_label": "Entity name",
"entity_description": "Optional if a device is selected, otherwise required. If the entity is assigned to a device, the device name is used as prefix.",
"entity_category_title": "Entity category",
"entity_category_description": "Classification of a non-primary entity. Leave empty for standard behaviour."
"entity_category_description": "Classification of a non-primary entity. Leave empty for standard behavior."
},
"knx": {
"title": "KNX configuration",

View File

@@ -8,7 +8,7 @@ from pychromecast import Chromecast
from pychromecast.const import CAST_TYPE_CHROMECAST
from homeassistant.components.cast import DOMAIN as CAST_DOMAIN
from homeassistant.components.cast.home_assistant_cast import (
from homeassistant.components.cast.home_assistant_cast import ( # pylint: disable=hass-component-root-import
ATTR_URL_PATH,
ATTR_VIEW_PATH,
NO_URL_AVAILABLE_ERROR,

View File

@@ -6,5 +6,5 @@
"iot_class": "cloud_push",
"loggers": ["matrix_client"],
"quality_scale": "legacy",
"requirements": ["matrix-nio==0.25.2", "Pillow==11.3.0"]
"requirements": ["matrix-nio==0.25.2", "Pillow==12.0.0"]
}

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "platinum",
"requirements": ["aiomealie==1.0.0"]
"requirements": ["aiomealie==1.0.1"]
}

View File

@@ -5,9 +5,8 @@ from __future__ import annotations
import asyncio
import logging
import datapoint
import datapoint.Forecast
import datapoint.Manager
from datapoint.Forecast import Forecast
from datapoint.Manager import Manager
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
@@ -48,19 +47,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
coordinates = f"{latitude}_{longitude}"
connection = datapoint.Manager.Manager(api_key=api_key)
connection = Manager(api_key=api_key)
async def async_update_hourly() -> datapoint.Forecast:
async def async_update_hourly() -> Forecast:
return await hass.async_add_executor_job(
fetch_data, connection, latitude, longitude, "hourly"
)
async def async_update_daily() -> datapoint.Forecast:
async def async_update_daily() -> Forecast:
return await hass.async_add_executor_job(
fetch_data, connection, latitude, longitude, "daily"
)
async def async_update_twice_daily() -> datapoint.Forecast:
async def async_update_twice_daily() -> Forecast:
return await hass.async_add_executor_job(
fetch_data, connection, latitude, longitude, "twice-daily"
)

View File

@@ -6,9 +6,8 @@ from collections.abc import Mapping
import logging
from typing import Any
import datapoint
from datapoint.exceptions import APIException
import datapoint.Manager
from datapoint.Manager import Manager
from requests import HTTPError
import voluptuous as vol
@@ -31,7 +30,7 @@ async def validate_input(
Data has the keys from DATA_SCHEMA with values provided by the user.
"""
errors = {}
connection = datapoint.Manager.Manager(api_key=api_key)
connection = Manager(api_key=api_key)
try:
forecast = await hass.async_add_executor_job(

View File

@@ -5,8 +5,9 @@ from __future__ import annotations
import logging
from typing import Any, Literal
import datapoint
from datapoint.exceptions import APIException
from datapoint.Forecast import Forecast
from datapoint.Manager import Manager
from requests import HTTPError
from homeassistant.exceptions import ConfigEntryAuthFailed
@@ -16,7 +17,7 @@ _LOGGER = logging.getLogger(__name__)
def fetch_data(
connection: datapoint.Manager,
connection: Manager,
latitude: float,
longitude: float,
frequency: Literal["daily", "twice-daily", "hourly"],
@@ -26,7 +27,7 @@ def fetch_data(
return connection.get_forecast(
latitude, longitude, frequency, convert_weather_code=False
)
except (ValueError, datapoint.exceptions.APIException) as err:
except (ValueError, APIException) as err:
_LOGGER.error("Check Met Office connection: %s", err.args)
raise UpdateFailed from err
except HTTPError as err:

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from datetime import datetime
from typing import Any, cast
from datapoint.Forecast import Forecast as ForecastData
from datapoint.Forecast import Forecast
from homeassistant.components.weather import (
ATTR_FORECAST_CONDITION,
@@ -22,7 +22,7 @@ from homeassistant.components.weather import (
ATTR_FORECAST_WIND_BEARING,
DOMAIN as WEATHER_DOMAIN,
CoordinatorWeatherEntity,
Forecast,
Forecast as WeatherForecast,
WeatherEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
@@ -85,20 +85,20 @@ async def async_setup_entry(
)
def _build_hourly_forecast_data(timestep: dict[str, Any]) -> Forecast:
data = Forecast(datetime=timestep["time"].isoformat())
def _build_hourly_forecast_data(timestep: dict[str, Any]) -> WeatherForecast:
data = WeatherForecast(datetime=timestep["time"].isoformat())
_populate_forecast_data(data, timestep, HOURLY_FORECAST_ATTRIBUTE_MAP)
return data
def _build_daily_forecast_data(timestep: dict[str, Any]) -> Forecast:
data = Forecast(datetime=timestep["time"].isoformat())
def _build_daily_forecast_data(timestep: dict[str, Any]) -> WeatherForecast:
data = WeatherForecast(datetime=timestep["time"].isoformat())
_populate_forecast_data(data, timestep, DAILY_FORECAST_ATTRIBUTE_MAP)
return data
def _build_twice_daily_forecast_data(timestep: dict[str, Any]) -> Forecast:
data = Forecast(datetime=timestep["time"].isoformat())
def _build_twice_daily_forecast_data(timestep: dict[str, Any]) -> WeatherForecast:
data = WeatherForecast(datetime=timestep["time"].isoformat())
# day and night forecasts have slightly different format
if "daySignificantWeatherCode" in timestep:
@@ -111,7 +111,7 @@ def _build_twice_daily_forecast_data(timestep: dict[str, Any]) -> Forecast:
def _populate_forecast_data(
forecast: Forecast, timestep: dict[str, Any], mapping: dict[str, str]
forecast: WeatherForecast, timestep: dict[str, Any], mapping: dict[str, str]
) -> None:
def get_mapped_attribute(attr: str) -> Any:
if attr not in mapping:
@@ -153,9 +153,9 @@ def _populate_forecast_data(
class MetOfficeWeather(
CoordinatorWeatherEntity[
TimestampDataUpdateCoordinator[ForecastData],
TimestampDataUpdateCoordinator[ForecastData],
TimestampDataUpdateCoordinator[ForecastData],
TimestampDataUpdateCoordinator[Forecast],
TimestampDataUpdateCoordinator[Forecast],
TimestampDataUpdateCoordinator[Forecast],
]
):
"""Implementation of a Met Office weather condition."""
@@ -177,9 +177,9 @@ class MetOfficeWeather(
def __init__(
self,
coordinator_daily: TimestampDataUpdateCoordinator[ForecastData],
coordinator_hourly: TimestampDataUpdateCoordinator[ForecastData],
coordinator_twice_daily: TimestampDataUpdateCoordinator[ForecastData],
coordinator_daily: TimestampDataUpdateCoordinator[Forecast],
coordinator_hourly: TimestampDataUpdateCoordinator[Forecast],
coordinator_twice_daily: TimestampDataUpdateCoordinator[Forecast],
hass_data: dict[str, Any],
) -> None:
"""Initialise the platform with a data instance."""
@@ -263,10 +263,10 @@ class MetOfficeWeather(
return float(value) if value is not None else None
@callback
def _async_forecast_daily(self) -> list[Forecast] | None:
def _async_forecast_daily(self) -> list[WeatherForecast] | None:
"""Return the daily forecast in native units."""
coordinator = cast(
TimestampDataUpdateCoordinator[ForecastData],
TimestampDataUpdateCoordinator[Forecast],
self.forecast_coordinators["daily"],
)
timesteps = coordinator.data.timesteps
@@ -277,10 +277,10 @@ class MetOfficeWeather(
]
@callback
def _async_forecast_hourly(self) -> list[Forecast] | None:
def _async_forecast_hourly(self) -> list[WeatherForecast] | None:
"""Return the hourly forecast in native units."""
coordinator = cast(
TimestampDataUpdateCoordinator[ForecastData],
TimestampDataUpdateCoordinator[Forecast],
self.forecast_coordinators["hourly"],
)
@@ -292,10 +292,10 @@ class MetOfficeWeather(
]
@callback
def _async_forecast_twice_daily(self) -> list[Forecast] | None:
def _async_forecast_twice_daily(self) -> list[WeatherForecast] | None:
"""Return the twice daily forecast in native units."""
coordinator = cast(
TimestampDataUpdateCoordinator[ForecastData],
TimestampDataUpdateCoordinator[Forecast],
self.forecast_coordinators["twice_daily"],
)
timesteps = coordinator.data.timesteps

View File

@@ -8,6 +8,7 @@ from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from .onewirehub import OneWireConfigEntry
@@ -26,7 +27,28 @@ async def async_get_config_entry_diagnostics(
"data": async_redact_data(entry.data, TO_REDACT),
"options": {**entry.options},
},
"devices": [asdict(device_details) for device_details in onewire_hub.devices]
if onewire_hub.devices
else [],
"devices": [asdict(device_details) for device_details in onewire_hub.devices],
}
async def async_get_device_diagnostics(
hass: HomeAssistant, entry: OneWireConfigEntry, device_entry: dr.DeviceEntry
) -> dict[str, Any]:
"""Return diagnostics for a device."""
onewire_hub = entry.runtime_data
return {
"entry": {
"title": entry.title,
"data": async_redact_data(entry.data, TO_REDACT),
"options": {**entry.options},
},
"device": asdict(
next(
device_details
for device_details in onewire_hub.devices
if device_details.id[3:] == device_entry.serial_number
)
),
}

View File

@@ -7,6 +7,7 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aio_ownet"],
"quality_scale": "silver",
"requirements": ["aio-ownet==0.0.4"],
"zeroconf": ["_owserver._tcp.local."]
}

View File

@@ -1,8 +1,6 @@
rules:
## Bronze
config-flow:
status: todo
comment: missing data_description on options flow
config-flow: done
test-before-configure: done
unique-config-entry:
status: done
@@ -16,27 +14,19 @@ rules:
entity-event-setup:
status: exempt
comment: entities do not subscribe to events
dependency-transparency:
status: todo
comment: The package is not built and published inside a CI pipeline
dependency-transparency: done
action-setup:
status: exempt
comment: No service actions currently available
common-modules:
status: done
comment: base entity available, but no coordinator
docs-high-level-description:
status: todo
comment: Under review
docs-installation-instructions:
status: todo
comment: Under review
docs-removal-instructions:
status: todo
comment: Under review
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-actions:
status: todo
comment: Under review
status: exempt
comment: No service actions currently available
brands: done
## Silver
@@ -52,12 +42,8 @@ rules:
parallel-updates: done
test-coverage: done
integration-owner: done
docs-installation-parameters:
status: todo
comment: Under review
docs-configuration-parameters:
status: todo
comment: Under review
docs-installation-parameters: done
docs-configuration-parameters: done
## Gold
entity-translations: done
@@ -73,9 +59,7 @@ rules:
comment: >
Manual removal, as it is not possible to distinguish
between a flaky device and a device that has been removed
diagnostics:
status: todo
comment: config-entry diagnostics level available, might be nice to have device-level diagnostics
diagnostics: done
exception-translations:
status: todo
comment: Under review

View File

@@ -139,8 +139,12 @@
"step": {
"device_selection": {
"data": {
"clear_device_options": "Clear all device configurations",
"device_selection": "[%key:component::onewire::options::error::device_not_selected%]"
"clear_device_options": "Reset all device customizations",
"device_selection": "Customize specific devices"
},
"data_description": {
"clear_device_options": "Use this to reset all device specific options to default values.",
"device_selection": "Customize behavior of individual devices."
},
"description": "Select what configuration steps to process",
"title": "1-Wire device options"
@@ -149,6 +153,9 @@
"data": {
"precision": "Sensor precision"
},
"data_description": {
"precision": "The lower the precision, the faster the sensor will respond, but with less accuracy."
},
"description": "Select sensor precision for {sensor_id}",
"title": "1-Wire sensor precision"
}

View File

@@ -25,6 +25,13 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
STEP_RECONFIGURE_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): str,
vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port,
}
)
async def validate_input(hass: HomeAssistant, host: str, port: int) -> None:
"""Validate the user input allows us to connect."""
@@ -39,6 +46,48 @@ async def validate_input(hass: HomeAssistant, host: str, port: int) -> None:
class OpenRGBConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OpenRGB."""
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the OpenRGB SDK Server."""
reconfigure_entry = self._get_reconfigure_entry()
errors: dict[str, str] = {}
if user_input is not None:
host = user_input[CONF_HOST]
port = user_input[CONF_PORT]
# Prevent duplicate entries
self._async_abort_entries_match({CONF_HOST: host, CONF_PORT: port})
try:
await validate_input(self.hass, host, port)
except CONNECTION_ERRORS:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception(
"Unknown error while connecting to OpenRGB SDK server at %s",
f"{host}:{port}",
)
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reconfigure_entry,
data_updates={CONF_HOST: host, CONF_PORT: port},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_RECONFIGURE_DATA_SCHEMA,
suggested_values={
CONF_HOST: reconfigure_entry.data[CONF_HOST],
CONF_PORT: reconfigure_entry.data[CONF_PORT],
},
),
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:

View File

@@ -68,7 +68,7 @@ rules:
entity-translations: todo
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo

View File

@@ -2,6 +2,7 @@
"config": {
"step": {
"user": {
"title": "Set up OpenRGB SDK server",
"description": "Set up your OpenRGB SDK server to allow control from within Home Assistant.",
"data": {
"name": "[%key:common::config_flow::data::name%]",
@@ -13,6 +14,18 @@
"host": "The IP address or hostname of the computer running the OpenRGB SDK server.",
"port": "The port number that the OpenRGB SDK server is running on."
}
},
"reconfigure": {
"title": "Reconfigure OpenRGB SDK server",
"description": "Update the connection settings for your OpenRGB SDK server.",
"data": {
"host": "[%key:common::config_flow::data::host%]",
"port": "[%key:common::config_flow::data::port%]"
},
"data_description": {
"host": "[%key:component::openrgb::config::step::user::data_description::host%]",
"port": "[%key:component::openrgb::config::step::user::data_description::port%]"
}
}
},
"error": {
@@ -21,6 +34,7 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
}
},

View File

@@ -8,6 +8,6 @@
"iot_class": "local_polling",
"loggers": ["plugwise"],
"quality_scale": "platinum",
"requirements": ["plugwise==1.8.0"],
"requirements": ["plugwise==1.8.1"],
"zeroconf": ["_plugwise._tcp.local."]
}

View File

@@ -4,5 +4,5 @@
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/proxy",
"quality_scale": "legacy",
"requirements": ["Pillow==11.3.0"]
"requirements": ["Pillow==12.0.0"]
}

View File

@@ -6,5 +6,5 @@
"iot_class": "calculated",
"loggers": ["pyzbar"],
"quality_scale": "legacy",
"requirements": ["Pillow==11.3.0", "pyzbar==0.1.7"]
"requirements": ["Pillow==12.0.0", "pyzbar==0.1.7"]
}

View File

@@ -72,7 +72,7 @@ class RAPTPillConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
current_addresses = self._async_current_ids()
current_addresses = self._async_current_ids(include_ignore=False)
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/seven_segments",
"iot_class": "local_polling",
"quality_scale": "legacy",
"requirements": ["Pillow==11.3.0"]
"requirements": ["Pillow==12.0.0"]
}

View File

@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["simplehound"],
"quality_scale": "legacy",
"requirements": ["Pillow==11.3.0", "simplehound==0.3"]
"requirements": ["Pillow==12.0.0", "simplehound==0.3"]
}

View File

@@ -39,7 +39,9 @@ from homeassistant.components.media_player import (
async_process_play_media_url,
)
from homeassistant.components.plex import PLEX_URI_SCHEME
from homeassistant.components.plex.services import process_plex_payload
from homeassistant.components.plex.services import ( # pylint: disable=hass-component-root-import
process_plex_payload,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import entity_registry as er

View File

@@ -441,9 +441,7 @@ class KeyFrameConverter:
# Keep import here so that we can import stream integration
# without installing reqs
from homeassistant.components.camera.img_util import ( # noqa: PLC0415
TurboJPEGSingleton,
)
from homeassistant.components.camera import TurboJPEGSingleton # noqa: PLC0415
self._packet: Packet | None = None
self._event: asyncio.Event = asyncio.Event()

View File

@@ -7,7 +7,7 @@ import io
import logging
from ssl import SSLContext
from types import MappingProxyType
from typing import Any
from typing import Any, cast
import httpx
from telegram import (
@@ -23,6 +23,7 @@ from telegram import (
InputMediaVideo,
InputPollOption,
Message,
PhotoSize,
ReplyKeyboardMarkup,
ReplyKeyboardRemove,
Update,
@@ -56,6 +57,10 @@ from .const import (
ATTR_DISABLE_NOTIF,
ATTR_DISABLE_WEB_PREV,
ATTR_FILE,
ATTR_FILE_ID,
ATTR_FILE_MIME_TYPE,
ATTR_FILE_NAME,
ATTR_FILE_SIZE,
ATTR_FROM_FIRST,
ATTR_FROM_LAST,
ATTR_INLINE_MESSAGE_ID,
@@ -86,6 +91,7 @@ from .const import (
CONF_CHAT_ID,
CONF_PROXY_URL,
DOMAIN,
EVENT_TELEGRAM_ATTACHMENT,
EVENT_TELEGRAM_CALLBACK,
EVENT_TELEGRAM_COMMAND,
EVENT_TELEGRAM_SENT,
@@ -183,6 +189,10 @@ class BaseTelegramBot:
# This is a command message - set event type to command and split data into command and args
event_type = EVENT_TELEGRAM_COMMAND
event_data.update(self._get_command_event_data(message.text))
elif filters.ATTACHMENT.filter(message):
event_type = EVENT_TELEGRAM_ATTACHMENT
event_data[ATTR_TEXT] = message.caption
event_data.update(self._get_file_id_event_data(message))
else:
event_type = EVENT_TELEGRAM_TEXT
event_data[ATTR_TEXT] = message.text
@@ -192,6 +202,26 @@ class BaseTelegramBot:
return event_type, event_data
def _get_file_id_event_data(self, message: Message) -> dict[str, Any]:
"""Extract file_id from a message attachment, if any."""
if filters.PHOTO.filter(message):
photos = cast(Sequence[PhotoSize], message.effective_attachment)
return {
ATTR_FILE_ID: photos[-1].file_id,
ATTR_FILE_MIME_TYPE: "image/jpeg", # telegram always uses jpeg for photos
ATTR_FILE_SIZE: photos[-1].file_size,
}
return {
k: getattr(message.effective_attachment, v)
for k, v in (
(ATTR_FILE_ID, "file_id"),
(ATTR_FILE_NAME, "file_name"),
(ATTR_FILE_MIME_TYPE, "mime_type"),
(ATTR_FILE_SIZE, "file_size"),
)
if hasattr(message.effective_attachment, v)
}
def _get_user_event_data(self, user: User) -> dict[str, Any]:
return {
ATTR_USER_ID: user.id,
@@ -548,6 +578,7 @@ class TelegramNotificationService:
"Error sending message",
params[ATTR_MESSAGE_TAG],
text,
target=target,
parse_mode=params[ATTR_PARSER],
disable_web_page_preview=params[ATTR_DISABLE_WEB_PREV],
disable_notification=params[ATTR_DISABLE_NOTIF],

View File

@@ -54,6 +54,7 @@ SERVICE_LEAVE_CHAT = "leave_chat"
EVENT_TELEGRAM_CALLBACK = "telegram_callback"
EVENT_TELEGRAM_COMMAND = "telegram_command"
EVENT_TELEGRAM_TEXT = "telegram_text"
EVENT_TELEGRAM_ATTACHMENT = "telegram_attachment"
EVENT_TELEGRAM_SENT = "telegram_sent"
PARSER_HTML = "html"
@@ -90,6 +91,10 @@ ATTR_DISABLE_NOTIF = "disable_notification"
ATTR_DISABLE_WEB_PREV = "disable_web_page_preview"
ATTR_EDITED_MSG = "edited_message"
ATTR_FILE = "file"
ATTR_FILE_ID = "file_id"
ATTR_FILE_MIME_TYPE = "file_mime_type"
ATTR_FILE_NAME = "file_name"
ATTR_FILE_SIZE = "file_size"
ATTR_FROM_FIRST = "from_first"
ATTR_FROM_LAST = "from_last"
ATTR_KEYBOARD = "keyboard"

View File

@@ -11,6 +11,6 @@
"tf-models-official==2.5.0",
"pycocotools==2.0.6",
"numpy==2.3.2",
"Pillow==11.3.0"
"Pillow==12.0.0"
]
}

View File

@@ -254,10 +254,11 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
def _is_position_reversed(self) -> bool:
"""Check if the cover position and direction should be reversed."""
# The default is True
# Having motor_reverse_mode == "back" cancels the inversion
# Having motor_reverse_mode == "forward" cancels the inversion
return not (
self._motor_reverse_mode_enum
and self.device.status.get(self._motor_reverse_mode_enum.dpcode) == "back"
and self.device.status.get(self._motor_reverse_mode_enum.dpcode)
== "forward"
)
@property

View File

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

View File

@@ -348,6 +348,12 @@
"odometer": {
"default": "mdi:counter"
},
"service_warning": {
"default": "mdi:wrench-clock",
"state": {
"no_warning": "mdi:car-wrench"
}
},
"target_battery_charge_level": {
"default": "mdi:battery-medium"
},

View File

@@ -332,6 +332,25 @@ _DESCRIPTIONS: tuple[VolvoSensorDescription, ...] = (
state_class=SensorStateClass.TOTAL_INCREASING,
suggested_display_precision=1,
),
# diagnostics endpoint
VolvoSensorDescription(
key="service_warning",
api_field="serviceWarning",
device_class=SensorDeviceClass.ENUM,
options=[
"distance_driven_almost_time_for_service",
"distance_driven_overdue_for_service",
"distance_driven_time_for_service",
"engine_hours_almost_time_for_service",
"engine_hours_overdue_for_service",
"engine_hours_time_for_service",
"no_warning",
"regular_maintenance_almost_time_for_service",
"regular_maintenance_overdue_for_service",
"regular_maintenance_time_for_service",
"unknown_warning",
],
),
# energy state endpoint
VolvoSensorDescription(
key="target_battery_charge_level",

View File

@@ -309,6 +309,22 @@
"odometer": {
"name": "Odometer"
},
"service_warning": {
"name": "Service",
"state": {
"distance_driven_almost_time_for_service": "Almost time for distance service",
"distance_driven_overdue_for_service": "Distance service overdue",
"distance_driven_time_for_service": "Time for distance service",
"engine_hours_almost_time_for_service": "Almost time for engine service",
"engine_hours_overdue_for_service": "Engine service overdue",
"engine_hours_time_for_service": "Time for engine service",
"no_warning": "No warning",
"regular_maintenance_almost_time_for_service": "Almost time for service",
"regular_maintenance_overdue_for_service": "Service overdue",
"regular_maintenance_time_for_service": "Time for service",
"unknown_warning": "Unknown warning"
}
},
"target_battery_charge_level": {
"name": "Target battery charge level"
},

View File

@@ -2,32 +2,169 @@
from __future__ import annotations
from types import MappingProxyType
from typing import TYPE_CHECKING
from aiowaqi import WAQIClient
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
entity_registry as er,
)
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import CONF_STATION_NUMBER, DOMAIN, SUBENTRY_TYPE_STATION
from .coordinator import WAQIConfigEntry, WAQIDataUpdateCoordinator
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS: list[Platform] = [Platform.SENSOR]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up WAQI."""
await async_migrate_integration(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> bool:
"""Set up World Air Quality Index (WAQI) from a config entry."""
client = WAQIClient(session=async_get_clientsession(hass))
client.authenticate(entry.data[CONF_API_KEY])
waqi_coordinator = WAQIDataUpdateCoordinator(hass, entry, client)
await waqi_coordinator.async_config_entry_first_refresh()
entry.runtime_data = waqi_coordinator
entry.runtime_data = {}
for subentry in entry.subentries.values():
if subentry.subentry_type != SUBENTRY_TYPE_STATION:
continue
# Create a coordinator for each station subentry
coordinator = WAQIDataUpdateCoordinator(hass, entry, subentry, client)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data[subentry.subentry_id] = coordinator
entry.async_on_unload(entry.add_update_listener(async_update_entry))
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_update_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> None:
"""Update entry."""
await hass.config_entries.async_reload(entry.entry_id)
async def async_unload_entry(hass: HomeAssistant, entry: WAQIConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_migrate_integration(hass: HomeAssistant) -> None:
"""Migrate integration entry structure to subentries."""
# Make sure we get enabled config entries first
entries = sorted(
hass.config_entries.async_entries(DOMAIN),
key=lambda e: e.disabled_by is not None,
)
if not any(entry.version == 1 for entry in entries):
return
api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {}
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
for entry in entries:
subentry = ConfigSubentry(
data=MappingProxyType(
{CONF_STATION_NUMBER: entry.data[CONF_STATION_NUMBER]}
),
subentry_type="station",
title=entry.title,
unique_id=entry.unique_id,
)
if entry.data[CONF_API_KEY] not in api_keys_entries:
all_disabled = all(
e.disabled_by is not None
for e in entries
if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY]
)
api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled)
parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]]
hass.config_entries.async_add_subentry(parent_entry, subentry)
entities = er.async_entries_for_config_entry(entity_registry, entry.entry_id)
if TYPE_CHECKING:
assert entry.unique_id is not None
device = device_registry.async_get_device(
identifiers={(DOMAIN, entry.unique_id)}
)
for entity_entry in entities:
entity_disabled_by = entity_entry.disabled_by
if (
entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY
and not all_disabled
):
# Device and entity registries don't update the disabled_by flag
# when moving a device or entity from one config entry to another,
# so we need to do it manually.
entity_disabled_by = (
er.RegistryEntryDisabler.DEVICE
if device
else er.RegistryEntryDisabler.USER
)
entity_registry.async_update_entity(
entity_entry.entity_id,
config_entry_id=parent_entry.entry_id,
config_subentry_id=subentry.subentry_id,
disabled_by=entity_disabled_by,
)
if device is not None:
# Device and entity registries don't update the disabled_by flag when
# moving a device or entity from one config entry to another, so we
# need to do it manually.
device_disabled_by = device.disabled_by
if (
device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY
and not all_disabled
):
device_disabled_by = dr.DeviceEntryDisabler.USER
device_registry.async_update_device(
device.id,
disabled_by=device_disabled_by,
add_config_subentry_id=subentry.subentry_id,
add_config_entry_id=parent_entry.entry_id,
)
if parent_entry.entry_id != entry.entry_id:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
)
else:
device_registry.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
remove_config_subentry_id=None,
)
if parent_entry.entry_id != entry.entry_id:
await hass.config_entries.async_remove(entry.entry_id)
else:
hass.config_entries.async_update_entry(
entry,
title="WAQI",
version=2,
data={CONF_API_KEY: entry.data[CONF_API_KEY]},
unique_id=None,
)

View File

@@ -13,22 +13,24 @@ from aiowaqi import (
)
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
ConfigEntry,
ConfigFlow,
ConfigFlowResult,
ConfigSubentryFlow,
SubentryFlowResult,
)
from homeassistant.const import (
CONF_API_KEY,
CONF_LATITUDE,
CONF_LOCATION,
CONF_LONGITUDE,
CONF_METHOD,
)
from homeassistant.core import callback
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.selector import (
LocationSelector,
SelectSelector,
SelectSelectorConfig,
)
from homeassistant.helpers.selector import LocationSelector
from .const import CONF_STATION_NUMBER, DOMAIN
from .const import CONF_STATION_NUMBER, DOMAIN, SUBENTRY_TYPE_STATION
_LOGGER = logging.getLogger(__name__)
@@ -54,11 +56,15 @@ async def get_by_station_number(
class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for World Air Quality Index (WAQI)."""
VERSION = 1
VERSION = 2
def __init__(self) -> None:
"""Initialize config flow."""
self.data: dict[str, Any] = {}
@classmethod
@callback
def async_get_supported_subentry_types(
cls, config_entry: ConfigEntry
) -> dict[str, type[ConfigSubentryFlow]]:
"""Return subentries supported by this handler."""
return {SUBENTRY_TYPE_STATION: StationFlowHandler}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -66,6 +72,7 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_API_KEY: user_input[CONF_API_KEY]})
client = WAQIClient(session=async_get_clientsession(self.hass))
client.authenticate(user_input[CONF_API_KEY])
try:
@@ -78,35 +85,40 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self.data = user_input
if user_input[CONF_METHOD] == CONF_MAP:
return await self.async_step_map()
return await self.async_step_station_number()
return self.async_create_entry(
title="World Air Quality Index",
data={
CONF_API_KEY: user_input[CONF_API_KEY],
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_API_KEY): str,
vol.Required(CONF_METHOD): SelectSelector(
SelectSelectorConfig(
options=[CONF_MAP, CONF_STATION_NUMBER],
translation_key="method",
)
),
}
),
data_schema=vol.Schema({vol.Required(CONF_API_KEY): str}),
errors=errors,
)
class StationFlowHandler(ConfigSubentryFlow):
"""Handle subentry flow."""
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""User flow to create a sensor subentry."""
return self.async_show_menu(
step_id="user",
menu_options=["map", "station_number"],
)
async def async_step_map(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
) -> SubentryFlowResult:
"""Add measuring station via map."""
errors: dict[str, str] = {}
if user_input is not None:
client = WAQIClient(session=async_get_clientsession(self.hass))
client.authenticate(self.data[CONF_API_KEY])
client.authenticate(self._get_entry().data[CONF_API_KEY])
try:
measuring_station = await client.get_by_coordinates(
user_input[CONF_LOCATION][CONF_LATITUDE],
@@ -124,9 +136,7 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Required(
CONF_LOCATION,
): LocationSelector(),
vol.Required(CONF_LOCATION): LocationSelector(),
}
),
{
@@ -141,12 +151,12 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
async def async_step_station_number(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
) -> SubentryFlowResult:
"""Add measuring station via station number."""
errors: dict[str, str] = {}
if user_input is not None:
client = WAQIClient(session=async_get_clientsession(self.hass))
client.authenticate(self.data[CONF_API_KEY])
client.authenticate(self._get_entry().data[CONF_API_KEY])
station_number = user_input[CONF_STATION_NUMBER]
measuring_station, errors = await get_by_station_number(
client, abs(station_number)
@@ -160,25 +170,22 @@ class WAQIConfigFlow(ConfigFlow, domain=DOMAIN):
return await self._async_create_entry(measuring_station)
return self.async_show_form(
step_id=CONF_STATION_NUMBER,
data_schema=vol.Schema(
{
vol.Required(
CONF_STATION_NUMBER,
): int,
}
),
data_schema=vol.Schema({vol.Required(CONF_STATION_NUMBER): int}),
errors=errors,
)
async def _async_create_entry(
self, measuring_station: WAQIAirQuality
) -> ConfigFlowResult:
await self.async_set_unique_id(str(measuring_station.station_id))
self._abort_if_unique_id_configured()
) -> SubentryFlowResult:
station_id = str(measuring_station.station_id)
for entry in self.hass.config_entries.async_entries(DOMAIN):
for subentry in entry.subentries.values():
if subentry.unique_id == station_id:
return self.async_abort(reason="already_configured")
return self.async_create_entry(
title=measuring_station.city.name,
data={
CONF_API_KEY: self.data[CONF_API_KEY],
CONF_STATION_NUMBER: measuring_station.station_id,
},
unique_id=station_id,
)

View File

@@ -8,4 +8,4 @@ LOGGER = logging.getLogger(__package__)
CONF_STATION_NUMBER = "station_number"
ISSUE_PLACEHOLDER = {"url": "/config/integrations/dashboard/add?domain=waqi"}
SUBENTRY_TYPE_STATION = "station"

View File

@@ -6,13 +6,13 @@ from datetime import timedelta
from aiowaqi import WAQIAirQuality, WAQIClient, WAQIError
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigSubentry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import CONF_STATION_NUMBER, DOMAIN, LOGGER
from .const import CONF_STATION_NUMBER, LOGGER
type WAQIConfigEntry = ConfigEntry[WAQIDataUpdateCoordinator]
type WAQIConfigEntry = ConfigEntry[dict[str, WAQIDataUpdateCoordinator]]
class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]):
@@ -21,22 +21,27 @@ class WAQIDataUpdateCoordinator(DataUpdateCoordinator[WAQIAirQuality]):
config_entry: WAQIConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: WAQIConfigEntry, client: WAQIClient
self,
hass: HomeAssistant,
config_entry: WAQIConfigEntry,
subentry: ConfigSubentry,
client: WAQIClient,
) -> None:
"""Initialize the WAQI data coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=config_entry,
name=DOMAIN,
name=subentry.title,
update_interval=timedelta(minutes=5),
)
self._client = client
self.subentry = subentry
async def _async_update_data(self) -> WAQIAirQuality:
try:
return await self._client.get_by_station_number(
self.config_entry.data[CONF_STATION_NUMBER]
self.subentry.data[CONF_STATION_NUMBER]
)
except WAQIError as exc:
raise UpdateFailed from exc

View File

@@ -130,12 +130,15 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the WAQI sensor."""
coordinator = entry.runtime_data
async_add_entities(
WaqiSensor(coordinator, sensor)
for sensor in SENSORS
if sensor.available_fn(coordinator.data)
)
for subentry_id, coordinator in entry.runtime_data.items():
async_add_entities(
(
WaqiSensor(coordinator, sensor)
for sensor in SENSORS
if sensor.available_fn(coordinator.data)
),
config_subentry_id=subentry_id,
)
class WaqiSensor(CoordinatorEntity[WAQIDataUpdateCoordinator], SensorEntity):

View File

@@ -3,19 +3,10 @@
"step": {
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"method": "How do you want to select a measuring station?"
}
},
"map": {
"description": "Select a location to get the closest measuring station.",
"data": {
"location": "[%key:common::config_flow::data::location%]"
}
},
"station_number": {
"data": {
"station_number": "Measuring station number"
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "API key for the World Air Quality Index"
}
}
},
@@ -25,15 +16,44 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
}
},
"selector": {
"method": {
"options": {
"map": "Select nearest from point on the map",
"station_number": "Enter a station number"
}
"config_subentries": {
"station": {
"step": {
"user": {
"title": "Add measuring station",
"description": "How do you want to select a measuring station?",
"menu_options": {
"map": "[%key:common::config_flow::data::location%]",
"station_number": "Measuring station number"
}
},
"map": {
"data": {
"location": "[%key:common::config_flow::data::location%]"
},
"data_description": {
"location": "The location to get the nearest measuring station from"
}
},
"station_number": {
"data": {
"station_number": "[%key:component::waqi::config_subentries::station::step::user::menu_options::station_number%]"
},
"data_description": {
"station_number": "The number of the measuring station"
}
}
},
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
},
"initiate_flow": {
"user": "Add measuring station"
},
"entry_type": "Measuring station"
}
},
"entity": {

View File

@@ -8,14 +8,13 @@ from xbox.webapi.api.client import XboxLiveClient
from xbox.webapi.api.provider.smartglass.models import SmartglassConsoleList
from xbox.webapi.common.signed_session import SignedSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv
from . import api
from .const import DOMAIN
from .coordinator import XboxUpdateCoordinator
from .coordinator import XboxConfigEntry, XboxUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -29,7 +28,7 @@ PLATFORMS = [
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: XboxConfigEntry) -> bool:
"""Set up xbox from a config entry."""
implementation = (
await config_entry_oauth2_flow.async_get_config_entry_implementation(
@@ -45,30 +44,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
_LOGGER.debug(
"Found %d consoles: %s",
len(consoles.result),
consoles.dict(),
consoles.model_dump(),
)
coordinator = XboxUpdateCoordinator(hass, entry, client, consoles)
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {
"client": XboxLiveClient(auth),
"consoles": consoles,
"coordinator": 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: XboxConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok:
# Unsub from coordinator updates
hass.data[DOMAIN][entry.entry_id]["sensor_unsub"]()
hass.data[DOMAIN][entry.entry_id]["binary_sensor_unsub"]()
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -5,13 +5,11 @@ from __future__ import annotations
from functools import partial
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import XboxUpdateCoordinator
from .coordinator import XboxConfigEntry, XboxUpdateCoordinator
from .entity import XboxBaseEntity
PRESENCE_ATTRIBUTES = ["online", "in_party", "in_game", "in_multiplayer"]
@@ -19,18 +17,16 @@ PRESENCE_ATTRIBUTES = ["online", "in_party", "in_game", "in_multiplayer"]
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: XboxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Xbox Live friends."""
coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
"coordinator"
]
coordinator = entry.runtime_data
update_friends = partial(async_update_friends, coordinator, {}, async_add_entities)
unsub = coordinator.async_add_listener(update_friends)
hass.data[DOMAIN][entry.entry_id]["binary_sensor_unsub"] = unsub
entry.async_on_unload(coordinator.async_add_listener(update_friends))
update_friends()

View File

@@ -28,6 +28,8 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type XboxConfigEntry = ConfigEntry[XboxUpdateCoordinator]
@dataclass
class ConsoleData:

View File

@@ -5,13 +5,11 @@ from __future__ import annotations
import re
from typing import Any
from xbox.webapi.api.client import XboxLiveClient
from xbox.webapi.api.provider.catalog.models import Image
from xbox.webapi.api.provider.smartglass.models import (
PlaybackState,
PowerState,
SmartglassConsole,
SmartglassConsoleList,
VolumeDirection,
)
@@ -21,7 +19,6 @@ from homeassistant.components.media_player import (
MediaPlayerState,
MediaType,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -29,7 +26,7 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .browse_media import build_item_response
from .const import DOMAIN
from .coordinator import ConsoleData, XboxUpdateCoordinator
from .coordinator import ConsoleData, XboxConfigEntry, XboxUpdateCoordinator
SUPPORT_XBOX = (
MediaPlayerEntityFeature.TURN_ON
@@ -57,18 +54,18 @@ XBOX_STATE_MAP: dict[PlaybackState | PowerState, MediaPlayerState | None] = {
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: XboxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Xbox media_player from a config entry."""
client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id]["client"]
consoles: SmartglassConsoleList = hass.data[DOMAIN][entry.entry_id]["consoles"]
coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
"coordinator"
]
coordinator = entry.runtime_data
async_add_entities(
[XboxMediaPlayer(client, console, coordinator) for console in consoles.result]
[
XboxMediaPlayer(console, coordinator)
for console in coordinator.consoles.result
]
)
@@ -77,14 +74,13 @@ class XboxMediaPlayer(CoordinatorEntity[XboxUpdateCoordinator], MediaPlayerEntit
def __init__(
self,
client: XboxLiveClient,
console: SmartglassConsole,
coordinator: XboxUpdateCoordinator,
) -> None:
"""Initialize the Xbox Media Player."""
super().__init__(coordinator)
self.client: XboxLiveClient = client
self._console: SmartglassConsole = console
self.client = coordinator.client
self._console = console
@property
def name(self):

View File

@@ -24,6 +24,7 @@ from homeassistant.util import dt as dt_util
from .browse_media import _find_media_image
from .const import DOMAIN
from .coordinator import XboxConfigEntry
MIME_TYPE_MAP = {
"gameclips": "video/mp4",
@@ -38,8 +39,8 @@ MEDIA_CLASS_MAP = {
async def async_get_media_source(hass: HomeAssistant):
"""Set up Xbox media source."""
entry = hass.config_entries.async_entries(DOMAIN)[0]
client = hass.data[DOMAIN][entry.entry_id]["client"]
entry: XboxConfigEntry = hass.config_entries.async_entries(DOMAIN)[0]
client = entry.runtime_data.client
return XboxSource(hass, client)

View File

@@ -7,12 +7,10 @@ from collections.abc import Iterable
import re
from typing import Any
from xbox.webapi.api.client import XboxLiveClient
from xbox.webapi.api.provider.smartglass.models import (
InputKeyType,
PowerState,
SmartglassConsole,
SmartglassConsoleList,
)
from homeassistant.components.remote import (
@@ -21,30 +19,25 @@ from homeassistant.components.remote import (
DEFAULT_DELAY_SECS,
RemoteEntity,
)
from homeassistant.config_entries import ConfigEntry
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 .const import DOMAIN
from .coordinator import ConsoleData, XboxUpdateCoordinator
from .coordinator import ConsoleData, XboxConfigEntry, XboxUpdateCoordinator
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: XboxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Xbox media_player from a config entry."""
client: XboxLiveClient = hass.data[DOMAIN][entry.entry_id]["client"]
consoles: SmartglassConsoleList = hass.data[DOMAIN][entry.entry_id]["consoles"]
coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][
"coordinator"
]
coordinator = entry.runtime_data
async_add_entities(
[XboxRemote(client, console, coordinator) for console in consoles.result]
[XboxRemote(console, coordinator) for console in coordinator.consoles.result]
)
@@ -53,14 +46,13 @@ class XboxRemote(CoordinatorEntity[XboxUpdateCoordinator], RemoteEntity):
def __init__(
self,
client: XboxLiveClient,
console: SmartglassConsole,
coordinator: XboxUpdateCoordinator,
) -> None:
"""Initialize the Xbox Media Player."""
super().__init__(coordinator)
self.client: XboxLiveClient = client
self._console: SmartglassConsole = console
self.client = coordinator.client
self._console = console
@property
def name(self):

View File

@@ -5,13 +5,11 @@ from __future__ import annotations
from functools import partial
from homeassistant.components.sensor import SensorEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import XboxUpdateCoordinator
from .coordinator import XboxConfigEntry, XboxUpdateCoordinator
from .entity import XboxBaseEntity
SENSOR_ATTRIBUTES = ["status", "gamer_score", "account_tier", "gold_tenure"]
@@ -19,18 +17,15 @@ SENSOR_ATTRIBUTES = ["status", "gamer_score", "account_tier", "gold_tenure"]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: XboxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Xbox Live friends."""
coordinator: XboxUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id][
"coordinator"
]
coordinator = config_entry.runtime_data
update_friends = partial(async_update_friends, coordinator, {}, async_add_entities)
unsub = coordinator.async_add_listener(update_friends)
hass.data[DOMAIN][config_entry.entry_id]["sensor_unsub"] = unsub
config_entry.async_on_unload(coordinator.async_add_listener(update_friends))
update_friends()

View File

@@ -58,7 +58,7 @@ ACTION_PARAMETERS_CACHE: HassKey[
LLM_API_ASSIST = "assist"
BASE_PROMPT = (
DATE_TIME_PROMPT = (
'Current time is {{ now().strftime("%H:%M:%S") }}. '
'Today\'s date is {{ now().strftime("%Y-%m-%d") }}.\n'
)
@@ -592,6 +592,8 @@ class AssistAPI(API):
for intent_handler in intent_handlers
]
tools.append(GetDateTimeTool())
if exposed_entities:
if exposed_entities[CALENDAR_DOMAIN]:
names = []
@@ -1181,3 +1183,29 @@ class GetLiveContextTool(Tool):
"success": True,
"result": "\n".join(prompt),
}
class GetDateTimeTool(Tool):
"""Tool for getting the current date and time."""
name = "GetDateTime"
description = "Provides the current date and time."
async def async_call(
self,
hass: HomeAssistant,
tool_input: ToolInput,
llm_context: LLMContext,
) -> JsonObjectType:
"""Get the current date and time."""
now = dt_util.now()
return {
"success": True,
"result": {
"date": now.strftime("%Y-%m-%d"),
"time": now.strftime("%H:%M:%S"),
"timezone": now.strftime("%Z"),
"weekday": now.strftime("%A"),
},
}

View File

@@ -49,7 +49,7 @@ mutagen==1.47.0
orjson==3.11.3
packaging>=23.1
paho-mqtt==2.1.0
Pillow==11.3.0
Pillow==12.0.0
propcache==0.4.1
psutil-home-assistant==0.0.1
PyJWT==2.10.1

View File

@@ -29,7 +29,7 @@ from homeassistant.helpers.check_config import async_check_ha_config_file
# mypy: allow-untyped-calls, allow-untyped-defs
REQUIREMENTS = ("colorlog==6.9.0",)
REQUIREMENTS = ("colorlog==6.10.1",)
_LOGGER = logging.getLogger(__name__)
MOCKS: dict[str, tuple[str, Callable]] = {

View File

@@ -126,24 +126,13 @@ _OBSOLETE_IMPORT: dict[str, list[ObsoleteImportMatch]] = {
}
_IGNORE_ROOT_IMPORT = (
"automation",
"bluetooth",
"camera",
"cast",
"device_automation",
"device_tracker",
"ffmpeg",
"ffmpeg_motion",
"google_assistant",
"homeassistant",
"homeassistant_hardware",
"http",
"manual",
"plex",
"recorder",
"rest",
"script",
"stream",
)

View File

@@ -59,7 +59,7 @@ dependencies = [
"PyJWT==2.10.1",
# PyJWT has loose dependency. We want the latest one.
"cryptography==46.0.2",
"Pillow==11.3.0",
"Pillow==12.0.0",
"propcache==0.4.1",
"pyOpenSSL==25.3.0",
"orjson==3.11.3",

2
requirements.txt generated
View File

@@ -30,7 +30,7 @@ Jinja2==3.1.6
lru-dict==1.3.0
PyJWT==2.10.1
cryptography==46.0.2
Pillow==11.3.0
Pillow==12.0.0
propcache==0.4.1
pyOpenSSL==25.3.0
orjson==3.11.3

12
requirements_all.txt generated
View File

@@ -36,7 +36,7 @@ PSNAWP==3.0.0
# homeassistant.components.seven_segments
# homeassistant.components.sighthound
# homeassistant.components.tensorflow
Pillow==11.3.0
Pillow==12.0.0
# homeassistant.components.plex
PlexAPI==4.15.16
@@ -319,7 +319,7 @@ aiolookin==1.0.0
aiolyric==2.0.2
# homeassistant.components.mealie
aiomealie==1.0.0
aiomealie==1.0.1
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -739,7 +739,7 @@ clx-sdk-xms==1.0.0
coinbase-advanced-py==1.2.2
# homeassistant.scripts.check_config
colorlog==6.9.0
colorlog==6.10.1
# homeassistant.components.color_extractor
colorthief==0.2.1
@@ -1148,7 +1148,7 @@ ha-philipsjs==3.2.4
ha-silabs-firmware-client==0.2.0
# homeassistant.components.habitica
habiticalib==0.4.5
habiticalib==0.4.6
# homeassistant.components.bluetooth
habluetooth==5.7.0
@@ -1732,7 +1732,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==1.8.0
plugwise==1.8.1
# homeassistant.components.serial_pm
pmsensor==0.4
@@ -2629,7 +2629,7 @@ pyvera==0.3.16
pyversasense==0.0.6
# homeassistant.components.vesync
pyvesync==3.1.0
pyvesync==3.1.2
# homeassistant.components.vizio
pyvizio==0.1.61

View File

@@ -36,7 +36,7 @@ PSNAWP==3.0.0
# homeassistant.components.seven_segments
# homeassistant.components.sighthound
# homeassistant.components.tensorflow
Pillow==11.3.0
Pillow==12.0.0
# homeassistant.components.plex
PlexAPI==4.15.16
@@ -301,7 +301,7 @@ aiolookin==1.0.0
aiolyric==2.0.2
# homeassistant.components.mealie
aiomealie==1.0.0
aiomealie==1.0.1
# homeassistant.components.modern_forms
aiomodernforms==0.1.8
@@ -648,7 +648,7 @@ caldav==1.6.0
coinbase-advanced-py==1.2.2
# homeassistant.scripts.check_config
colorlog==6.9.0
colorlog==6.10.1
# homeassistant.components.color_extractor
colorthief==0.2.1
@@ -1009,7 +1009,7 @@ ha-philipsjs==3.2.4
ha-silabs-firmware-client==0.2.0
# homeassistant.components.habitica
habiticalib==0.4.5
habiticalib==0.4.6
# homeassistant.components.bluetooth
habluetooth==5.7.0
@@ -1473,7 +1473,7 @@ plexauth==0.0.6
plexwebsocket==0.0.14
# homeassistant.components.plugwise
plugwise==1.8.0
plugwise==1.8.1
# homeassistant.components.poolsense
poolsense==0.0.8
@@ -2187,7 +2187,7 @@ pyuptimerobot==22.2.0
pyvera==0.3.16
# homeassistant.components.vesync
pyvesync==3.1.0
pyvesync==3.1.2
# homeassistant.components.vizio
pyvizio==0.1.61

View File

@@ -1755,7 +1755,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"omnilogic",
"oncue",
"ondilo_ico",
"onewire",
"onvif",
"open_meteo",
"openai_conversation",

View File

@@ -454,7 +454,10 @@ async def test_function_call(
agent_id=agent_id,
)
assert "Today's date is 2024-06-03." in mock_create.mock_calls[1][2]["system"]
assert (
"You are a voice assistant for Home Assistant."
in mock_create.mock_calls[1][2]["system"]
)
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert (

View File

@@ -33,7 +33,9 @@ from homeassistant.components.assist_pipeline.pipeline import ( # pylint: disab
)
from homeassistant.components.cloud.const import DEFAULT_EXPOSED_DOMAINS, DOMAIN
from homeassistant.components.cloud.http_api import validate_language_voice
from homeassistant.components.google_assistant.helpers import GoogleEntity
from homeassistant.components.google_assistant.helpers import ( # pylint: disable=hass-component-root-import
GoogleEntity,
)
from homeassistant.components.homeassistant import exposed_entities
from homeassistant.components.websocket_api import ERR_INVALID_FORMAT
from homeassistant.core import HomeAssistant, State

View File

@@ -13,7 +13,7 @@ from tests.common import assert_setup_component
COMPONENT = "comfoconnect"
VALID_CONFIG = {
COMPONENT: {"host": "1.2.3.4"},
COMPONENT: {"host": "192.0.2.1"},
SENSOR_DOMAIN: {
"platform": COMPONENT,
"resources": [
@@ -31,7 +31,10 @@ VALID_CONFIG = {
def mock_bridge_discover() -> Generator[MagicMock]:
"""Mock the bridge discover method."""
with patch("pycomfoconnect.bridge.Bridge.discover") as mock_bridge_discover:
mock_bridge_discover.return_value[0].uuid.hex.return_value = "00"
bridge = MagicMock()
bridge.uuid.hex.return_value = "00"
bridge.host = "192.0.2.1"
mock_bridge_discover.return_value = [bridge]
yield mock_bridge_discover
@@ -44,11 +47,28 @@ def mock_comfoconnect_command() -> Generator[MagicMock]:
yield mock_comfoconnect_command
@pytest.fixture
def mock_comfoconnect_connect() -> Generator[MagicMock]:
"""Mock the ComfoConnect connect method."""
with patch("pycomfoconnect.comfoconnect.ComfoConnect.connect") as mock_connect:
yield mock_connect
@pytest.fixture(autouse=True)
def mock_comfoconnect_disconnect() -> Generator[MagicMock]:
"""Mock the ComfoConnect disconnect method, autouse=True to mock in teardown."""
with patch(
"pycomfoconnect.comfoconnect.ComfoConnect.disconnect"
) as mock_disconnect:
yield mock_disconnect
@pytest.fixture
async def setup_sensor(
hass: HomeAssistant,
mock_bridge_discover: MagicMock,
mock_comfoconnect_command: MagicMock,
mock_comfoconnect_connect: MagicMock,
) -> None:
"""Set up demo sensor component."""
with assert_setup_component(1, SENSOR_DOMAIN):

View File

@@ -156,6 +156,106 @@ async def test_multiple_llm_apis(
assert chat_log.llm_api.api.id == "assist|my-api"
async def test_dynamic_time_injection(
hass: HomeAssistant, mock_conversation_input: ConversationInput
) -> None:
"""Test that dynamic time injection works correctly."""
class MyAPI(llm.API):
"""Test API."""
async def async_get_api_instance(
self, llm_context: llm.LLMContext
) -> llm.APIInstance:
"""Return a list of tools."""
return llm.APIInstance(self, "My API Prompt", llm_context, [])
not_assist_1_api = MyAPI(hass=hass, id="not-assist-1", name="Not Assist 1")
llm.async_register_api(hass, not_assist_1_api)
not_assist_2_api = MyAPI(hass=hass, id="not-assist-2", name="Not Assist 2")
llm.async_register_api(hass, not_assist_2_api)
# Helper to track which prompts are rendered
rendered_prompts = []
async def fake_expand_prompt_template(
llm_context, prompt, language, user_name=None
):
rendered_prompts.append(prompt)
return prompt
# Case 1: No API used -> prompt should contain the time
with (
chat_session.async_get_chat_session(hass) as session,
async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
):
chat_log._async_expand_prompt_template = fake_expand_prompt_template
rendered_prompts.clear()
await chat_log.async_provide_llm_data(
mock_conversation_input.as_llm_context("test"),
user_llm_hass_api=None,
user_llm_prompt=None,
)
assert llm.DATE_TIME_PROMPT in rendered_prompts
# Case 2: Single API (not assist) -> prompt should contain the time
with (
chat_session.async_get_chat_session(hass) as session,
async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
):
chat_log._async_expand_prompt_template = fake_expand_prompt_template
rendered_prompts.clear()
await chat_log.async_provide_llm_data(
mock_conversation_input.as_llm_context("test"),
user_llm_hass_api=["not-assist-1"],
user_llm_prompt=None,
)
assert llm.DATE_TIME_PROMPT in rendered_prompts
# Case 3: Single API (assist) -> prompt should NOT contain the time
with (
chat_session.async_get_chat_session(hass) as session,
async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
):
chat_log._async_expand_prompt_template = fake_expand_prompt_template
rendered_prompts.clear()
await chat_log.async_provide_llm_data(
mock_conversation_input.as_llm_context("test"),
user_llm_hass_api=[llm.LLM_API_ASSIST],
user_llm_prompt=None,
)
assert llm.DATE_TIME_PROMPT not in rendered_prompts
# Case 4: Merged API (without assist) -> prompt should contain the time
with (
chat_session.async_get_chat_session(hass) as session,
async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
):
chat_log._async_expand_prompt_template = fake_expand_prompt_template
rendered_prompts.clear()
await chat_log.async_provide_llm_data(
mock_conversation_input.as_llm_context("test"),
user_llm_hass_api=["not-assist-1", "not-assist-2"],
user_llm_prompt=None,
)
assert llm.DATE_TIME_PROMPT in rendered_prompts
# Case 5: Merged API (with assist) -> prompt should NOT contain the time
with (
chat_session.async_get_chat_session(hass) as session,
async_get_chat_log(hass, session, mock_conversation_input) as chat_log,
):
chat_log._async_expand_prompt_template = fake_expand_prompt_template
rendered_prompts.clear()
await chat_log.async_provide_llm_data(
mock_conversation_input.as_llm_context("test"),
user_llm_hass_api=[llm.LLM_API_ASSIST, "not-assist-1"],
user_llm_prompt=None,
)
assert llm.DATE_TIME_PROMPT not in rendered_prompts
async def test_template_error(
hass: HomeAssistant,
mock_conversation_input: ConversationInput,

View File

@@ -154,12 +154,17 @@ async def test_set_cover_position(hass: HomeAssistant) -> None:
"""Test moving the cover to a specific position."""
state = hass.states.get(ENTITY_COVER)
assert state.attributes[ATTR_CURRENT_POSITION] == 70
# close to 10%
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
{ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 10},
blocking=True,
)
state = hass.states.get(ENTITY_COVER)
assert state.state == CoverState.CLOSING
for _ in range(6):
future = dt_util.utcnow() + timedelta(seconds=1)
async_fire_time_changed(hass, future)
@@ -167,6 +172,26 @@ async def test_set_cover_position(hass: HomeAssistant) -> None:
state = hass.states.get(ENTITY_COVER)
assert state.attributes[ATTR_CURRENT_POSITION] == 10
assert state.state == CoverState.OPEN
# open to 80%
await hass.services.async_call(
COVER_DOMAIN,
SERVICE_SET_COVER_POSITION,
{ATTR_ENTITY_ID: ENTITY_COVER, ATTR_POSITION: 80},
blocking=True,
)
state = hass.states.get(ENTITY_COVER)
assert state.state == CoverState.OPENING
for _ in range(7):
future = dt_util.utcnow() + timedelta(seconds=1)
async_fire_time_changed(hass, future)
await hass.async_block_till_done()
state = hass.states.get(ENTITY_COVER)
assert state.attributes[ATTR_CURRENT_POSITION] == 80
assert state.state == CoverState.OPEN
async def test_stop_cover(hass: HomeAssistant) -> None:

View File

@@ -1,56 +0,0 @@
"""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,24 +1,65 @@
"""Test that validation works."""
from unittest.mock import patch
import pytest
from homeassistant.components.energy import validate
from homeassistant.components.energy import async_get_manager, 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 setup_energy_for_validation(
mock_energy_manager: EnergyManager,
async def mock_energy_manager(
recorder_mock: Recorder, hass: HomeAssistant
) -> EnergyManager:
"""Ensure energy manager is set up for validation tests."""
return mock_energy_manager
"""Set up energy."""
assert await async_setup_component(hass, "energy", {"energy": {}})
manager = await async_get_manager(hass)
manager.data = manager.default_preferences()
return manager
async def test_validation_empty_config(hass: HomeAssistant) -> None:
@@ -372,7 +413,6 @@ async def test_validation_grid(
"stat_compensation": "sensor.grid_compensation_1",
}
],
"power": [],
}
]
}
@@ -464,7 +504,6 @@ async def test_validation_grid_external_cost_compensation(
"stat_compensation": "external:grid_compensation_1",
}
],
"power": [],
}
]
}
@@ -703,7 +742,6 @@ async def test_validation_grid_price_errors(
}
],
"flow_to": [],
"power": [],
}
]
}
@@ -909,7 +947,6 @@ async def test_validation_grid_no_costs_tracking(
"number_energy_price": None,
},
],
"power": [],
"cost_adjustment_day": 0.0,
}
]

View File

@@ -1,450 +0,0 @@
"""Test power stat validation."""
import pytest
from homeassistant.components.energy import validate
from homeassistant.components.energy.data import EnergyManager
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
POWER_UNITS_STRING = ", ".join(tuple(UnitOfPower))
@pytest.fixture(autouse=True)
async def setup_energy_for_validation(
mock_energy_manager: EnergyManager,
) -> EnergyManager:
"""Ensure energy manager is set up for validation tests."""
return mock_energy_manager
async def test_validation_grid_power_valid(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with valid power sensor."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_power": "sensor.grid_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set(
"sensor.grid_power",
"1.5",
{
"device_class": "power",
"unit_of_measurement": UnitOfPower.KILO_WATT,
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [[]],
"device_consumption": [],
}
async def test_validation_grid_power_wrong_unit(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with power sensor having wrong unit."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_power": "sensor.grid_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set(
"sensor.grid_power",
"1.5",
{
"device_class": "power",
"unit_of_measurement": "beers",
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_unit_power",
"affected_entities": {("sensor.grid_power", "beers")},
"translation_placeholders": {"power_units": POWER_UNITS_STRING},
}
]
],
"device_consumption": [],
}
async def test_validation_grid_power_wrong_state_class(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with power sensor having wrong state class."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_power": "sensor.grid_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set(
"sensor.grid_power",
"1.5",
{
"device_class": "power",
"unit_of_measurement": UnitOfPower.KILO_WATT,
"state_class": "total_increasing",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_state_class",
"affected_entities": {("sensor.grid_power", "total_increasing")},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
}
async def test_validation_grid_power_entity_missing(
hass: HomeAssistant, mock_energy_manager
) -> None:
"""Test validating grid with missing power sensor."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_power": "sensor.missing_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "statistics_not_defined",
"affected_entities": {("sensor.missing_power", None)},
"translation_placeholders": None,
},
{
"type": "entity_not_defined",
"affected_entities": {("sensor.missing_power", None)},
"translation_placeholders": None,
},
]
],
"device_consumption": [],
}
async def test_validation_grid_power_entity_unavailable(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with unavailable power sensor."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_power": "sensor.unavailable_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set("sensor.unavailable_power", "unavailable", {})
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_unavailable",
"affected_entities": {("sensor.unavailable_power", "unavailable")},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
}
async def test_validation_grid_power_entity_non_numeric(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with non-numeric power sensor."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_power": "sensor.non_numeric_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set(
"sensor.non_numeric_power",
"not_a_number",
{
"device_class": "power",
"unit_of_measurement": UnitOfPower.KILO_WATT,
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_state_non_numeric",
"affected_entities": {("sensor.non_numeric_power", "not_a_number")},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
}
async def test_validation_grid_power_wrong_device_class(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with power sensor having wrong device class."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_power": "sensor.wrong_device_class_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set(
"sensor.wrong_device_class_power",
"1.5",
{
"device_class": "energy",
"unit_of_measurement": "kWh",
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "entity_unexpected_device_class",
"affected_entities": {
("sensor.wrong_device_class_power", "energy")
},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
}
async def test_validation_grid_power_different_units(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with power sensors using different valid units."""
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_power": "sensor.power_watt",
},
{
"stat_power": "sensor.power_milliwatt",
},
],
"cost_adjustment_day": 0.0,
}
]
}
)
hass.states.async_set(
"sensor.power_watt",
"1500",
{
"device_class": "power",
"unit_of_measurement": UnitOfPower.WATT,
"state_class": "measurement",
},
)
hass.states.async_set(
"sensor.power_milliwatt",
"1500000",
{
"device_class": "power",
"unit_of_measurement": UnitOfPower.MILLIWATT,
"state_class": "measurement",
},
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [[]],
"device_consumption": [],
}
async def test_validation_grid_power_external_statistics(
hass: HomeAssistant, mock_energy_manager, mock_get_metadata
) -> None:
"""Test validating grid with external power statistics (non-entity)."""
mock_get_metadata["external:power_stat"] = None
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_power": "external:power_stat",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "statistics_not_defined",
"affected_entities": {("external:power_stat", None)},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
}
async def test_validation_grid_power_recorder_untracked(
hass: HomeAssistant, mock_energy_manager, mock_is_entity_recorded, mock_get_metadata
) -> None:
"""Test validating grid with power sensor not tracked by recorder."""
mock_is_entity_recorded["sensor.untracked_power"] = False
await mock_energy_manager.async_update(
{
"energy_sources": [
{
"type": "grid",
"flow_from": [],
"flow_to": [],
"power": [
{
"stat_power": "sensor.untracked_power",
}
],
"cost_adjustment_day": 0.0,
}
]
}
)
result = await validate.async_validate(hass)
assert result.as_dict() == {
"energy_sources": [
[
{
"type": "recorder_untracked",
"affected_entities": {("sensor.untracked_power", None)},
"translation_placeholders": None,
}
]
],
"device_consumption": [],
}

View File

@@ -137,24 +137,17 @@ async def test_save_preferences(
"number_energy_price": 0.20,
},
],
"power": [
{
"stat_power": "sensor.grid_power",
}
],
"cost_adjustment_day": 1.2,
},
{
"type": "solar",
"stat_energy_from": "my_solar_production",
"stat_power": "my_solar_power",
"config_entry_solar_forecast": ["predicted_config_entry"],
},
{
"type": "battery",
"stat_energy_from": "my_battery_draining",
"stat_energy_to": "my_battery_charging",
"stat_power": "my_battery_power",
},
],
"device_consumption": [
@@ -162,7 +155,6 @@ async def test_save_preferences(
"stat_consumption": "some_device_usage",
"name": "My Device",
"included_in_stat": "sensor.some_other_device",
"stat_power": "sensor.some_device_power",
}
],
}
@@ -261,7 +253,6 @@ async def test_handle_duplicate_from_stat(
},
],
"flow_to": [],
"power": [],
"cost_adjustment_day": 0,
},
],

View File

@@ -21,6 +21,9 @@ import pytest
from webrtc_models import RTCIceCandidateInit
from homeassistant.components.camera import (
DATA_CAMERA_PREFS,
CameraPreferences,
DynamicStreamSettings,
StreamType,
WebRTCAnswer as HAWebRTCAnswer,
WebRTCCandidate as HAWebRTCCandidate,
@@ -29,11 +32,6 @@ from homeassistant.components.camera import (
WebRTCSendMessage,
async_get_image,
)
from homeassistant.components.camera.const import DATA_CAMERA_PREFS
from homeassistant.components.camera.prefs import (
CameraPreferences,
DynamicStreamSettings,
)
from homeassistant.components.default_config import DOMAIN as DEFAULT_CONFIG_DOMAIN
from homeassistant.components.go2rtc import HomeAssistant, WebRTCProvider
from homeassistant.components.go2rtc.const import (

View File

@@ -1 +1,12 @@
"""Tests for the growatt_server component."""
"""Tests for the Growatt Server integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
"""Set up the Growatt Server integration for testing."""
config_entry.add_to_hass(hass)
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()

View File

@@ -70,8 +70,19 @@ def mock_growatt_v1_api():
}
# Called by MIN device coordinator during refresh
# Empty dict is sufficient for switch tests (sensor tests would need real energy data)
mock_v1_api.min_energy.return_value = {}
# Provide realistic energy data for sensor tests
mock_v1_api.min_energy.return_value = {
"eChargeToday": 5.2,
"eChargeTotal": 125.8,
"eDischargeToday": 8.1,
"eDischargeTotal": 245.6,
"eSelfToday": 12.5,
"eSelfTotal": 320.4,
"eBatChargeToday": 6.3,
"eBatChargeTotal": 150.2,
"eBatDischargeToday": 7.8,
"eBatDischargeTotal": 180.5,
}
# Called by total coordinator during refresh
mock_v1_api.plant_energy_overview.return_value = {

View File

@@ -0,0 +1,125 @@
# serializer version: 1
# name: test_classic_api_setup
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'growatt_server',
'TLX123456',
),
}),
'labels': set({
}),
'manufacturer': 'Growatt',
'model': None,
'model_id': None,
'name': 'TLX123456',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_device_info
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'growatt_server',
'MIN123456',
),
}),
'labels': set({
}),
'manufacturer': 'Growatt',
'model': None,
'model_id': None,
'name': 'MIN123456',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_multiple_devices_discovered[device_min123456]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'growatt_server',
'MIN123456',
),
}),
'labels': set({
}),
'manufacturer': 'Growatt',
'model': None,
'model_id': None,
'name': 'MIN123456',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---
# name: test_multiple_devices_discovered[device_min789012]
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'growatt_server',
'MIN789012',
),
}),
'labels': set({
}),
'manufacturer': 'Growatt',
'model': None,
'model_id': None,
'name': 'MIN789012',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': None,
'sw_version': None,
'via_device_id': None,
})
# ---

File diff suppressed because it is too large Load Diff

View File

@@ -78,3 +78,51 @@
'state': 'on',
})
# ---
# name: test_switch_entity_attributes[entity_entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'switch',
'entity_category': <EntityCategory.CONFIG: 'config'>,
'entity_id': 'switch.min123456_charge_from_grid',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Charge from grid',
'platform': 'growatt_server',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'ac_charge',
'unique_id': 'MIN123456_ac_charge',
'unit_of_measurement': None,
})
# ---
# name: test_switch_entity_attributes[state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'MIN123456 Charge from grid',
}),
'context': <ANY>,
'entity_id': 'switch.min123456_charge_from_grid',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---

View File

@@ -0,0 +1,176 @@
"""Tests for the Growatt Server integration."""
from datetime import timedelta
import json
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import growattServer
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.growatt_server.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed
@pytest.mark.usefixtures("init_integration")
async def test_load_unload_config_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test loading and unloading the integration."""
assert mock_config_entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.usefixtures("init_integration")
async def test_device_info(
snapshot: SnapshotAssertion,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test device registry integration."""
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")})
assert device_entry is not None
assert device_entry == snapshot
@pytest.mark.parametrize(
("exception", "expected_state"),
[
(growattServer.GrowattV1ApiError("API Error"), ConfigEntryState.SETUP_ERROR),
(
json.decoder.JSONDecodeError("Invalid JSON", "", 0),
ConfigEntryState.SETUP_ERROR,
),
],
)
async def test_setup_error_on_api_failure(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
exception: Exception,
expected_state: ConfigEntryState,
) -> None:
"""Test setup error on API failures during device list."""
mock_growatt_v1_api.device_list.side_effect = exception
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is expected_state
@pytest.mark.usefixtures("init_integration")
async def test_coordinator_update_failed(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test coordinator handles update failures gracefully."""
# Integration should be loaded
assert mock_config_entry.state is ConfigEntryState.LOADED
# Cause coordinator update to fail
mock_growatt_v1_api.min_detail.side_effect = growattServer.GrowattV1ApiError(
"Connection timeout"
)
# Trigger coordinator refresh
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Integration should remain loaded despite coordinator error
assert mock_config_entry.state is ConfigEntryState.LOADED
async def test_classic_api_setup(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_growatt_classic_api,
mock_config_entry_classic: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test integration setup with Classic API (password auth)."""
# Classic API doesn't support MIN devices - use TLX device instead
mock_growatt_classic_api.device_list.return_value = [
{"deviceSn": "TLX123456", "deviceType": "tlx"}
]
await setup_integration(hass, mock_config_entry_classic)
assert mock_config_entry_classic.state is ConfigEntryState.LOADED
# Verify Classic API login was called
mock_growatt_classic_api.login.assert_called()
# Verify device was created
device_entry = device_registry.async_get_device(identifiers={(DOMAIN, "TLX123456")})
assert device_entry is not None
assert device_entry == snapshot
@pytest.mark.usefixtures("init_integration")
async def test_unload_removes_listeners(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test that unloading removes all listeners."""
# Get initial listener count
initial_listeners = len(hass.bus.async_listeners())
# Unload the integration
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify listeners were removed (should be same or less)
final_listeners = len(hass.bus.async_listeners())
assert final_listeners <= initial_listeners
async def test_multiple_devices_discovered(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
device_registry: dr.DeviceRegistry,
) -> None:
"""Test handling multiple devices from device_list."""
# Reset and add multiple devices
mock_config_entry_new = MockConfigEntry(
domain=DOMAIN,
data=mock_config_entry.data,
unique_id="plant_456",
)
mock_growatt_v1_api.device_list.return_value = {
"devices": [
{"device_sn": "MIN123456", "type": 7},
{"device_sn": "MIN789012", "type": 7},
]
}
with patch(
"homeassistant.components.growatt_server.coordinator.SCAN_INTERVAL",
timedelta(minutes=5),
):
await setup_integration(hass, mock_config_entry_new)
# Verify both devices were created
device1 = device_registry.async_get_device(identifiers={(DOMAIN, "MIN123456")})
device2 = device_registry.async_get_device(identifiers={(DOMAIN, "MIN789012")})
assert device1 is not None
assert device1 == snapshot(name="device_min123456")
assert device2 is not None
assert device2 == snapshot(name="device_min789012")

View File

@@ -0,0 +1,133 @@
"""Tests for the Growatt Server sensor platform."""
from datetime import timedelta
from unittest.mock import patch
from freezegun.api import FrozenDateTimeFactory
import growattServer
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.const import STATE_UNAVAILABLE, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_all_sensors(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test all sensor entities with snapshot."""
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
async def test_sensor_coordinator_updates(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sensors update when coordinator refreshes."""
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
# Verify sensor exists
state = hass.states.get("sensor.test_plant_total_energy_today")
assert state is not None
assert state.state == "12.5"
# Update mock data
mock_growatt_v1_api.plant_energy_overview.return_value = {
"today_energy": 25.0, # Changed from 12.5
"total_energy": 1250.0,
"current_power": 2500,
}
# Trigger coordinator refresh
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify state updated
state = hass.states.get("sensor.test_plant_total_energy_today")
assert state is not None
assert state.state == "25.0"
async def test_sensor_unavailable_on_coordinator_error(
hass: HomeAssistant,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test sensors become unavailable when coordinator fails."""
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
# Verify sensor is initially available
state = hass.states.get("sensor.min123456_all_batteries_charged_today")
assert state is not None
assert state.state != STATE_UNAVAILABLE
# Cause coordinator update to fail
mock_growatt_v1_api.min_detail.side_effect = growattServer.GrowattV1ApiError(
"Connection timeout"
)
# Trigger coordinator refresh
freezer.tick(timedelta(minutes=5))
async_fire_time_changed(hass)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify sensor becomes unavailable
state = hass.states.get("sensor.min123456_all_batteries_charged_today")
assert state is not None
assert state.state == STATE_UNAVAILABLE
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_total_sensors_classic_api(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_growatt_classic_api,
mock_config_entry_classic: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test total sensors with Classic API."""
# Classic API uses TLX devices
mock_growatt_classic_api.device_list.return_value = [
{"deviceSn": "TLX123456", "deviceType": "tlx"}
]
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry_classic)
await snapshot_platform(
hass, entity_registry, snapshot, mock_config_entry_classic.entry_id
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensor_entity_registry(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
mock_growatt_v1_api,
mock_config_entry: MockConfigEntry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test sensor entities are properly registered."""
with patch("homeassistant.components.growatt_server.PLATFORMS", [Platform.SENSOR]):
await setup_integration(hass, mock_config_entry)
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)

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