Compare commits

...

55 Commits

Author SHA1 Message Date
Bram Kragten
4d55939f53 Bump version to 2026.1.0b3 2026-01-05 16:53:53 +01:00
Bram Kragten
e5e7546d49 Fix humidifier trigger turned on icon (#160297) 2026-01-05 16:52:56 +01:00
Joakim Sørensen
e560795d04 Add connection check before registering cloudhook URL (#160284) 2026-01-05 16:52:55 +01:00
epenet
15b0342bd7 Fix Tuya light color data wrapper (#160280) 2026-01-05 16:52:54 +01:00
Jan-Philipp Benecke
8d05a5f3d4 Bump aiowebdav2 to 0.5.0 (#160233) 2026-01-05 16:52:53 +01:00
Samuel Xiao
358ad29b59 Switchbot Cloud: Fixed Robot Vacuum Cleaner S20 had two device_model name (#160230) 2026-01-05 16:52:52 +01:00
J. Nick Koston
5c4f99b828 Bump aiohttp 3.13.3 (#160206) 2026-01-05 16:52:03 +01:00
Erik Montnemery
b3f123c715 Await writes in shopping_list action handlers (#157420) 2026-01-05 16:51:30 +01:00
J. Nick Koston
85c2351af2 Ensure Brotli >= 1.2.0 (#160229) 2026-01-05 16:45:49 +01:00
Josef Zweck
ec19529c99 Remove referral link from fish_audio (#160193) 2026-01-05 16:40:46 +01:00
Vincent Courcelle
d5ebd02afe Bump python-roborock to 4.2.0 (#160184) 2026-01-05 16:40:45 +01:00
wollew
37d82ab795 bump pyvlx version to 0.2.27 (#160139) 2026-01-05 16:40:44 +01:00
mettolen
5d08481137 Bump pyairobotrest to 0.2.0 (#160125) 2026-01-05 16:40:43 +01:00
Maikel Punie
0861b7541d Bump velbusaio to 2026.1.1 (#160116) 2026-01-05 16:40:42 +01:00
Jan Bouwhuis
abf7078842 Fix reolink brightness scaling (#160106) 2026-01-05 16:40:41 +01:00
Michael Hansen
c4012fae4e Bump intents to 2026.1.1 (#160099) 2026-01-05 16:40:40 +01:00
Maikel Punie
d6082ab6c3 Bump velbusaio to 2026.1.0 (#160087) 2026-01-05 16:40:39 +01:00
Austin Mroczek
77367e415f Bump total_connect_client to 2025.12.2 (#160075) 2026-01-05 16:40:38 +01:00
Miguel Camba
6c006c68c1 Update voluptuous and voluptuous-openapi (#160073) 2026-01-05 16:40:37 +01:00
Pete Sage
026fdeb4ce Improve Sonos wait to unjoin timeout (#160011) 2026-01-05 16:40:36 +01:00
cdnninja
1034218e6e add description to string vesync (#160003) 2026-01-05 16:40:35 +01:00
Willem-Jan van Rootselaar
a21062f502 Add schema validation for set_hot_water_schedule service (#159990) 2026-01-05 16:40:34 +01:00
Maikel Punie
2e157f1bc6 Velbus Exception translations (#159627)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-05 16:40:32 +01:00
Paul Tarjan
a697e63b8c Fix Tesla update showing scheduled updates as installing (#158681) 2026-01-05 16:40:31 +01:00
Ben Wolstencroft
d28d55c7db Add support for health_overview API endpoint to Tractive integration (#157960)
Co-authored-by: Maciej Bieniek <bieniu@users.noreply.github.com>
2026-01-05 16:40:30 +01:00
Brett Adams
8863488286 Handle export options when enrolled to VPP in Teslemetry (#157665) 2026-01-05 16:40:29 +01:00
Daniel Hjelseth Høyer
53cfdef1ac Move Tibber to OAuth (#156690)
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-01-05 16:40:28 +01:00
Franck Nijhof
42ea7ecbd6 Bump version to 2026.1.0b2 2025-12-31 15:34:05 +00:00
tronikos
d58d08c350 Filter out duplicate voices without language code in Google Cloud (#160046) 2025-12-31 15:33:49 +00:00
Paul Tarjan
65a259b9df Fix Hikvision thread safety issue when calling async_write_ha_state (#160027) 2025-12-31 15:33:48 +00:00
Luke Lashley
cbfbfbee13 Don't prefer cache for Roborock device fetching (#160022) 2025-12-31 15:33:47 +00:00
David Knowles
e503b37ddc Use WATER device_class for Hydrawise sensors (#160018) 2025-12-31 15:33:45 +00:00
Simone Chemelli
217eef39f3 Bump aioamazondevices to 11.0.2 (#160016)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-12-31 15:33:44 +00:00
Manu
dcdbce9b21 Convert store image URLs to https in Xbox media resolver (#160015) 2025-12-31 15:33:42 +00:00
Erwin Douna
71db8fe185 Bump portainer 1.0.19 (#160014) 2025-12-31 15:33:41 +00:00
Anders Melchiorsen
9b96cb66d5 Fix netgear_lte unloading (#160008) 2025-12-31 15:33:39 +00:00
Anders Melchiorsen
78bccbbbc2 Move async_setup_services to async_setup for netgear_lte (#160007) 2025-12-31 15:33:38 +00:00
Anders Melchiorsen
b0a8f9575c Bump eternalegypt to 0.0.18 (#160006) 2025-12-31 15:33:36 +00:00
Matthias Alphart
61104a9970 Update knx-frontend to 2025.12.30.151231 (#159999) 2025-12-31 15:33:35 +00:00
Franck Nijhof
8d13dbdd0c Bump version to 2026.1.0b1 2025-12-30 09:14:36 +00:00
Erwin Douna
9afb41004e Portainer fix stopped container for stats (#159964) 2025-12-30 09:14:24 +00:00
Luke Lashley
cdd542f6e6 Bump Python-Roborock to 4.1.0 (#159963) 2025-12-30 09:14:22 +00:00
Joost Lekkerkerker
f520686002 Small cleanup in Feedreader (#159962) 2025-12-30 09:14:20 +00:00
J. Nick Koston
e4d09bb615 Bump aioesphomeapi to 43.9.1 (#159960) 2025-12-30 09:14:19 +00:00
Matthias Alphart
10f6ccf6cc Fix KNX translation references (#159959) 2025-12-30 09:14:17 +00:00
Ernst Klamer
d9fa67b16f bump xiaomi-ble to 1.4.1 (#159954) 2025-12-30 09:14:15 +00:00
Joost Lekkerkerker
cf228ae02b Inject session in Switchbot cloud (#159942) 2025-12-30 09:14:14 +00:00
Joost Lekkerkerker
cb4d62ab9a Add integration_type device to ps4 (#159892) 2025-12-30 09:14:12 +00:00
Joost Lekkerkerker
d2f75aec04 Add integration_type hub to poolsense (#159881) 2025-12-30 09:14:11 +00:00
Joost Lekkerkerker
a609fbc07b Add integration_type hub to pooldose (#159880) 2025-12-30 09:14:09 +00:00
Joost Lekkerkerker
1b9c7ae0ac Add integration_type hub to permobil (#159872) 2025-12-30 09:14:07 +00:00
Joost Lekkerkerker
492f2117fb Add integration_type service to nuheat (#159845) 2025-12-30 09:14:06 +00:00
Joost Lekkerkerker
2346f83635 Add integration_type device to netgear (#159816) 2025-12-30 09:14:04 +00:00
Kamil Breguła
8925bfb182 Add translation of exceptions in met (#155765)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-30 09:12:18 +00:00
Franck Nijhof
8f2b1f0eff Bump version to 2026.1.0b0 2025-12-29 19:01:17 +00:00
107 changed files with 2904 additions and 504 deletions

View File

@@ -13,5 +13,5 @@
"iot_class": "local_polling",
"loggers": ["pyairobotrest"],
"quality_scale": "silver",
"requirements": ["pyairobotrest==0.1.0"]
"requirements": ["pyairobotrest==0.2.0"]
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aioamazondevices"],
"quality_scale": "platinum",
"requirements": ["aioamazondevices==10.0.0"]
"requirements": ["aioamazondevices==11.0.2"]
}

View File

@@ -7,11 +7,12 @@ import logging
from typing import TYPE_CHECKING
from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot
import voluptuous as vol
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers import config_validation as cv, device_registry as dr
from .const import DOMAIN
@@ -33,28 +34,27 @@ ATTR_SUNDAY_SLOTS = "sunday_slots"
SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule"
def _parse_time_value(value: time | str) -> time:
"""Parse a time value from either a time object or string.
# Schema for a single time slot
_SLOT_SCHEMA = vol.Schema(
{
vol.Required("start_time"): cv.time,
vol.Required("end_time"): cv.time,
}
)
Raises ServiceValidationError if the format is invalid.
"""
if isinstance(value, time):
return value
if isinstance(value, str):
try:
parts = value.split(":")
return time(int(parts[0]), int(parts[1]))
except (ValueError, IndexError):
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_time_format",
) from None
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="invalid_time_format",
)
SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA = vol.Schema(
{
vol.Required(ATTR_DEVICE_ID): cv.string,
vol.Optional(ATTR_MONDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_TUESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_WEDNESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_THURSDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_FRIDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_SATURDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
vol.Optional(ATTR_SUNDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
}
)
def _convert_time_slots_to_day_schedule(
@@ -62,8 +62,8 @@ def _convert_time_slots_to_day_schedule(
) -> DaySchedule | None:
"""Convert list of time slot dicts to a DaySchedule object.
Example: [{"start_time": "06:00", "end_time": "08:00"},
{"start_time": "17:00", "end_time": "21:00"}]
Example: [{"start_time": time(6, 0), "end_time": time(8, 0)},
{"start_time": time(17, 0), "end_time": time(21, 0)}]
becomes: DaySchedule with two TimeSlot objects
None returns None (don't modify this day).
@@ -77,31 +77,27 @@ def _convert_time_slots_to_day_schedule(
time_slots = []
for slot in slots:
start = slot.get("start_time")
end = slot.get("end_time")
start_time = slot["start_time"]
end_time = slot["end_time"]
if start and end:
start_time = _parse_time_value(start)
end_time = _parse_time_value(end)
# Validate that end time is after start time
if end_time <= start_time:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_time_before_start_time",
translation_placeholders={
"start_time": start_time.strftime("%H:%M"),
"end_time": end_time.strftime("%H:%M"),
},
)
time_slots.append(TimeSlot(start=start_time, end=end_time))
LOGGER.debug(
"Created time slot: %s-%s",
start_time.strftime("%H:%M"),
end_time.strftime("%H:%M"),
# Validate that end time is after start time
if end_time <= start_time:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="end_time_before_start_time",
translation_placeholders={
"start_time": start_time.strftime("%H:%M"),
"end_time": end_time.strftime("%H:%M"),
},
)
time_slots.append(TimeSlot(start=start_time, end=end_time))
LOGGER.debug(
"Created time slot: %s-%s",
start_time.strftime("%H:%M"),
end_time.strftime("%H:%M"),
)
LOGGER.debug("Created DaySchedule with %d slots", len(time_slots))
return DaySchedule(slots=time_slots)
@@ -214,4 +210,5 @@ def async_setup_services(hass: HomeAssistant) -> None:
DOMAIN,
SERVICE_SET_HOT_WATER_SCHEDULE,
set_hot_water_schedule,
schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA,
)

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.5.0", "home-assistant-intents==2025.12.2"]
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.1"]
}

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==43.9.0",
"aioesphomeapi==43.9.1",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.4.0"
],

View File

@@ -9,14 +9,12 @@ from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
from .coordinator import FeedReaderConfigEntry, FeedReaderCoordinator, StoredData
CONF_URLS = "urls"
MY_KEY: HassKey[StoredData] = HassKey(DOMAIN)
FEEDREADER_KEY: HassKey[StoredData] = HassKey(DOMAIN)
async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) -> bool:
"""Set up Feedreader from a config entry."""
storage = hass.data.setdefault(MY_KEY, StoredData(hass))
storage = hass.data.setdefault(FEEDREADER_KEY, StoredData(hass))
if not storage.is_initialized:
await storage.async_setup()
@@ -42,5 +40,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry)
)
# if this is the last entry, remove the storage
if len(entries) == 1:
hass.data.pop(MY_KEY)
hass.data.pop(FEEDREADER_KEY)
return await hass.config_entries.async_unload_platforms(entry, [Platform.EVENT])

View File

@@ -42,16 +42,15 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity):
_attr_event_types = [EVENT_FEEDREADER]
_attr_name = None
_attr_has_entity_name = True
_attr_translation_key = "latest_feed"
_unrecorded_attributes = frozenset(
{ATTR_CONTENT, ATTR_DESCRIPTION, ATTR_TITLE, ATTR_LINK}
)
coordinator: FeedReaderCoordinator
def __init__(self, coordinator: FeedReaderCoordinator) -> None:
"""Initialize the feedreader event."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_latest_feed"
self._attr_translation_key = "latest_feed"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
name=coordinator.config_entry.title,

View File

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

View File

@@ -35,6 +35,6 @@ BACKEND_MODELS = ["s1", "speech-1.5", "speech-1.6"]
SORT_BY_OPTIONS = ["task_count", "score", "created_at"]
LATENCY_OPTIONS = ["normal", "balanced"]
SIGNUP_URL = "https://fish.audio/?fpr=homeassistant" # codespell:ignore fpr
SIGNUP_URL = "https://fish.audio/"
BILLING_URL = "https://fish.audio/app/billing/"
API_KEYS_URL = "https://fish.audio/app/api-keys/"

View File

@@ -48,6 +48,8 @@ async def async_tts_voices(
list_voices_response = await client.list_voices()
for voice in list_voices_response.voices:
language_code = voice.language_codes[0]
if not voice.name.startswith(language_code):
continue
if language_code not in voices:
voices[language_code] = []
voices[language_code].append(voice.name)

View File

@@ -24,7 +24,7 @@ from homeassistant.const import (
CONF_SSL,
CONF_USERNAME,
)
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
from homeassistant.helpers.device_registry import DeviceInfo
@@ -227,7 +227,10 @@ class HikvisionBinarySensor(BinarySensorEntity):
# Register callback with pyhik
self._camera.add_update_callback(self._update_callback, self._callback_id)
@callback
def _update_callback(self, msg: str) -> None:
"""Update the sensor's state when callback is triggered."""
self.async_write_ha_state()
"""Update the sensor's state when callback is triggered.
This is called from pyhik's event stream thread, so we use
schedule_update_ha_state which is thread-safe.
"""
self.schedule_update_ha_state()

View File

@@ -66,7 +66,7 @@
"trigger": "mdi:air-humidifier-off"
},
"turned_on": {
"trigger": "mdi:air-humidifier-on"
"trigger": "mdi:air-humidifier"
}
}
}

View File

@@ -67,21 +67,21 @@ FLOW_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_total_water_use",
translation_key="daily_total_water_use",
device_class=SensorDeviceClass.VOLUME,
device_class=SensorDeviceClass.WATER,
suggested_display_precision=1,
value_fn=lambda sensor: _get_water_use(sensor).total_use,
),
HydrawiseSensorEntityDescription(
key="daily_active_water_use",
translation_key="daily_active_water_use",
device_class=SensorDeviceClass.VOLUME,
device_class=SensorDeviceClass.WATER,
suggested_display_precision=1,
value_fn=lambda sensor: _get_water_use(sensor).total_active_use,
),
HydrawiseSensorEntityDescription(
key="daily_inactive_water_use",
translation_key="daily_inactive_water_use",
device_class=SensorDeviceClass.VOLUME,
device_class=SensorDeviceClass.WATER,
suggested_display_precision=1,
value_fn=lambda sensor: _get_water_use(sensor).total_inactive_use,
),
@@ -91,7 +91,7 @@ FLOW_ZONE_SENSORS: tuple[SensorEntityDescription, ...] = (
HydrawiseSensorEntityDescription(
key="daily_active_water_use",
translation_key="daily_active_water_use",
device_class=SensorDeviceClass.VOLUME,
device_class=SensorDeviceClass.WATER,
suggested_display_precision=1,
value_fn=lambda sensor: float(
_get_water_use(sensor).active_use_by_zone_id.get(sensor.zone.id, 0.0)
@@ -204,7 +204,7 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity):
@property
def native_unit_of_measurement(self) -> str | None:
"""Return the unit_of_measurement of the sensor."""
if self.entity_description.device_class != SensorDeviceClass.VOLUME:
if self.entity_description.device_class != SensorDeviceClass.WATER:
return self.entity_description.native_unit_of_measurement
return (
UnitOfVolume.GALLONS
@@ -217,7 +217,7 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity):
"""Icon of the entity based on the value."""
if (
self.entity_description.key in FLOW_MEASUREMENT_KEYS
and self.entity_description.device_class == SensorDeviceClass.VOLUME
and self.entity_description.device_class == SensorDeviceClass.WATER
and round(self.state, 2) == 0.0
):
return "mdi:water-outline"

View File

@@ -13,7 +13,7 @@
"requirements": [
"xknx==3.13.0",
"xknxproject==3.8.2",
"knx-frontend==2025.12.28.215221"
"knx-frontend==2025.12.30.151231"
],
"single_config_entry": true
}

View File

@@ -154,6 +154,27 @@
}
},
"config_panel": {
"dashboard": {
"connection_flow": {
"description": "Reconfigure KNX connection or import a new KNX keyring file",
"title": "Connection settings"
},
"options_flow": {
"description": "Configure integration settings",
"title": "Integration options"
},
"project_upload": {
"description": "Import a KNX project file to help configure group addresses and datapoint types",
"title": "[%key:component::knx::config_panel::dialogs::project_upload::title%]"
}
},
"dialogs": {
"project_upload": {
"description": "Details such as group address names, datapoint types, devices and group objects are extracted from your project file. The ETS project file itself and its optional password are not stored.\n\n`.knxproj` files exported by ETS 4, 5 or 6 are supported.",
"file_upload_label": "ETS project file",
"title": "Import ETS project"
}
},
"dpt": {
"options": {
"5": "Generic 1-byte unsigned integer",
@@ -845,9 +866,9 @@
},
"mode": {
"description": "Select how the entity is displayed in Home Assistant.",
"label": "[%common::config_flow::data::mode%]",
"label": "[%key:common::config_flow::data::mode%]",
"options": {
"password": "[%common::config_flow::data::password%]",
"password": "[%key:common::config_flow::data::password%]",
"text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]"
}
}

View File

@@ -80,8 +80,6 @@ async def register_panel(hass: HomeAssistant) -> None:
hass=hass,
frontend_url_path=DOMAIN,
webcomponent_name=knx_panel.webcomponent_name,
sidebar_title=DOMAIN.upper(),
sidebar_icon="mdi:bus-electric",
module_url=f"{URL_BASE}/{knx_panel.entrypoint_js}",
embed_iframe=True,
require_admin=True,

View File

@@ -116,8 +116,12 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator[MetWeatherData]):
"""Fetch data from Met."""
try:
return await self.weather.fetch_data()
except Exception as err:
raise UpdateFailed(f"Update failed: {err}") from err
except CannotConnect as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
translation_placeholders={"error": str(err)},
) from err
def track_home(self) -> None:
"""Start tracking changes to HA home setting."""

View File

@@ -19,6 +19,11 @@
}
}
},
"exceptions": {
"update_failed": {
"message": "Update of data from the web site failed: {error}"
}
},
"options": {
"step": {
"init": {

View File

@@ -69,7 +69,7 @@ class RegistrationsView(HomeAssistantView):
webhook_id = secrets.token_hex()
if cloud.async_active_subscription(hass):
if cloud.async_active_subscription(hass) and cloud.async_is_connected(hass):
data[CONF_CLOUDHOOK_URL] = await async_create_cloud_hook(
hass, webhook_id, None
)

View File

@@ -4,6 +4,7 @@
"codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/netgear",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pynetgear"],
"requirements": ["pynetgear==0.10.10"],

View File

@@ -51,7 +51,6 @@ ALL_BINARY_SENSORS = [
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.NOTIFY,
Platform.SENSOR,
]
@@ -61,6 +60,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up Netgear LTE component."""
hass.data[DATA_HASS_CONFIG] = config
async_setup_services(hass)
return True
@@ -96,19 +96,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
async_setup_services(hass)
await discovery.async_load_platform(
hass,
Platform.NOTIFY,
DOMAIN,
{CONF_NAME: entry.title, "modem": modem},
{CONF_NAME: entry.title, "modem": modem, "entry": entry},
hass.data[DATA_HASS_CONFIG],
)
await hass.config_entries.async_forward_entry_setups(
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -118,7 +114,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry)
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if not hass.config_entries.async_loaded_entries(DOMAIN):
hass.data.pop(DOMAIN, None)
for service_name in hass.services.async_services()[DOMAIN]:
hass.services.async_remove(DOMAIN, service_name)
return unload_ok

View File

@@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import DEFAULT_HOST, DOMAIN, LOGGER, MANUFACTURER
from .const import DEFAULT_HOST, DOMAIN, MANUFACTURER
class NetgearLTEFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -72,9 +72,6 @@ class NetgearLTEFlowHandler(ConfigFlow, domain=DOMAIN):
info = await modem.information()
except Error as ex:
raise InputValidationError("cannot_connect") from ex
except Exception as ex:
LOGGER.exception("Unexpected exception")
raise InputValidationError("unknown") from ex
await modem.logout()
return info

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["eternalegypt"],
"requirements": ["eternalegypt==0.0.16"]
"requirements": ["eternalegypt==0.0.18"]
}

View File

@@ -38,6 +38,7 @@ class NetgearNotifyService(BaseNotificationService):
"""Initialize the service."""
self.config = config
self.modem: Modem = discovery_info["modem"]
discovery_info["entry"].async_on_unload(self.async_unregister_services)
async def async_send_message(self, message="", **kwargs):
"""Send a message to a user."""

View File

@@ -4,6 +4,7 @@ import voluptuous as vol
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from .const import (
@@ -14,7 +15,6 @@ from .const import (
AUTOCONNECT_MODES,
DOMAIN,
FAILOVER_MODES,
LOGGER,
)
from .coordinator import NetgearLTEConfigEntry
@@ -56,8 +56,11 @@ async def _service_handler(call: ServiceCall) -> None:
break
if not entry or not (modem := entry.runtime_data.modem).token:
LOGGER.error("%s: host %s unavailable", call.service, host)
return
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="config_entry_not_found",
translation_placeholders={"service": call.service},
)
if call.service == SERVICE_DELETE_SMS:
for sms_id in call.data[ATTR_SMS_ID]:

View File

@@ -71,6 +71,11 @@
}
}
},
"exceptions": {
"config_entry_not_found": {
"message": "Failed to perform action \"{service}\". Config entry for target not found"
}
},
"services": {
"connect_lte": {
"description": "Asks the modem to establish the LTE connection.",

View File

@@ -10,6 +10,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/nuheat",
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["nuheat"],
"requirements": ["nuheat==1.0.1"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@IsakNyberg"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/permobil",
"integration_type": "device",
"iot_class": "cloud_polling",
"requirements": ["mypermobil==0.1.8"]
}

View File

@@ -9,6 +9,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/pooldose",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["python-pooldose==0.8.1"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@haemishkyd"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/poolsense",
"integration_type": "device",
"iot_class": "cloud_polling",
"loggers": ["poolsense"],
"requirements": ["poolsense==0.0.8"]

View File

@@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PortainerConfigEntry
from .const import CONTAINER_STATE_RUNNING
from .coordinator import PortainerContainerData, PortainerCoordinator
from .entity import (
PortainerContainerEntity,
@@ -41,7 +42,7 @@ CONTAINER_SENSORS: tuple[PortainerContainerBinarySensorEntityDescription, ...] =
PortainerContainerBinarySensorEntityDescription(
key="status",
translation_key="status",
state_fn=lambda data: data.container.state == "running",
state_fn=lambda data: data.container.state == CONTAINER_STATE_RUNNING,
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
),

View File

@@ -4,3 +4,5 @@ DOMAIN = "portainer"
DEFAULT_NAME = "Portainer"
ENDPOINT_STATUS_DOWN = 2
CONTAINER_STATE_RUNNING = "running"

View File

@@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, ENDPOINT_STATUS_DOWN
from .const import CONTAINER_STATE_RUNNING, DOMAIN, ENDPOINT_STATUS_DOWN
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
@@ -158,10 +158,11 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
),
)
for container in containers
if container.state == CONTAINER_STATE_RUNNING
]
container_stats_gather = await asyncio.gather(
*[task for _, task in container_stats_task],
*[task for _, task in container_stats_task]
)
for (container, _), container_stats in zip(
container_stats_task, container_stats_gather, strict=False

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyportainer==1.0.17"]
"requirements": ["pyportainer==1.0.19"]
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@ktnrg45"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ps4",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyps4_2ndscreen"],
"requirements": ["pyps4-2ndscreen==1.3.1"]

View File

@@ -19,6 +19,7 @@ from homeassistant.components.light import (
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import color as color_util
from .entity import (
ReolinkChannelCoordinatorEntity,
@@ -157,16 +158,16 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity):
@property
def brightness(self) -> int | None:
"""Return the brightness of this light between 0.255."""
"""Return the brightness of this light between 1.255."""
assert self.entity_description.get_brightness_fn is not None
bright_pct = self.entity_description.get_brightness_fn(
self._host.api, self._channel
)
if bright_pct is None:
if not bright_pct:
return None
return round(255 * bright_pct / 100.0)
return color_util.value_to_brightness((1, 100), bright_pct)
@property
def color_temp_kelvin(self) -> int | None:
@@ -189,7 +190,7 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity):
if (
brightness := kwargs.get(ATTR_BRIGHTNESS)
) is not None and self.entity_description.set_brightness_fn is not None:
brightness_pct = int(brightness / 255.0 * 100)
brightness_pct = round(color_util.brightness_to_value((1, 100), brightness))
await self.entity_description.set_brightness_fn(
self._host.api, self._channel, brightness_pct
)

View File

@@ -79,6 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
map_scale=MAP_SCALE,
),
mqtt_session_unauthorized_hook=lambda: entry.async_start_reauth(hass),
prefer_cache=False,
)
except RoborockInvalidCredentials as err:
raise ConfigEntryAuthFailed(

View File

@@ -20,7 +20,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==3.21.0",
"python-roborock==4.2.0",
"vacuum-map-parser-roborock==0.1.4"
]
}

View File

@@ -325,8 +325,7 @@ class ShoppingData:
)
return self.items
@callback
def async_reorder(
async def async_reorder(
self, item_ids: list[str], context: Context | None = None
) -> None:
"""Reorder items."""
@@ -351,7 +350,7 @@ class ShoppingData:
)
new_items.append(value)
self.items = new_items
self.hass.async_add_executor_job(self.save)
await self.hass.async_add_executor_job(self.save)
self._async_notify()
self.hass.bus.async_fire(
EVENT_SHOPPING_LIST_UPDATED,
@@ -388,7 +387,7 @@ class ShoppingData:
) -> None:
"""Sort items by name."""
self.items = sorted(self.items, key=lambda item: item["name"], reverse=reverse) # type: ignore[arg-type,return-value]
self.hass.async_add_executor_job(self.save)
await self.hass.async_add_executor_job(self.save)
self._async_notify()
self.hass.bus.async_fire(
EVENT_SHOPPING_LIST_UPDATED,
@@ -591,7 +590,8 @@ async def websocket_handle_clear(
vol.Required("item_ids"): [str],
}
)
def websocket_handle_reorder(
@websocket_api.async_response
async def websocket_handle_reorder(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
@@ -599,7 +599,9 @@ def websocket_handle_reorder(
"""Handle reordering shopping_list items."""
msg_id = msg.pop("id")
try:
hass.data[DOMAIN].async_reorder(msg.pop("item_ids"), connection.context(msg))
await hass.data[DOMAIN].async_reorder(
msg.pop("item_ids"), connection.context(msg)
)
except NoMatchingShoppingListItem:
connection.send_error(
msg_id,

View File

@@ -958,6 +958,23 @@ class SonosSpeaker:
# as those "invisible" speakers will bypass the single speaker check
return
# Clear coordinator on speakers that are no longer in this group
old_members = set(self.sonos_group[1:])
new_members = set(sonos_group[1:])
removed_members = old_members - new_members
for removed_speaker in removed_members:
# Only clear if this speaker was coordinated by self and in the same group
if (
removed_speaker.coordinator == self
and removed_speaker.sonos_group is self.sonos_group
):
_LOGGER.debug(
"Zone %s Cleared coordinator [%s] (removed from group)",
removed_speaker.zone_name,
self.zone_name,
)
removed_speaker.clear_coordinator()
self.coordinator = None
self.sonos_group = sonos_group
self.sonos_group_entities = sonos_group_entities
@@ -990,6 +1007,19 @@ class SonosSpeaker:
return _async_handle_group_event(event)
@callback
def clear_coordinator(self) -> None:
"""Clear coordinator from speaker."""
self.coordinator = None
self.sonos_group = [self]
entity_registry = er.async_get(self.hass)
speaker_entity_id = cast(
str,
entity_registry.async_get_entity_id(MP_DOMAIN, DOMAIN, self.uid),
)
self.sonos_group_entities = [speaker_entity_id]
self.async_write_entity_states()
@soco_error()
def join(self, speakers: list[SonosSpeaker]) -> list[SonosSpeaker]:
"""Form a group with other players."""
@@ -1038,7 +1068,6 @@ class SonosSpeaker:
if self.sonos_group == [self]:
return
self.soco.unjoin()
self.coordinator = None
@staticmethod
async def unjoin_multi(

View File

@@ -20,6 +20,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, ENTRY_TITLE
from .coordinator import SwitchBotCoordinator
@@ -170,6 +171,7 @@ async def make_device_data(
"K20+ Pro",
"Robot Vacuum Cleaner K10+ Pro Combo",
"Robot Vacuum Cleaner S10",
"Robot Vacuum Cleaner S20",
"S20",
"Robot Vacuum Cleaner K11 Plus",
]:
@@ -309,7 +311,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
token = entry.data[CONF_API_TOKEN]
secret = entry.data[CONF_API_KEY]
api = SwitchBotAPI(token=token, secret=secret)
api = SwitchBotAPI(
token=token, secret=secret, session=async_get_clientsession(hass)
)
try:
devices = await api.list_devices()
except SwitchBotAuthenticationError as ex:

View File

@@ -245,6 +245,9 @@ def _async_make_entity(
return SwitchBotCloudVacuumV2(api, device, coordinator)
if device.device_type == "Robot Vacuum Cleaner K10+ Pro Combo":
return SwitchBotCloudVacuumK10PlusProCombo(api, device, coordinator)
if device.device_type in VacuumCleanerV3Commands.get_supported_devices():
if (
device.device_type in VacuumCleanerV3Commands.get_supported_devices()
or device.device_type == "Robot Vacuum Cleaner S20"
):
return SwitchBotCloudVacuumV3(api, device, coordinator)
return SwitchBotCloudVacuum(api, device, coordinator)

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import time
from typing import Any
from tesla_fleet_api.const import Scope
@@ -24,6 +25,9 @@ SCHEDULED = "scheduled"
PARALLEL_UPDATES = 0
# Show scheduled update as installing if within this many seconds
SCHEDULED_THRESHOLD_SECONDS = 120
async def async_setup_entry(
hass: HomeAssistant,
@@ -69,12 +73,9 @@ class TeslaFleetUpdateEntity(TeslaFleetVehicleEntity, UpdateEntity):
def _async_update_attrs(self) -> None:
"""Update the attributes of the entity."""
# Supported Features
if self.scoped and self._value in (
AVAILABLE,
SCHEDULED,
):
# Only allow install when an update has been fully downloaded
# Supported Features - only show install button if update is available
# but not already scheduled
if self.scoped and self._value == AVAILABLE:
self._attr_supported_features = (
UpdateEntityFeature.PROGRESS | UpdateEntityFeature.INSTALL
)
@@ -87,13 +88,9 @@ class TeslaFleetUpdateEntity(TeslaFleetVehicleEntity, UpdateEntity):
# Remove build from version
self._attr_installed_version = self._attr_installed_version.split(" ")[0]
# Latest Version
if self._value in (
AVAILABLE,
SCHEDULED,
INSTALLING,
DOWNLOADING,
WIFI_WAIT,
# Latest Version - hide update if scheduled far in the future
if self._value in (AVAILABLE, INSTALLING, DOWNLOADING, WIFI_WAIT) or (
self._value == SCHEDULED and self._is_scheduled_soon()
):
self._attr_latest_version = self.coordinator.data[
"vehicle_state_software_update_version"
@@ -101,14 +98,24 @@ class TeslaFleetUpdateEntity(TeslaFleetVehicleEntity, UpdateEntity):
else:
self._attr_latest_version = self._attr_installed_version
# In Progress
if self._value in (
SCHEDULED,
INSTALLING,
):
# In Progress - only show as installing if actually installing or
# scheduled to start within 2 minutes
if self._value == INSTALLING:
self._attr_in_progress = True
if install_perc := self.get("vehicle_state_software_update_install_perc"):
self._attr_update_percentage = install_perc
elif self._value == SCHEDULED and self._is_scheduled_soon():
self._attr_in_progress = True
self._attr_update_percentage = None
else:
self._attr_in_progress = False
self._attr_update_percentage = None
def _is_scheduled_soon(self) -> bool:
"""Check if a scheduled update is within the threshold to start."""
scheduled_time_ms = self.get("vehicle_state_software_update_scheduled_time_ms")
if scheduled_time_ms is None:
return False
# Convert milliseconds to seconds and compare to current time
scheduled_time_sec = scheduled_time_ms / 1000
return scheduled_time_sec - time.time() < SCHEDULED_THRESHOLD_SECONDS

View File

@@ -330,7 +330,9 @@ class TeslemetryOperationSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity):
self.async_write_ha_state()
class TeslemetryExportRuleSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity):
class TeslemetryExportRuleSelectEntity(
TeslemetryEnergyInfoEntity, SelectEntity, RestoreEntity
):
"""Select entity for export rules select entities."""
_attr_options: list[str] = [
@@ -348,9 +350,28 @@ class TeslemetryExportRuleSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity)
self.scoped = Scope.ENERGY_CMDS in scopes
super().__init__(data, "components_customer_preferred_export_rule")
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""
await super().async_added_to_hass()
# Restore state if it's not known
if self._attr_current_option is None:
if (state := await self.async_get_last_state()) is not None:
if state.state in self._attr_options:
self._attr_current_option = state.state
def _async_update_attrs(self) -> None:
"""Update the attributes of the entity."""
self._attr_current_option = self.get(self.key, EnergyExportMode.NEVER.value)
if value := self._value:
# Customer selected export option
self._attr_current_option = value
elif self.get("components_non_export_configured") is True:
# In VPP, Export is disabled
self._attr_current_option = EnergyExportMode.NEVER
elif self._attr_current_option == EnergyExportMode.NEVER:
# In VPP, Export is enabled, but our state shows it is disabled
self._attr_current_option = None # Unknown
# In VPP Mode, Export isn't disabled, so use last known state
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""

View File

@@ -1,20 +1,36 @@
"""Support for Tibber."""
from __future__ import annotations
from dataclasses import dataclass, field
import logging
import aiohttp
from aiohttp.client_exceptions import ClientError, ClientResponseError
import tibber
from tibber import data_api as tibber_data_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util, ssl as ssl_util
from .const import DATA_HASS_CONFIG, DOMAIN
from .const import (
AUTH_IMPLEMENTATION,
CONF_LEGACY_ACCESS_TOKEN,
DATA_HASS_CONFIG,
DOMAIN,
TibberConfigEntry,
)
from .coordinator import TibberDataAPICoordinator
from .services import async_setup_services
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
@@ -24,6 +40,33 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
@dataclass
class TibberRuntimeData:
"""Runtime data for Tibber API entries."""
tibber_connection: tibber.Tibber
session: OAuth2Session
data_api_coordinator: TibberDataAPICoordinator | None = field(default=None)
_client: tibber_data_api.TibberDataAPI | None = None
async def async_get_client(
self, hass: HomeAssistant
) -> tibber_data_api.TibberDataAPI:
"""Return an authenticated Tibber Data API client."""
await self.session.async_ensure_token_valid()
token = self.session.token
access_token = token.get(CONF_ACCESS_TOKEN)
if not access_token:
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
if self._client is None:
self._client = tibber_data_api.TibberDataAPI(
access_token,
websession=async_get_clientsession(hass),
)
self._client.set_access_token(access_token)
return self._client
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Tibber component."""
@@ -34,16 +77,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bool:
"""Set up a config entry."""
# Added in 2026.1 to migrate existing users to OAuth2 (Tibber Data API).
# Can be removed after 2026.7
if AUTH_IMPLEMENTATION not in entry.data:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="data_api_reauth_required",
)
tibber_connection = tibber.Tibber(
access_token=entry.data[CONF_ACCESS_TOKEN],
access_token=entry.data[CONF_LEGACY_ACCESS_TOKEN],
websession=async_get_clientsession(hass),
time_zone=dt_util.get_default_time_zone(),
ssl=ssl_util.get_default_context(),
)
hass.data[DOMAIN] = tibber_connection
async def _close(event: Event) -> None:
await tibber_connection.rt_disconnect()
@@ -52,7 +102,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
await tibber_connection.update_info()
except (
TimeoutError,
aiohttp.ClientError,
@@ -65,17 +114,45 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except tibber.FatalHttpExceptionError:
return False
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauthentication required"
) from err
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err
entry.runtime_data = TibberRuntimeData(
tibber_connection=tibber_connection,
session=session,
)
coordinator = TibberDataAPICoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data.data_api_coordinator = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, config_entry: TibberConfigEntry
) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
if unload_ok := await hass.config_entries.async_unload_platforms(
config_entry, PLATFORMS
)
if unload_ok:
tibber_connection = hass.data[DOMAIN]
await tibber_connection.rt_disconnect()
):
await config_entry.runtime_data.tibber_connection.rt_disconnect()
return unload_ok

View File

@@ -0,0 +1,15 @@
"""Application credentials platform for Tibber."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
AUTHORIZE_URL = "https://thewall.tibber.com/connect/authorize"
TOKEN_URL = "https://thewall.tibber.com/connect/token"
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server for Tibber Data API."""
return AuthorizationServer(
authorize_url=AUTHORIZE_URL,
token_url=TOKEN_URL,
)

View File

@@ -2,80 +2,164 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
import aiohttp
import tibber
from tibber import data_api as tibber_data_api
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import DOMAIN
from .const import CONF_LEGACY_ACCESS_TOKEN, DATA_API_DEFAULT_SCOPES, DOMAIN
DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
DATA_SCHEMA = vol.Schema({vol.Required(CONF_LEGACY_ACCESS_TOKEN): str})
ERR_TIMEOUT = "timeout"
ERR_CLIENT = "cannot_connect"
ERR_TOKEN = "invalid_access_token"
TOKEN_URL = "https://developer.tibber.com/settings/access-token"
_LOGGER = logging.getLogger(__name__)
class TibberConfigFlow(ConfigFlow, domain=DOMAIN):
class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle a config flow for Tibber integration."""
VERSION = 1
DOMAIN = DOMAIN
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._access_token: str | None = None
self._title = ""
@property
def logger(self) -> logging.Logger:
"""Return the logger."""
return _LOGGER
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data appended to the authorize URL."""
return {
**super().extra_authorize_data,
"scope": " ".join(DATA_API_DEFAULT_SCOPES),
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
self._async_abort_entries_match()
if user_input is not None:
access_token = user_input[CONF_ACCESS_TOKEN].replace(" ", "")
tibber_connection = tibber.Tibber(
access_token=access_token,
websession=async_get_clientsession(self.hass),
if user_input is None:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
)
errors = {}
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors={},
)
try:
await tibber_connection.update_info()
except TimeoutError:
errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT
except tibber.InvalidLoginError:
errors[CONF_ACCESS_TOKEN] = ERR_TOKEN
except (
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
tibber.FatalHttpExceptionError,
):
errors[CONF_ACCESS_TOKEN] = ERR_CLIENT
self._access_token = user_input[CONF_LEGACY_ACCESS_TOKEN].replace(" ", "")
tibber_connection = tibber.Tibber(
access_token=self._access_token,
websession=async_get_clientsession(self.hass),
)
self._title = tibber_connection.name or "Tibber"
if errors:
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
errors: dict[str, str] = {}
try:
await tibber_connection.update_info()
except TimeoutError:
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TIMEOUT
except tibber.InvalidLoginError:
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_TOKEN
except (
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
tibber.FatalHttpExceptionError,
):
errors[CONF_LEGACY_ACCESS_TOKEN] = ERR_CLIENT
unique_id = tibber_connection.user_id
await self.async_set_unique_id(unique_id)
if errors:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_LEGACY_ACCESS_TOKEN: self._access_token or ""}
)
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
await self.async_set_unique_id(tibber_connection.user_id)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"title": reauth_entry.title},
)
else:
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=tibber_connection.name,
data={CONF_ACCESS_TOKEN: access_token},
return await self.async_step_pick_implementation()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle a reauth flow."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
self._title = reauth_entry.title
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication by reusing the user step."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_LEGACY_ACCESS_TOKEN)
self._title = reauth_entry.title
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
)
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Finalize the OAuth flow and create the config entry."""
if self._access_token is None:
return self.async_abort(reason="missing_configuration")
data[CONF_LEGACY_ACCESS_TOKEN] = self._access_token
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
data_api_client = tibber_data_api.TibberDataAPI(
access_token,
websession=async_get_clientsession(self.hass),
)
try:
await data_api_client.get_userinfo()
except (aiohttp.ClientError, TimeoutError):
return self.async_abort(reason="cannot_connect")
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
reauth_entry,
data=data,
title=self._title,
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors={},
)
return self.async_create_entry(title=self._title, data=data)

View File

@@ -1,5 +1,34 @@
"""Constants for Tibber integration."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN
if TYPE_CHECKING:
from . import TibberRuntimeData
type TibberConfigEntry = ConfigEntry[TibberRuntimeData]
CONF_LEGACY_ACCESS_TOKEN = CONF_ACCESS_TOKEN
AUTH_IMPLEMENTATION = "auth_implementation"
DATA_HASS_CONFIG = "tibber_hass_config"
DOMAIN = "tibber"
MANUFACTURER = "Tibber"
DATA_API_DEFAULT_SCOPES = [
"openid",
"profile",
"email",
"offline_access",
"data-api-user-read",
"data-api-chargers-read",
"data-api-energy-systems-read",
"data-api-homes-read",
"data-api-thermostats-read",
"data-api-vehicles-read",
"data-api-inverters-read",
]

View File

@@ -4,9 +4,11 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import cast
from typing import TYPE_CHECKING, cast
from aiohttp.client_exceptions import ClientError
import tibber
from tibber.data_api import TibberDataAPI, TibberDevice
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
@@ -19,15 +21,18 @@ from homeassistant.components.recorder.statistics import (
get_last_statistics,
statistics_during_period,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
from .const import DOMAIN
if TYPE_CHECKING:
from .const import TibberConfigEntry
FIVE_YEARS = 5 * 365 * 24
_LOGGER = logging.getLogger(__name__)
@@ -36,12 +41,12 @@ _LOGGER = logging.getLogger(__name__)
class TibberDataCoordinator(DataUpdateCoordinator[None]):
"""Handle Tibber data and insert statistics."""
config_entry: ConfigEntry
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: TibberConfigEntry,
tibber_connection: tibber.Tibber,
) -> None:
"""Initialize the data handler."""
@@ -187,3 +192,64 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
unit_of_measurement=unit,
)
async_add_external_statistics(self.hass, metadata, statistics)
class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
"""Fetch and cache Tibber Data API device capabilities."""
config_entry: TibberConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: TibberConfigEntry,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN} Data API",
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self._runtime_data = entry.runtime_data
self.sensors_by_device: dict[str, dict[str, tibber.data_api.Sensor]] = {}
def _build_sensor_lookup(self, devices: dict[str, TibberDevice]) -> None:
"""Build sensor lookup dict for efficient access."""
self.sensors_by_device = {
device_id: {sensor.id: sensor for sensor in device.sensors}
for device_id, device in devices.items()
}
def get_sensor(
self, device_id: str, sensor_id: str
) -> tibber.data_api.Sensor | None:
"""Get a sensor by device and sensor ID."""
if device_sensors := self.sensors_by_device.get(device_id):
return device_sensors.get(sensor_id)
return None
async def _async_get_client(self) -> TibberDataAPI:
"""Get the Tibber Data API client with error handling."""
try:
return await self._runtime_data.async_get_client(self.hass)
except ConfigEntryAuthFailed:
raise
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
raise UpdateFailed(
f"Unable to create Tibber Data API client: {err}"
) from err
async def _async_setup(self) -> None:
"""Initial load of Tibber Data API devices."""
client = await self._async_get_client()
devices = await client.get_all_devices()
self._build_sensor_lookup(devices)
async def _async_update_data(self) -> dict[str, TibberDevice]:
"""Fetch the latest device capabilities from the Tibber Data API."""
client = await self._async_get_client()
devices: dict[str, TibberDevice] = await client.update_devices()
self._build_sensor_lookup(devices)
return devices

View File

@@ -4,21 +4,18 @@ from __future__ import annotations
from typing import Any
import tibber
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from .const import DOMAIN
from .const import TibberConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
hass: HomeAssistant, config_entry: TibberConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
tibber_connection: tibber.Tibber = hass.data[DOMAIN]
return {
runtime = config_entry.runtime_data
result: dict[str, Any] = {
"homes": [
{
"last_data_timestamp": home.last_data_timestamp,
@@ -27,6 +24,24 @@ async def async_get_config_entry_diagnostics(
"last_cons_data_timestamp": home.last_cons_data_timestamp,
"country": home.country,
}
for home in tibber_connection.get_homes(only_active=False)
for home in runtime.tibber_connection.get_homes(only_active=False)
]
}
devices = (
runtime.data_api_coordinator.data
if runtime.data_api_coordinator is not None
else {}
) or {}
result["devices"] = [
{
"id": device.id,
"name": device.name,
"brand": device.brand,
"model": device.model,
}
for device in devices.values()
]
return result

View File

@@ -3,9 +3,9 @@
"name": "Tibber",
"codeowners": ["@danielhiversen"],
"config_flow": true,
"dependencies": ["recorder"],
"dependencies": ["application_credentials", "recorder"],
"documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.32.2"]
"requirements": ["pyTibber==0.33.1"]
}

View File

@@ -2,28 +2,25 @@
from __future__ import annotations
from tibber import Tibber
from homeassistant.components.notify import (
ATTR_TITLE_DEFAULT,
NotifyEntity,
NotifyEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .const import DOMAIN, TibberConfigEntry
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber notification entity."""
async_add_entities([TibberNotificationEntity(entry.entry_id)])
async_add_entities([TibberNotificationEntity(entry)])
class TibberNotificationEntity(NotifyEntity):
@@ -33,13 +30,14 @@ class TibberNotificationEntity(NotifyEntity):
_attr_name = DOMAIN
_attr_icon = "mdi:message-flash"
def __init__(self, unique_id: str) -> None:
def __init__(self, entry: TibberConfigEntry) -> None:
"""Initialize Tibber notify entity."""
self._attr_unique_id = unique_id
self._attr_unique_id = entry.entry_id
self._entry = entry
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to Tibber devices."""
tibber_connection: Tibber = self.hass.data[DOMAIN]
tibber_connection = self._entry.runtime_data.tibber_connection
try:
await tibber_connection.send_notification(
title or ATTR_TITLE_DEFAULT, message

View File

@@ -10,7 +10,8 @@ from random import randrange
from typing import Any
import aiohttp
import tibber
from tibber import FatalHttpExceptionError, RetryableHttpExceptionError, TibberHome
from tibber.data_api import TibberDevice
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -27,6 +28,7 @@ from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfLength,
UnitOfPower,
)
from homeassistant.core import Event, HomeAssistant, callback
@@ -41,8 +43,8 @@ from homeassistant.helpers.update_coordinator import (
)
from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN, MANUFACTURER
from .coordinator import TibberDataCoordinator
from .const import DOMAIN, MANUFACTURER, TibberConfigEntry
from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -260,14 +262,65 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
)
DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="storage.stateOfCharge",
translation_key="storage_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="storage.targetStateOfCharge",
translation_key="storage_target_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="range.remaining",
translation_key="range_remaining",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
SensorEntityDescription(
key="charging.current.max",
translation_key="charging_current_max",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="charging.current.offlineFallback",
translation_key="charging_current_offline_fallback",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = hass.data[DOMAIN]
_setup_data_api_sensors(entry, async_add_entities)
await _async_setup_graphql_sensors(hass, entry, async_add_entities)
async def _async_setup_graphql_sensors(
hass: HomeAssistant,
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = entry.runtime_data.tibber_connection
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
@@ -280,7 +333,11 @@ async def async_setup_entry(
except TimeoutError as err:
_LOGGER.error("Timeout connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err:
except (
RetryableHttpExceptionError,
FatalHttpExceptionError,
aiohttp.ClientError,
) as err:
_LOGGER.error("Error connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
@@ -325,7 +382,67 @@ async def async_setup_entry(
device_entry.id, new_identifiers={(DOMAIN, home.home_id)}
)
async_add_entities(entities, True)
async_add_entities(entities)
def _setup_data_api_sensors(
entry: TibberConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors backed by the Tibber Data API."""
coordinator = entry.runtime_data.data_api_coordinator
if coordinator is None:
return
entities: list[TibberDataAPISensor] = []
api_sensors = {sensor.key: sensor for sensor in DATA_API_SENSORS}
for device in coordinator.data.values():
for sensor in device.sensors:
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
if description is None:
_LOGGER.debug(
"Sensor %s not found in DATA_API_SENSORS, skipping", sensor
)
continue
entities.append(TibberDataAPISensor(coordinator, device, description))
async_add_entities(entities)
class TibberDataAPISensor(CoordinatorEntity[TibberDataAPICoordinator], SensorEntity):
"""Representation of a Tibber Data API capability sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: TibberDataAPICoordinator,
device: TibberDevice,
entity_description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._device_id: str = device.id
self.entity_description = entity_description
self._attr_translation_key = entity_description.translation_key
self._attr_unique_id = f"{device.external_id}_{self.entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.external_id)},
name=device.name,
manufacturer=device.brand,
model=device.model,
)
@property
def native_value(self) -> StateType:
"""Return the value reported by the device."""
sensors = self.coordinator.sensors_by_device.get(self._device_id, {})
sensor = sensors.get(self.entity_description.key)
return sensor.value if sensor else None
class TibberSensor(SensorEntity):
@@ -333,9 +450,7 @@ class TibberSensor(SensorEntity):
_attr_has_entity_name = True
def __init__(
self, *args: Any, tibber_home: tibber.TibberHome, **kwargs: Any
) -> None:
def __init__(self, *args: Any, tibber_home: TibberHome, **kwargs: Any) -> None:
"""Initialize the sensor."""
super().__init__(*args, **kwargs)
self._tibber_home = tibber_home
@@ -366,7 +481,7 @@ class TibberSensorElPrice(TibberSensor):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_translation_key = "electricity_price"
def __init__(self, tibber_home: tibber.TibberHome) -> None:
def __init__(self, tibber_home: TibberHome) -> None:
"""Initialize the sensor."""
super().__init__(tibber_home=tibber_home)
self._last_updated: datetime.datetime | None = None
@@ -443,7 +558,7 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]):
def __init__(
self,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
coordinator: TibberDataCoordinator,
entity_description: SensorEntityDescription,
) -> None:
@@ -470,7 +585,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"])
def __init__(
self,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
description: SensorEntityDescription,
initial_state: float,
coordinator: TibberRtDataCoordinator,
@@ -532,7 +647,7 @@ class TibberRtEntityCreator:
def __init__(
self,
async_add_entities: AddConfigEntryEntitiesCallback,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
entity_registry: er.EntityRegistry,
) -> None:
"""Initialize the data handler."""
@@ -618,7 +733,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en
hass: HomeAssistant,
config_entry: ConfigEntry,
add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None],
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
) -> None:
"""Initialize the data handler."""
self._add_sensor_callback = add_sensor_callback

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import datetime as dt
from datetime import datetime
from typing import Any, Final
from typing import TYPE_CHECKING, Any, Final
import voluptuous as vol
@@ -20,6 +20,9 @@ from homeassistant.util import dt as dt_util
from .const import DOMAIN
if TYPE_CHECKING:
from .const import TibberConfigEntry
PRICE_SERVICE_NAME = "get_prices"
ATTR_START: Final = "start"
ATTR_END: Final = "end"
@@ -33,7 +36,13 @@ SERVICE_SCHEMA: Final = vol.Schema(
async def __get_prices(call: ServiceCall) -> ServiceResponse:
tibber_connection = call.hass.data[DOMAIN]
entries: list[TibberConfigEntry] = call.hass.config_entries.async_entries(DOMAIN)
if not entries:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_config_entry",
)
tibber_connection = entries[0].runtime_data.tibber_connection
start = __get_date(call.data.get(ATTR_START), "start")
end = __get_date(call.data.get(ATTR_END), "end")
@@ -57,7 +66,7 @@ async def __get_prices(call: ServiceCall) -> ServiceResponse:
selected_data = [
price
for price in price_data
if start <= dt.datetime.fromisoformat(price["start_time"]) < end
if start <= dt.datetime.fromisoformat(str(price["start_time"])) < end
]
tibber_prices[home_nickname] = selected_data

View File

@@ -1,7 +1,11 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "The connected account does not match {title}. Sign in with the same Tibber account and try again."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +13,10 @@
"timeout": "[%key:common::config_flow::error::timeout_connect%]"
},
"step": {
"reauth_confirm": {
"description": "Reconnect your Tibber account to refresh access.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
@@ -40,6 +48,12 @@
"average_power": {
"name": "Average power"
},
"charging_current_max": {
"name": "Maximum allowed charge current"
},
"charging_current_offline_fallback": {
"name": "Fallback current if charger goes offline"
},
"current_l1": {
"name": "Current L1"
},
@@ -88,9 +102,18 @@
"power_production": {
"name": "Power production"
},
"range_remaining": {
"name": "Estimated remaining driving range"
},
"signal_strength": {
"name": "Signal strength"
},
"storage_state_of_charge": {
"name": "State of charge"
},
"storage_target_state_of_charge": {
"name": "Target state of charge"
},
"voltage_phase1": {
"name": "Voltage phase1"
},
@@ -103,9 +126,18 @@
}
},
"exceptions": {
"data_api_reauth_required": {
"message": "Reconnect Tibber so Home Assistant can enable the new Tibber Data API features."
},
"invalid_date": {
"message": "Invalid datetime provided {date}"
},
"no_config_entry": {
"message": "No Tibber integration configured"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"send_message_timeout": {
"message": "Timeout sending message with Tibber"
}

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/totalconnect",
"iot_class": "cloud_polling",
"loggers": ["total_connect_client"],
"requirements": ["total-connect-client==2025.5"]
"requirements": ["total-connect-client==2025.12.2"]
}

View File

@@ -39,6 +39,7 @@ from .const import (
SERVER_UNAVAILABLE,
SWITCH_KEY_MAP,
TRACKER_HARDWARE_STATUS_UPDATED,
TRACKER_HEALTH_OVERVIEW_UPDATED,
TRACKER_POSITION_UPDATED,
TRACKER_SWITCH_STATUS_UPDATED,
TRACKER_WELLNESS_STATUS_UPDATED,
@@ -64,6 +65,7 @@ class Trackables:
tracker_details: dict
hw_info: dict
pos_report: dict
health_overview: dict
@dataclass(slots=True)
@@ -114,6 +116,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) ->
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Send initial health overview data to sensors after platforms are set up
for item in filtered_trackables:
if item.health_overview:
tractive.send_health_overview_update(item.health_overview)
async def cancel_listen_task(_: Event) -> None:
await tractive.unsubscribe()
@@ -144,9 +151,13 @@ async def _generate_trackables(
return None
tracker = client.tracker(trackable["device_id"])
trackable_pet = client.trackable_object(trackable["_id"])
tracker_details, hw_info, pos_report = await asyncio.gather(
tracker.details(), tracker.hw_info(), tracker.pos_report()
tracker_details, hw_info, pos_report, health_overview = await asyncio.gather(
tracker.details(),
tracker.hw_info(),
tracker.pos_report(),
trackable_pet.health_overview(),
)
if not tracker_details.get("_id"):
@@ -154,7 +165,9 @@ async def _generate_trackables(
f"Tractive API returns incomplete data for tracker {trackable['device_id']}",
)
return Trackables(tracker, trackable, tracker_details, hw_info, pos_report)
return Trackables(
tracker, trackable, tracker_details, hw_info, pos_report, health_overview
)
async def async_unload_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> bool:
@@ -226,6 +239,9 @@ class TractiveClient:
if server_was_unavailable:
_LOGGER.debug("Tractive is back online")
server_was_unavailable = False
if event["message"] == "health_overview":
self.send_health_overview_update(event)
continue
if event["message"] == "wellness_overview":
self._send_wellness_update(event)
continue
@@ -316,6 +332,27 @@ class TractiveClient:
TRACKER_WELLNESS_STATUS_UPDATED, event["pet_id"], payload
)
def send_health_overview_update(self, event: dict[str, Any]) -> None:
"""Handle health_overview events from Tractive API."""
# The health_overview response can be at root level or wrapped in 'content'
# Handle both structures for compatibility
data = event.get("content", event)
activity = data.get("activity", {})
sleep = data.get("sleep", {})
payload = {
ATTR_DAILY_GOAL: activity.get("minutesGoal"),
ATTR_MINUTES_ACTIVE: activity.get("minutesActive"),
ATTR_MINUTES_DAY_SLEEP: sleep.get("minutesDaySleep"),
ATTR_MINUTES_NIGHT_SLEEP: sleep.get("minutesNightSleep"),
# Calm minutes can be used as rest indicator
ATTR_MINUTES_REST: sleep.get("minutesCalm"),
}
self._dispatch_tracker_event(
TRACKER_HEALTH_OVERVIEW_UPDATED, data["petId"], payload
)
def _send_position_update(self, event: dict[str, Any]) -> None:
payload = {
"latitude": event["position"]["latlong"][0],

View File

@@ -28,6 +28,7 @@ TRACKER_HARDWARE_STATUS_UPDATED = f"{DOMAIN}_tracker_hardware_status_updated"
TRACKER_POSITION_UPDATED = f"{DOMAIN}_tracker_position_updated"
TRACKER_SWITCH_STATUS_UPDATED = f"{DOMAIN}_tracker_switch_updated"
TRACKER_WELLNESS_STATUS_UPDATED = f"{DOMAIN}_tracker_wellness_updated"
TRACKER_HEALTH_OVERVIEW_UPDATED = f"{DOMAIN}_tracker_health_overview_updated"
SERVER_UNAVAILABLE = f"{DOMAIN}_server_unavailable"

View File

@@ -35,6 +35,7 @@ from .const import (
ATTR_SLEEP_LABEL,
ATTR_TRACKER_STATE,
TRACKER_HARDWARE_STATUS_UPDATED,
TRACKER_HEALTH_OVERVIEW_UPDATED,
TRACKER_WELLNESS_STATUS_UPDATED,
)
from .entity import TractiveEntity
@@ -115,14 +116,14 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = (
key=ATTR_MINUTES_ACTIVE,
translation_key="activity_time",
native_unit_of_measurement=UnitOfTime.MINUTES,
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED,
state_class=SensorStateClass.TOTAL,
),
TractiveSensorEntityDescription(
key=ATTR_MINUTES_REST,
translation_key="rest_time",
native_unit_of_measurement=UnitOfTime.MINUTES,
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED,
state_class=SensorStateClass.TOTAL,
),
TractiveSensorEntityDescription(
@@ -136,20 +137,20 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = (
key=ATTR_DAILY_GOAL,
translation_key="daily_goal",
native_unit_of_measurement=UnitOfTime.MINUTES,
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED,
),
TractiveSensorEntityDescription(
key=ATTR_MINUTES_DAY_SLEEP,
translation_key="minutes_day_sleep",
native_unit_of_measurement=UnitOfTime.MINUTES,
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED,
state_class=SensorStateClass.TOTAL,
),
TractiveSensorEntityDescription(
key=ATTR_MINUTES_NIGHT_SLEEP,
translation_key="minutes_night_sleep",
native_unit_of_measurement=UnitOfTime.MINUTES,
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED,
state_class=SensorStateClass.TOTAL,
),
TractiveSensorEntityDescription(

View File

@@ -186,14 +186,14 @@ class _ColorDataWrapper(DPCodeJsonWrapper):
)
def _convert_value_to_raw_value(
self, device: CustomerDevice, value: tuple[tuple[float, float], float]
self, device: CustomerDevice, value: tuple[float, float, float]
) -> Any:
"""Convert a Home Assistant color/brightness pair back to a raw device value."""
color, brightness = value
"""Convert a Home Assistant tuple (H, S, V) back to a raw device value."""
hue, saturation, brightness = value
return json.dumps(
{
"h": round(self.h_type.remap_value_from(color[0])),
"s": round(self.s_type.remap_value_from(color[1])),
"h": round(self.h_type.remap_value_from(hue)),
"s": round(self.s_type.remap_value_from(saturation)),
"v": round(self.v_type.remap_value_from(brightness)),
}
)

View File

@@ -105,7 +105,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bo
try:
await controller.connect()
except VelbusConnectionFailed as error:
raise ConfigEntryNotReady("Cannot connect to Velbus") from error
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="connection_failed",
) from error
task = hass.async_create_task(velbus_scan_task(controller, hass, entry.entry_id))
entry.runtime_data = VelbusData(controller=controller, scan_task=task)

View File

@@ -65,7 +65,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity):
)
@property
def current_temperature(self) -> int | None:
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._channel.get_state()

View File

@@ -66,6 +66,7 @@ class VelbusEntity(Entity):
self._channel.remove_on_status_update(self._on_update)
async def _on_update(self) -> None:
"""Handle status updates from the channel."""
self.async_write_ha_state()
@@ -80,8 +81,13 @@ def api_call[_T: VelbusEntity, **_P](
try:
await func(self, *args, **kwargs)
except OSError as exc:
entity_name = self.name if isinstance(self.name, str) else "Unknown"
raise HomeAssistantError(
f"Could not execute {func.__name__} service for {self.name}"
translation_domain=DOMAIN,
translation_key="api_call_failed",
translation_placeholders={
"entity": entity_name,
},
) from exc
return cmd_wrapper

View File

@@ -14,7 +14,7 @@
"velbus-protocol"
],
"quality_scale": "bronze",
"requirements": ["velbus-aio==2025.12.0"],
"requirements": ["velbus-aio==2026.1.1"],
"usb": [
{
"pid": "0B1B",

View File

@@ -56,7 +56,7 @@ rules:
entity-device-class: todo
entity-disabled-by-default: done
entity-translations: todo
exception-translations: todo
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues:

View File

@@ -57,8 +57,14 @@
}
},
"exceptions": {
"api_call_failed": {
"message": "Action execute for {entity} failed."
},
"clear_cache_failed": {
"message": "Could not cleat the Velbus cache: {error}"
"message": "Could not clear the Velbus cache: {error}"
},
"connection_failed": {
"message": "Could not connect to Velbus."
},
"integration_not_found": {
"message": "Integration \"{target}\" not found in registry."

View File

@@ -13,5 +13,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyvlx"],
"requirements": ["pyvlx==0.2.26"]
"requirements": ["pyvlx==0.2.27"]
}

View File

@@ -5,9 +5,7 @@ rules:
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow:
status: todo
comment: Missing data descriptions
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done

View File

@@ -15,6 +15,10 @@
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::email%]"
},
"data_description": {
"password": "[%key:component::vesync::config::step::user::data_description::password%]",
"username": "[%key:component::vesync::config::step::user::data_description::username%]"
},
"description": "The VeSync integration needs to re-authenticate your account",
"title": "[%key:common::config_flow::title::reauth%]"
},
@@ -23,6 +27,11 @@
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::email%]"
},
"data_description": {
"password": "Password associated with your VeSync account",
"username": "Email address associated with your VeSync account"
},
"description": "Enter the account used in the vesync app. 2FA is not supported and must be disabled.",
"title": "Enter username and password"
}
}

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["aiowebdav2"],
"quality_scale": "bronze",
"requirements": ["aiowebdav2==0.4.6"]
"requirements": ["aiowebdav2==0.5.0"]
}

View File

@@ -209,7 +209,7 @@ class XboxSource(MediaSource):
if images is not None:
try:
return PlayMedia(
images[int(identifier.media_id)].url,
to_https(images[int(identifier.media_id)].url),
MIME_TYPE_MAP[ATTR_SCREENSHOTS],
)
except (ValueError, IndexError):

View File

@@ -25,5 +25,5 @@
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["xiaomi-ble==1.2.0"]
"requirements": ["xiaomi-ble==1.4.1"]
}

View File

@@ -17,7 +17,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2026
MINOR_VERSION: Final = 1
PATCH_VERSION: Final = "0.dev0"
PATCH_VERSION: Final = "0b3"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)

View File

@@ -39,6 +39,7 @@ APPLICATION_CREDENTIALS = [
"spotify",
"tesla_fleet",
"teslemetry",
"tibber",
"twitch",
"volvo",
"watts",

View File

@@ -4584,7 +4584,7 @@
},
"nuheat": {
"name": "NuHeat",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_polling"
},
@@ -5014,7 +5014,7 @@
},
"permobil": {
"name": "MyPermobil",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_polling"
},
@@ -5152,13 +5152,13 @@
},
"pooldose": {
"name": "SEKO PoolDose",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_polling"
},
"poolsense": {
"name": "PoolSense",
"integration_type": "hub",
"integration_type": "device",
"config_flow": true,
"iot_class": "cloud_polling"
},

View File

@@ -6,7 +6,7 @@ aiodns==3.6.1
aiohasupervisor==0.3.3
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
aiohttp==3.13.2
aiohttp==3.13.3
aiohttp_cors==0.8.1
aiousbwatcher==1.1.1
aiozoneinfo==0.2.3
@@ -40,7 +40,7 @@ hass-nabucasa==1.7.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-frontend==20251229.0
home-assistant-intents==2025.12.2
home-assistant-intents==2026.1.1
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
@@ -70,9 +70,9 @@ typing-extensions>=4.15.0,<5.0
ulid-transform==1.5.2
urllib3>=2.0
uv==0.9.17
voluptuous-openapi==0.2.0
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
voluptuous==0.16.0
webrtc-models==0.3.0
yarl==1.22.0
zeroconf==0.148.0
@@ -129,6 +129,9 @@ multidict>=6.0.2
# Version 2.0 added typing, prevent accidental fallbacks
backoff>=2.0
# Brotli 1.2.0 fixes CVE and is required for aiohttp 3.13.3 compatibility
Brotli>=1.2.0
# ensure pydantic version does not float since it might have breaking changes
pydantic==2.12.2

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.1.0.dev0"
version = "2026.1.0b3"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
@@ -29,7 +29,7 @@ dependencies = [
# change behavior based on presence of supervisor. Deprecated with #127228
# Lib can be removed with 2025.11
"aiohasupervisor==0.3.3",
"aiohttp==3.13.2",
"aiohttp==3.13.3",
"aiohttp_cors==0.8.1",
"aiohttp-fast-zlib==0.3.0",
"aiohttp-asyncmdnsresolver==0.1.1",
@@ -76,9 +76,9 @@ dependencies = [
"ulid-transform==1.5.2",
"urllib3>=2.0",
"uv==0.9.17",
"voluptuous==0.15.2",
"voluptuous==0.16.0",
"voluptuous-serialize==2.7.0",
"voluptuous-openapi==0.2.0",
"voluptuous-openapi==0.3.0",
"yarl==1.22.0",
"webrtc-models==0.3.0",
"zeroconf==0.148.0",

8
requirements.txt generated
View File

@@ -7,7 +7,7 @@ aiodns==3.6.1
aiohasupervisor==0.3.3
aiohttp-asyncmdnsresolver==0.1.1
aiohttp-fast-zlib==0.3.0
aiohttp==3.13.2
aiohttp==3.13.3
aiohttp_cors==0.8.1
aiozoneinfo==0.2.3
annotatedyaml==1.0.2
@@ -27,7 +27,7 @@ ha-ffmpeg==3.2.2
hass-nabucasa==1.7.0
hassil==3.5.0
home-assistant-bluetooth==1.13.1
home-assistant-intents==2025.12.2
home-assistant-intents==2026.1.1
httpx==0.28.1
ifaddr==0.2.0
Jinja2==3.1.6
@@ -54,9 +54,9 @@ typing-extensions>=4.15.0,<5.0
ulid-transform==1.5.2
urllib3>=2.0
uv==0.9.17
voluptuous-openapi==0.2.0
voluptuous-openapi==0.3.0
voluptuous-serialize==2.7.0
voluptuous==0.15.2
voluptuous==0.16.0
webrtc-models==0.3.0
yarl==1.22.0
zeroconf==0.148.0

28
requirements_all.txt generated
View File

@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.4
# homeassistant.components.alexa_devices
aioamazondevices==10.0.0
aioamazondevices==11.0.2
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -252,7 +252,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==43.9.0
aioesphomeapi==43.9.1
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -441,7 +441,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webdav
aiowebdav2==0.4.6
aiowebdav2==0.5.0
# homeassistant.components.webostv
aiowebostv==0.7.5
@@ -929,7 +929,7 @@ esphome-dashboard-api==1.3.0
essent-dynamic-pricing==0.2.7
# homeassistant.components.netgear_lte
eternalegypt==0.0.16
eternalegypt==0.0.18
# homeassistant.components.eufylife_ble
eufylife-ble-client==0.1.8
@@ -1216,7 +1216,7 @@ holidays==0.84
home-assistant-frontend==20251229.0
# homeassistant.components.conversation
home-assistant-intents==2025.12.2
home-assistant-intents==2026.1.1
# homeassistant.components.gentex_homelink
homelink-integration-api==0.0.1
@@ -1349,7 +1349,7 @@ kiwiki-client==0.1.1
knocki==0.4.2
# homeassistant.components.knx
knx-frontend==2025.12.28.215221
knx-frontend==2025.12.30.151231
# homeassistant.components.konnected
konnected==1.2.0
@@ -1867,7 +1867,7 @@ pyRFXtrx==0.31.1
pySDCP==1
# homeassistant.components.tibber
pyTibber==0.32.2
pyTibber==0.33.1
# homeassistant.components.dlink
pyW215==0.8.0
@@ -1891,7 +1891,7 @@ pyaftership==21.11.0
pyairnow==1.3.1
# homeassistant.components.airobot
pyairobotrest==0.1.0
pyairobotrest==0.2.0
# homeassistant.components.airvisual
# homeassistant.components.airvisual_pro
@@ -2318,7 +2318,7 @@ pyplaato==0.0.19
pypoint==3.0.0
# homeassistant.components.portainer
pyportainer==1.0.17
pyportainer==1.0.19
# homeassistant.components.probe_plus
pyprobeplus==1.1.2
@@ -2581,7 +2581,7 @@ python-rabbitair==0.0.8
python-ripple-api==0.0.3
# homeassistant.components.roborock
python-roborock==3.21.0
python-roborock==4.2.0
# homeassistant.components.smarttub
python-smarttub==0.0.46
@@ -2657,7 +2657,7 @@ pyvesync==3.3.3
pyvizio==0.1.61
# homeassistant.components.velux
pyvlx==0.2.26
pyvlx==0.2.27
# homeassistant.components.volumio
pyvolumio==0.1.5
@@ -3039,7 +3039,7 @@ tololib==1.2.2
toonapi==0.3.0
# homeassistant.components.totalconnect
total-connect-client==2025.5
total-connect-client==2025.12.2
# homeassistant.components.tplink_lte
tp-connected==0.0.4
@@ -3122,7 +3122,7 @@ vegehub==0.1.26
vehicle==2.2.2
# homeassistant.components.velbus
velbus-aio==2025.12.0
velbus-aio==2026.1.1
# homeassistant.components.venstar
venstarcolortouch==0.21
@@ -3213,7 +3213,7 @@ wsdot==0.0.1
wyoming==1.7.2
# homeassistant.components.xiaomi_ble
xiaomi-ble==1.2.0
xiaomi-ble==1.4.1
# homeassistant.components.knx
xknx==3.13.0

View File

@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.4
# homeassistant.components.alexa_devices
aioamazondevices==10.0.0
aioamazondevices==11.0.2
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -243,7 +243,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==43.9.0
aioesphomeapi==43.9.1
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -426,7 +426,7 @@ aiowaqi==3.1.0
aiowatttime==0.1.1
# homeassistant.components.webdav
aiowebdav2==0.4.6
aiowebdav2==0.5.0
# homeassistant.components.webostv
aiowebostv==0.7.5
@@ -820,7 +820,7 @@ esphome-dashboard-api==1.3.0
essent-dynamic-pricing==0.2.7
# homeassistant.components.netgear_lte
eternalegypt==0.0.16
eternalegypt==0.0.18
# homeassistant.components.eufylife_ble
eufylife-ble-client==0.1.8
@@ -1074,7 +1074,7 @@ holidays==0.84
home-assistant-frontend==20251229.0
# homeassistant.components.conversation
home-assistant-intents==2025.12.2
home-assistant-intents==2026.1.1
# homeassistant.components.gentex_homelink
homelink-integration-api==0.0.1
@@ -1183,7 +1183,7 @@ kegtron-ble==1.0.2
knocki==0.4.2
# homeassistant.components.knx
knx-frontend==2025.12.28.215221
knx-frontend==2025.12.30.151231
# homeassistant.components.konnected
konnected==1.2.0
@@ -1595,7 +1595,7 @@ pyHomee==1.3.8
pyRFXtrx==0.31.1
# homeassistant.components.tibber
pyTibber==0.32.2
pyTibber==0.33.1
# homeassistant.components.dlink
pyW215==0.8.0
@@ -1613,7 +1613,7 @@ pyaftership==21.11.0
pyairnow==1.3.1
# homeassistant.components.airobot
pyairobotrest==0.1.0
pyairobotrest==0.2.0
# homeassistant.components.airvisual
# homeassistant.components.airvisual_pro
@@ -1959,7 +1959,7 @@ pyplaato==0.0.19
pypoint==3.0.0
# homeassistant.components.portainer
pyportainer==1.0.17
pyportainer==1.0.19
# homeassistant.components.probe_plus
pyprobeplus==1.1.2
@@ -2165,7 +2165,7 @@ python-pooldose==0.8.1
python-rabbitair==0.0.8
# homeassistant.components.roborock
python-roborock==3.21.0
python-roborock==4.2.0
# homeassistant.components.smarttub
python-smarttub==0.0.46
@@ -2229,7 +2229,7 @@ pyvesync==3.3.3
pyvizio==0.1.61
# homeassistant.components.velux
pyvlx==0.2.26
pyvlx==0.2.27
# homeassistant.components.volumio
pyvolumio==0.1.5
@@ -2533,7 +2533,7 @@ tololib==1.2.2
toonapi==0.3.0
# homeassistant.components.totalconnect
total-connect-client==2025.5
total-connect-client==2025.12.2
# homeassistant.components.tplink_omada
tplink-omada-client==1.5.3
@@ -2607,7 +2607,7 @@ vegehub==0.1.26
vehicle==2.2.2
# homeassistant.components.velbus
velbus-aio==2025.12.0
velbus-aio==2026.1.1
# homeassistant.components.venstar
venstarcolortouch==0.21
@@ -2683,7 +2683,7 @@ wsdot==0.0.1
wyoming==1.7.2
# homeassistant.components.xiaomi_ble
xiaomi-ble==1.2.0
xiaomi-ble==1.4.1
# homeassistant.components.knx
xknx==3.13.0

View File

@@ -120,6 +120,9 @@ multidict>=6.0.2
# Version 2.0 added typing, prevent accidental fallbacks
backoff>=2.0
# Brotli 1.2.0 fixes CVE and is required for aiohttp 3.13.3 compatibility
Brotli>=1.2.0
# ensure pydantic version does not float since it might have breaking changes
pydantic==2.12.2

View File

@@ -6,6 +6,7 @@ from unittest.mock import MagicMock
from bsblan import BSBLANError, DaySchedule, TimeSlot
import pytest
import voluptuous as vol
from homeassistant.components.bsblan.const import DOMAIN
from homeassistant.components.bsblan.services import (
@@ -198,9 +199,7 @@ async def test_no_config_entry_for_device(
SERVICE_SET_HOT_WATER_SCHEDULE,
{
"device_id": device_entry.id,
"monday_slots": [
{"start_time": time(6, 0), "end_time": time(8, 0)},
],
"monday_slots": [{"start_time": time(6, 0), "end_time": time(8, 0)}],
},
blocking=True,
)
@@ -274,14 +273,10 @@ async def test_api_error(
[
(time(13, 0), time(11, 0), "end_time_before_start_time"),
("13:00", "11:00", "end_time_before_start_time"),
("invalid", "08:00", "invalid_time_format"),
("06:00", "not-a-time", "invalid_time_format"),
],
ids=[
"time_objects_end_before_start",
"strings_end_before_start",
"invalid_start_time_format",
"invalid_end_time_format",
],
)
async def test_time_validation_errors(
@@ -395,22 +390,20 @@ async def test_non_standard_time_types(
device_entry: dr.DeviceEntry,
) -> None:
"""Test service with non-standard time types raises error."""
# Test with integer time values (shouldn't happen but need coverage)
with pytest.raises(ServiceValidationError) as exc_info:
# Test with integer time values - schema validation will reject these
with pytest.raises(vol.MultipleInvalid):
await hass.services.async_call(
DOMAIN,
SERVICE_SET_HOT_WATER_SCHEDULE,
{
"device_id": device_entry.id,
"monday_slots": [
{"start_time": 600, "end_time": 800}, # Non-standard types
{"start_time": 600, "end_time": 800},
],
},
blocking=True,
)
assert exc_info.value.translation_key == "invalid_time_format"
async def test_async_setup_services(
hass: HomeAssistant,

View File

@@ -81,6 +81,7 @@ def mock_api_tts() -> AsyncMock:
voices=[
cloud_tts.Voice(language_codes=["en-US"], name="en-US-Standard-A"),
cloud_tts.Voice(language_codes=["en-US"], name="en-US-Standard-B"),
cloud_tts.Voice(language_codes=["en-US"], name="Standard-A"),
cloud_tts.Voice(language_codes=["el-GR"], name="el-GR-Standard-A"),
]
)

View File

@@ -294,6 +294,10 @@ async def test_binary_sensor_update_callback(
callback_func = add_callback_call[0][0]
callback_func("motion detected")
# Wait for the event loop to process the scheduled state update
# (callback uses call_soon_threadsafe to schedule update in event loop)
await hass.async_block_till_done()
# Verify state was updated
state = hass.states.get("binary_sensor.front_camera_motion")
assert state is not None

View File

@@ -28,7 +28,7 @@
'suggested_unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
}),
}),
'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>,
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
'original_icon': None,
'original_name': 'Daily active water use',
'platform': 'hydrawise',
@@ -44,7 +44,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by hydrawise.com',
'device_class': 'volume',
'device_class': 'water',
'friendly_name': 'Home Controller Daily active water use',
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
}),
@@ -139,7 +139,7 @@
'suggested_unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
}),
}),
'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>,
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
'original_icon': None,
'original_name': 'Daily inactive water use',
'platform': 'hydrawise',
@@ -155,7 +155,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by hydrawise.com',
'device_class': 'volume',
'device_class': 'water',
'friendly_name': 'Home Controller Daily inactive water use',
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
}),
@@ -196,7 +196,7 @@
'suggested_unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
}),
}),
'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>,
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
'original_icon': None,
'original_name': 'Daily total water use',
'platform': 'hydrawise',
@@ -212,7 +212,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by hydrawise.com',
'device_class': 'volume',
'device_class': 'water',
'friendly_name': 'Home Controller Daily total water use',
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
}),
@@ -253,7 +253,7 @@
'suggested_unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
}),
}),
'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>,
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
'original_icon': None,
'original_name': 'Daily active water use',
'platform': 'hydrawise',
@@ -269,7 +269,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by hydrawise.com',
'device_class': 'volume',
'device_class': 'water',
'friendly_name': 'Zone One Daily active water use',
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
}),
@@ -464,7 +464,7 @@
'suggested_unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
}),
}),
'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>,
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
'original_icon': 'mdi:water-outline',
'original_name': 'Daily active water use',
'platform': 'hydrawise',
@@ -480,7 +480,7 @@
StateSnapshot({
'attributes': ReadOnlyDict({
'attribution': 'Data provided by hydrawise.com',
'device_class': 'volume',
'device_class': 'water',
'friendly_name': 'Zone Two Daily active water use',
'icon': 'mdi:water-outline',
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,

View File

@@ -7,8 +7,13 @@ from unittest.mock import patch
from nacl.encoding import Base64Encoder
from nacl.secret import SecretBox
import pytest
from homeassistant.components.mobile_app.const import CONF_SECRET, DOMAIN
from homeassistant.components.mobile_app.const import (
CONF_CLOUDHOOK_URL,
CONF_SECRET,
DOMAIN,
)
from homeassistant.const import CONF_WEBHOOK_ID
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
@@ -101,6 +106,61 @@ async def test_registration_encryption(
assert json.loads(decrypted_data) == {"one": "Hello world"}
@pytest.mark.parametrize(
"cloud_is_connected",
[
True,
False,
],
)
async def test_registration_with_cloud(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
hass_admin_user: MockUser,
cloud_is_connected: bool,
) -> None:
"""Test that cloudhook_url is only returned when cloud is connected."""
await async_setup_component(hass, DOMAIN, {DOMAIN: {}})
api_client = await hass_client()
cloudhook_url = "https://hooks.nabu.casa/test123"
with (
patch(
"homeassistant.components.mobile_app.http_api.cloud.async_active_subscription",
return_value=True,
),
patch(
"homeassistant.components.mobile_app.http_api.cloud.async_is_connected",
return_value=cloud_is_connected,
),
patch(
"homeassistant.components.mobile_app.http_api.async_create_cloud_hook",
return_value=cloudhook_url,
),
patch(
"homeassistant.components.mobile_app.http_api.cloud.async_remote_ui_url",
return_value="https://remote.ui",
),
patch(
"homeassistant.components.person.async_add_user_device_tracker",
spec=True,
),
):
resp = await api_client.post(
"/api/mobile_app/registrations", json=REGISTER_CLEARTEXT
)
assert resp.status == HTTPStatus.CREATED
register_json = await resp.json()
assert CONF_WEBHOOK_ID in register_json
assert register_json.get(CONF_CLOUDHOOK_URL) == (
cloudhook_url if cloud_is_connected else None
)
async def test_registration_encryption_legacy(
hass: HomeAssistant, hass_client: ClientSessionGenerator
) -> None:

View File

@@ -65,18 +65,3 @@ async def test_flow_user_cannot_connect(
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"]["base"] == "cannot_connect"
async def test_flow_user_unknown_error(hass: HomeAssistant, unknown: None) -> None:
"""Test unknown error."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={CONF_SOURCE: SOURCE_USER},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input=CONF_DATA,
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"]["base"] == "unknown"

View File

@@ -2,9 +2,12 @@
from unittest.mock import patch
import pytest
from homeassistant.components.netgear_lte.const import DOMAIN
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from .conftest import HOST
@@ -54,3 +57,15 @@ async def test_set_option(hass: HomeAssistant, setup_integration: None) -> None:
blocking=True,
)
assert len(mock_client.mock_calls) == 1
entry = hass.config_entries.async_entries(DOMAIN)[0]
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
with pytest.raises(ServiceValidationError):
await hass.services.async_call(
DOMAIN,
"delete_sms",
{CONF_HOST: "no-match", "sms_id": 1},
blocking=True,
)

View File

@@ -185,6 +185,7 @@ def _init_host_mock(host_mock: MagicMock) -> None:
host_mock.baichuan.smart_ai_type_list.return_value = ["people"]
host_mock.baichuan.smart_ai_index.return_value = 1
host_mock.baichuan.smart_ai_name.return_value = "zone1"
host_mock.whiteled_brightness.return_value = None
def ai_detect_type(channel: int, object_type: str) -> str | None:
if object_type == "people":

View File

@@ -74,6 +74,7 @@ async def test_light_turn_off(
) -> None:
"""Test light turn off service."""
reolink_host.whiteled_color_temperature.return_value = 3000
reolink_host.whiteled_brightness.return_value = 75
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]):
assert await hass.config_entries.async_setup(config_entry.entry_id)
@@ -81,6 +82,8 @@ async def test_light_turn_off(
assert config_entry.state is ConfigEntryState.LOADED
entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight"
state = hass.states.get(entity_id)
assert state and state.attributes.get(ATTR_BRIGHTNESS) == 191
await hass.services.async_call(
LIGHT_DOMAIN,
@@ -107,6 +110,7 @@ async def test_light_turn_on(
) -> None:
"""Test light turn on service."""
reolink_host.whiteled_color_temperature.return_value = 3000
reolink_host.whiteled_brightness.return_value = None
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]):
assert await hass.config_entries.async_setup(config_entry.entry_id)

View File

@@ -1,22 +1,28 @@
"""Shopping list test helpers."""
from collections.abc import Generator
from pathlib import Path
from unittest.mock import patch
import pytest
from homeassistant.components.shopping_list import intent as sl_intent
from homeassistant.components.shopping_list import PERSISTENCE, intent as sl_intent
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def mock_shopping_list_io():
"""Stub out the persistence."""
with (
patch("homeassistant.components.shopping_list.ShoppingData.save"),
patch("homeassistant.components.shopping_list.ShoppingData.async_load"),
):
def shopping_list_tmp_path(tmp_path: Path, hass: HomeAssistant) -> Generator[None]:
"""Use a unique temp directory for shopping list storage per test."""
orig_path = hass.config.path
def _mock_path(*args: str) -> str:
if args == (PERSISTENCE,):
return str(tmp_path / PERSISTENCE)
return orig_path(*args)
with patch.object(hass.config, "path", _mock_path):
yield
@@ -27,7 +33,7 @@ def mock_config_entry() -> MockConfigEntry:
@pytest.fixture
async def sl_setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry):
async def sl_setup(hass: HomeAssistant, mock_config_entry: MockConfigEntry) -> None:
"""Set up the shopping list."""
mock_config_entry.add_to_hass(hass)

View File

@@ -0,0 +1,717 @@
# serializer version: 1
# name: test_add_item
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
])
# ---
# name: test_add_item_service
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
])
# ---
# name: test_api_update_fails
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
])
# ---
# name: test_api_update_fails.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
])
# ---
# name: test_api_update_fails.2
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
])
# ---
# name: test_clear_completed_items
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'cheese',
}),
])
# ---
# name: test_clear_completed_items.1
list([
dict({
'complete': True,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': True,
'id': '<ANY>',
'name': 'cheese',
}),
])
# ---
# name: test_clear_completed_items.2
list([
])
# ---
# name: test_clear_completed_items_service
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
])
# ---
# name: test_clear_completed_items_service.1
list([
dict({
'complete': True,
'id': '<ANY>',
'name': 'beer',
}),
])
# ---
# name: test_clear_completed_items_service.2
list([
])
# ---
# name: test_deprecated_api_clear_completed
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_deprecated_api_clear_completed.1
list([
dict({
'complete': True,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_deprecated_api_clear_completed.2
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_deprecated_api_create
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'soda',
}),
])
# ---
# name: test_deprecated_api_create_fail
'<file missing>'
# ---
# name: test_deprecated_api_get_all
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_deprecated_api_get_all.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_deprecated_api_update
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_deprecated_api_update.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'soda',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_deprecated_api_update.2
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'soda',
}),
dict({
'complete': True,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_recent_items_intent
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'soda',
}),
])
# ---
# name: test_recent_items_intent.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'soda',
}),
])
# ---
# name: test_remove_item
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'cheese',
}),
])
# ---
# name: test_remove_item.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'cheese',
}),
])
# ---
# name: test_remove_item.2
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'cheese',
}),
])
# ---
# name: test_remove_item_service
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'cheese',
}),
])
# ---
# name: test_remove_item_service.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'cheese',
}),
])
# ---
# name: test_sort_list_service
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'zzz',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'ddd',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'aaa',
}),
])
# ---
# name: test_sort_list_service.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'aaa',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'ddd',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'zzz',
}),
])
# ---
# name: test_sort_list_service.2
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'zzz',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'ddd',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'aaa',
}),
])
# ---
# name: test_update_list
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'cheese',
}),
])
# ---
# name: test_update_list.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'dupe',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'dupe',
}),
])
# ---
# name: test_ws_add_item
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'soda',
}),
])
# ---
# name: test_ws_add_item_fail
'<file missing>'
# ---
# name: test_ws_clear_items
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_ws_clear_items.1
list([
dict({
'complete': True,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_ws_clear_items.2
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_ws_get_items
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_ws_get_items.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_ws_remove_item
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'soda',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'cheese',
}),
])
# ---
# name: test_ws_remove_item.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'cheese',
}),
])
# ---
# name: test_ws_remove_item_fail
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'soda',
}),
])
# ---
# name: test_ws_remove_item_fail.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'soda',
}),
])
# ---
# name: test_ws_reorder_items
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'apple',
}),
])
# ---
# name: test_ws_reorder_items.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'apple',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
])
# ---
# name: test_ws_reorder_items.2
list([
dict({
'complete': True,
'id': '<ANY>',
'name': 'wine',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'apple',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
])
# ---
# name: test_ws_reorder_items.3
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'apple',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': True,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_ws_reorder_items_failure
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'apple',
}),
])
# ---
# name: test_ws_reorder_items_failure.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'apple',
}),
])
# ---
# name: test_ws_reorder_items_failure.2
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'apple',
}),
])
# ---
# name: test_ws_update_item
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_ws_update_item.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'soda',
}),
dict({
'complete': False,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_ws_update_item.2
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'soda',
}),
dict({
'complete': True,
'id': '<ANY>',
'name': 'wine',
}),
])
# ---
# name: test_ws_update_item_fail
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
])
# ---
# name: test_ws_update_item_fail.1
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
])
# ---
# name: test_ws_update_item_fail.2
list([
dict({
'complete': False,
'id': '<ANY>',
'name': 'beer',
}),
])
# ---

View File

@@ -1,8 +1,11 @@
"""Test shopping list component."""
from http import HTTPStatus
import json
from pathlib import Path
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.shopping_list import NoMatchingShoppingListItem
from homeassistant.components.shopping_list.const import (
@@ -28,7 +31,23 @@ from tests.common import async_capture_events
from tests.typing import ClientSessionGenerator, WebSocketGenerator
async def test_add_item(hass: HomeAssistant, sl_setup) -> None:
def assert_shopping_list_data(hass: HomeAssistant, snapshot: SnapshotAssertion) -> None:
"""Assert shopping list data matches snapshot."""
path = Path(hass.config.path(".shopping_list.json"))
if not path.exists():
assert snapshot == "<file missing>"
else:
shopping_list_data = json.loads(path.read_text(encoding="utf-8"))
for item in shopping_list_data:
if "id" not in item:
continue
item["id"] = "<ANY>"
assert shopping_list_data == snapshot
async def test_add_item(
hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion
) -> None:
"""Test adding an item intent."""
response = await intent.async_handle(
@@ -39,10 +58,13 @@ async def test_add_item(hass: HomeAssistant, sl_setup) -> None:
# Response text is now handled by default conversation agent
assert response.response_type == intent.IntentResponseType.ACTION_DONE
assert_shopping_list_data(hass, snapshot)
async def test_remove_item(hass: HomeAssistant, sl_setup) -> None:
"""Test removiung list items."""
async def test_remove_item(
hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion
) -> None:
"""Test removing list items."""
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}}
)
@@ -50,12 +72,14 @@ async def test_remove_item(hass: HomeAssistant, sl_setup) -> None:
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "cheese"}}
)
assert_shopping_list_data(hass, snapshot)
assert len(hass.data[DOMAIN].items) == 2
# Remove a single item
item_id = hass.data[DOMAIN].items[0]["id"]
await hass.data[DOMAIN].async_remove(item_id)
assert_shopping_list_data(hass, snapshot)
assert len(hass.data[DOMAIN].items) == 1
@@ -65,9 +89,12 @@ async def test_remove_item(hass: HomeAssistant, sl_setup) -> None:
# Trying to remove the same item twice should fail
with pytest.raises(NoMatchingShoppingListItem):
await hass.data[DOMAIN].async_remove(item_id)
assert_shopping_list_data(hass, snapshot)
async def test_update_list(hass: HomeAssistant, sl_setup) -> None:
async def test_update_list(
hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion
) -> None:
"""Test updating all list items."""
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}}
@@ -76,6 +103,7 @@ async def test_update_list(hass: HomeAssistant, sl_setup) -> None:
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "cheese"}}
)
assert_shopping_list_data(hass, snapshot)
# Update a single attribute, other attributes shouldn't change
await hass.data[DOMAIN].async_update_list({"complete": True})
@@ -90,6 +118,7 @@ async def test_update_list(hass: HomeAssistant, sl_setup) -> None:
# Update multiple attributes
await hass.data[DOMAIN].async_update_list({"name": "dupe", "complete": False})
assert_shopping_list_data(hass, snapshot)
beer = hass.data[DOMAIN].items[0]
assert beer["name"] == "dupe"
@@ -100,7 +129,9 @@ async def test_update_list(hass: HomeAssistant, sl_setup) -> None:
assert cheese["complete"] is False
async def test_clear_completed_items(hass: HomeAssistant, sl_setup) -> None:
async def test_clear_completed_items(
hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion
) -> None:
"""Test clear completed list items."""
await intent.async_handle(
hass,
@@ -112,18 +143,23 @@ async def test_clear_completed_items(hass: HomeAssistant, sl_setup) -> None:
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "cheese"}}
)
assert_shopping_list_data(hass, snapshot)
assert len(hass.data[DOMAIN].items) == 2
# Update a single attribute, other attributes shouldn't change
await hass.data[DOMAIN].async_update_list({"complete": True})
assert_shopping_list_data(hass, snapshot)
await hass.data[DOMAIN].async_clear_completed()
assert_shopping_list_data(hass, snapshot)
assert len(hass.data[DOMAIN].items) == 0
async def test_recent_items_intent(hass: HomeAssistant, sl_setup) -> None:
async def test_recent_items_intent(
hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion
) -> None:
"""Test recent items."""
await intent.async_handle(
@@ -135,8 +171,10 @@ async def test_recent_items_intent(hass: HomeAssistant, sl_setup) -> None:
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "soda"}}
)
assert_shopping_list_data(hass, snapshot)
response = await intent.async_handle(hass, "test", "HassShoppingListLastItems")
assert_shopping_list_data(hass, snapshot)
assert (
response.speech["plain"]["speech"]
@@ -145,7 +183,10 @@ async def test_recent_items_intent(hass: HomeAssistant, sl_setup) -> None:
async def test_deprecated_api_get_all(
hass: HomeAssistant, hass_client: ClientSessionGenerator, sl_setup
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test the API."""
@@ -155,9 +196,11 @@ async def test_deprecated_api_get_all(
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}}
)
assert_shopping_list_data(hass, snapshot)
client = await hass_client()
resp = await client.get("/api/shopping_list")
assert_shopping_list_data(hass, snapshot)
assert resp.status == HTTPStatus.OK
data = await resp.json()
@@ -169,7 +212,10 @@ async def test_deprecated_api_get_all(
async def test_ws_get_items(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test get shopping_list items websocket command."""
@@ -179,6 +225,7 @@ async def test_ws_get_items(
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}}
)
assert_shopping_list_data(hass, snapshot)
client = await hass_ws_client(hass)
events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)
@@ -187,6 +234,7 @@ async def test_ws_get_items(
msg = await client.receive_json()
assert msg["success"] is True
assert len(events) == 0
assert_shopping_list_data(hass, snapshot)
assert msg["id"] == 5
assert msg["type"] == TYPE_RESULT
@@ -200,7 +248,10 @@ async def test_ws_get_items(
async def test_deprecated_api_update(
hass: HomeAssistant, hass_client: ClientSessionGenerator, sl_setup
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test the API."""
@@ -210,6 +261,7 @@ async def test_deprecated_api_update(
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}}
)
assert_shopping_list_data(hass, snapshot)
beer_id = hass.data["shopping_list"].items[0]["id"]
wine_id = hass.data["shopping_list"].items[1]["id"]
@@ -219,6 +271,7 @@ async def test_deprecated_api_update(
resp = await client.post(
f"/api/shopping_list/item/{beer_id}", json={"name": "soda"}
)
assert_shopping_list_data(hass, snapshot)
assert resp.status == HTTPStatus.OK
assert len(events) == 1
@@ -228,6 +281,7 @@ async def test_deprecated_api_update(
resp = await client.post(
f"/api/shopping_list/item/{wine_id}", json={"complete": True}
)
assert_shopping_list_data(hass, snapshot)
assert resp.status == HTTPStatus.OK
assert len(events) == 2
@@ -240,7 +294,10 @@ async def test_deprecated_api_update(
async def test_ws_update_item(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test update shopping_list item websocket command."""
await intent.async_handle(
@@ -249,6 +306,7 @@ async def test_ws_update_item(
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}}
)
assert_shopping_list_data(hass, snapshot)
beer_id = hass.data["shopping_list"].items[0]["id"]
wine_id = hass.data["shopping_list"].items[1]["id"]
@@ -267,6 +325,7 @@ async def test_ws_update_item(
data = msg["result"]
assert data == {"id": beer_id, "name": "soda", "complete": False}
assert len(events) == 1
assert_shopping_list_data(hass, snapshot)
await client.send_json(
{
@@ -281,6 +340,7 @@ async def test_ws_update_item(
data = msg["result"]
assert data == {"id": wine_id, "name": "wine", "complete": True}
assert len(events) == 2
assert_shopping_list_data(hass, snapshot)
beer, wine = hass.data["shopping_list"].items
assert beer == {"id": beer_id, "name": "soda", "complete": False}
@@ -288,34 +348,44 @@ async def test_ws_update_item(
async def test_api_update_fails(
hass: HomeAssistant, hass_client: ClientSessionGenerator, sl_setup
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test the API."""
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}}
)
assert_shopping_list_data(hass, snapshot)
client = await hass_client()
events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)
resp = await client.post("/api/shopping_list/non_existing", json={"name": "soda"})
assert_shopping_list_data(hass, snapshot)
assert resp.status == HTTPStatus.NOT_FOUND
assert len(events) == 0
beer_id = hass.data["shopping_list"].items[0]["id"]
resp = await client.post(f"/api/shopping_list/item/{beer_id}", json={"name": 123})
assert_shopping_list_data(hass, snapshot)
assert resp.status == HTTPStatus.BAD_REQUEST
async def test_ws_update_item_fail(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test failure of update shopping_list item websocket command."""
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "beer"}}
)
assert_shopping_list_data(hass, snapshot)
client = await hass_ws_client(hass)
events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)
await client.send_json(
@@ -331,15 +401,20 @@ async def test_ws_update_item_fail(
data = msg["error"]
assert data == {"code": "item_not_found", "message": "Item not found"}
assert len(events) == 0
assert_shopping_list_data(hass, snapshot)
await client.send_json({"id": 6, "type": "shopping_list/items/update", "name": 123})
msg = await client.receive_json()
assert msg["success"] is False
assert len(events) == 0
assert_shopping_list_data(hass, snapshot)
async def test_deprecated_api_clear_completed(
hass: HomeAssistant, hass_client: ClientSessionGenerator, sl_setup
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test the API."""
@@ -349,6 +424,7 @@ async def test_deprecated_api_clear_completed(
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}}
)
assert_shopping_list_data(hass, snapshot)
beer_id = hass.data["shopping_list"].items[0]["id"]
wine_id = hass.data["shopping_list"].items[1]["id"]
@@ -362,10 +438,12 @@ async def test_deprecated_api_clear_completed(
)
assert resp.status == HTTPStatus.OK
assert len(events) == 1
assert_shopping_list_data(hass, snapshot)
resp = await client.post("/api/shopping_list/clear_completed")
assert resp.status == HTTPStatus.OK
assert len(events) == 2
assert_shopping_list_data(hass, snapshot)
items = hass.data["shopping_list"].items
assert len(items) == 1
@@ -374,7 +452,10 @@ async def test_deprecated_api_clear_completed(
async def test_ws_clear_items(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test clearing shopping_list items websocket command."""
await intent.async_handle(
@@ -383,6 +464,7 @@ async def test_ws_clear_items(
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "wine"}}
)
assert_shopping_list_data(hass, snapshot)
beer_id = hass.data["shopping_list"].items[0]["id"]
wine_id = hass.data["shopping_list"].items[1]["id"]
client = await hass_ws_client(hass)
@@ -398,6 +480,7 @@ async def test_ws_clear_items(
msg = await client.receive_json()
assert msg["success"] is True
assert len(events) == 1
assert_shopping_list_data(hass, snapshot)
await client.send_json({"id": 6, "type": "shopping_list/items/clear"})
msg = await client.receive_json()
@@ -406,16 +489,21 @@ async def test_ws_clear_items(
assert len(items) == 1
assert items[0] == {"id": wine_id, "name": "wine", "complete": False}
assert len(events) == 2
assert_shopping_list_data(hass, snapshot)
async def test_deprecated_api_create(
hass: HomeAssistant, hass_client: ClientSessionGenerator, sl_setup
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test the API."""
client = await hass_client()
events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)
resp = await client.post("/api/shopping_list/item", json={"name": "soda"})
assert_shopping_list_data(hass, snapshot)
assert resp.status == HTTPStatus.OK
data = await resp.json()
@@ -430,13 +518,17 @@ async def test_deprecated_api_create(
async def test_deprecated_api_create_fail(
hass: HomeAssistant, hass_client: ClientSessionGenerator, sl_setup
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test the API."""
client = await hass_client()
events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)
resp = await client.post("/api/shopping_list/item", json={"name": 1234})
assert_shopping_list_data(hass, snapshot)
assert resp.status == HTTPStatus.BAD_REQUEST
assert len(hass.data["shopping_list"].items) == 0
@@ -444,7 +536,10 @@ async def test_deprecated_api_create_fail(
async def test_ws_add_item(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test adding shopping_list item websocket command."""
client = await hass_ws_client(hass)
@@ -456,6 +551,7 @@ async def test_ws_add_item(
assert data["name"] == "soda"
assert data["complete"] is False
assert len(events) == 1
assert_shopping_list_data(hass, snapshot)
items = hass.data["shopping_list"].items
assert len(items) == 1
@@ -464,7 +560,10 @@ async def test_ws_add_item(
async def test_ws_add_item_fail(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test adding shopping_list item failure websocket command."""
client = await hass_ws_client(hass)
@@ -474,10 +573,14 @@ async def test_ws_add_item_fail(
assert msg["success"] is False
assert len(events) == 0
assert len(hass.data["shopping_list"].items) == 0
assert_shopping_list_data(hass, snapshot)
async def test_ws_remove_item(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test removing shopping_list item websocket command."""
client = await hass_ws_client(hass)
@@ -490,6 +593,7 @@ async def test_ws_remove_item(
)
msg = await client.receive_json()
assert len(events) == 2
assert_shopping_list_data(hass, snapshot)
items = hass.data["shopping_list"].items
assert len(items) == 2
@@ -500,6 +604,7 @@ async def test_ws_remove_item(
msg = await client.receive_json()
assert len(events) == 3
assert msg["success"] is True
assert_shopping_list_data(hass, snapshot)
items = hass.data["shopping_list"].items
assert len(items) == 1
@@ -507,22 +612,30 @@ async def test_ws_remove_item(
async def test_ws_remove_item_fail(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test removing shopping_list item failure websocket command."""
client = await hass_ws_client(hass)
events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)
await client.send_json({"id": 5, "type": "shopping_list/items/add", "name": "soda"})
msg = await client.receive_json()
assert_shopping_list_data(hass, snapshot)
await client.send_json({"id": 6, "type": "shopping_list/items/remove"})
msg = await client.receive_json()
assert msg["success"] is False
assert len(events) == 1
assert len(hass.data["shopping_list"].items) == 1
assert_shopping_list_data(hass, snapshot)
async def test_ws_reorder_items(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test reordering shopping_list items websocket command."""
await intent.async_handle(
@@ -534,6 +647,7 @@ async def test_ws_reorder_items(
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "apple"}}
)
assert_shopping_list_data(hass, snapshot)
beer_id = hass.data["shopping_list"].items[0]["id"]
wine_id = hass.data["shopping_list"].items[1]["id"]
@@ -566,6 +680,7 @@ async def test_ws_reorder_items(
"name": "beer",
"complete": False,
}
assert_shopping_list_data(hass, snapshot)
# Mark wine as completed.
await client.send_json(
@@ -578,6 +693,7 @@ async def test_ws_reorder_items(
)
_ = await client.receive_json()
assert len(events) == 2
assert_shopping_list_data(hass, snapshot)
await client.send_json(
{
@@ -604,10 +720,14 @@ async def test_ws_reorder_items(
"name": "wine",
"complete": True,
}
assert_shopping_list_data(hass, snapshot)
async def test_ws_reorder_items_failure(
hass: HomeAssistant, hass_ws_client: WebSocketGenerator, sl_setup
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
sl_setup: None,
snapshot: SnapshotAssertion,
) -> None:
"""Test reordering shopping_list items websocket command."""
await intent.async_handle(
@@ -619,6 +739,7 @@ async def test_ws_reorder_items_failure(
await intent.async_handle(
hass, "test", "HassShoppingListAddItem", {"item": {"value": "apple"}}
)
assert_shopping_list_data(hass, snapshot)
beer_id = hass.data["shopping_list"].items[0]["id"]
wine_id = hass.data["shopping_list"].items[1]["id"]
@@ -639,6 +760,7 @@ async def test_ws_reorder_items_failure(
assert msg["success"] is False
assert msg["error"]["code"] == ERR_NOT_FOUND
assert len(events) == 0
assert_shopping_list_data(hass, snapshot)
# Testing not sending all unchecked item ids.
await client.send_json(
@@ -652,9 +774,12 @@ async def test_ws_reorder_items_failure(
assert msg["success"] is False
assert msg["error"]["code"] == ERR_INVALID_FORMAT
assert len(events) == 0
assert_shopping_list_data(hass, snapshot)
async def test_add_item_service(hass: HomeAssistant, sl_setup) -> None:
async def test_add_item_service(
hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion
) -> None:
"""Test adding shopping_list item service."""
events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)
await hass.services.async_call(
@@ -665,9 +790,12 @@ async def test_add_item_service(hass: HomeAssistant, sl_setup) -> None:
)
assert len(hass.data[DOMAIN].items) == 1
assert len(events) == 1
assert_shopping_list_data(hass, snapshot)
async def test_remove_item_service(hass: HomeAssistant, sl_setup) -> None:
async def test_remove_item_service(
hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion
) -> None:
"""Test removing shopping_list item service."""
events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)
await hass.services.async_call(
@@ -684,6 +812,7 @@ async def test_remove_item_service(hass: HomeAssistant, sl_setup) -> None:
)
assert len(hass.data[DOMAIN].items) == 2
assert len(events) == 2
assert_shopping_list_data(hass, snapshot)
await hass.services.async_call(
DOMAIN,
@@ -694,9 +823,12 @@ async def test_remove_item_service(hass: HomeAssistant, sl_setup) -> None:
assert len(hass.data[DOMAIN].items) == 1
assert hass.data[DOMAIN].items[0]["name"] == "cheese"
assert len(events) == 3
assert_shopping_list_data(hass, snapshot)
async def test_clear_completed_items_service(hass: HomeAssistant, sl_setup) -> None:
async def test_clear_completed_items_service(
hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion
) -> None:
"""Test clearing completed shopping_list items service."""
events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)
await hass.services.async_call(
@@ -707,6 +839,7 @@ async def test_clear_completed_items_service(hass: HomeAssistant, sl_setup) -> N
)
assert len(hass.data[DOMAIN].items) == 1
assert len(events) == 1
assert_shopping_list_data(hass, snapshot)
events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)
await hass.services.async_call(
@@ -717,6 +850,7 @@ async def test_clear_completed_items_service(hass: HomeAssistant, sl_setup) -> N
)
assert len(hass.data[DOMAIN].items) == 1
assert len(events) == 1
assert_shopping_list_data(hass, snapshot)
events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)
await hass.services.async_call(
@@ -727,9 +861,12 @@ async def test_clear_completed_items_service(hass: HomeAssistant, sl_setup) -> N
)
assert len(hass.data[DOMAIN].items) == 0
assert len(events) == 1
assert_shopping_list_data(hass, snapshot)
async def test_sort_list_service(hass: HomeAssistant, sl_setup) -> None:
async def test_sort_list_service(
hass: HomeAssistant, sl_setup: None, snapshot: SnapshotAssertion
) -> None:
"""Test sort_all service."""
for name in ("zzz", "ddd", "aaa"):
@@ -739,6 +876,7 @@ async def test_sort_list_service(hass: HomeAssistant, sl_setup) -> None:
{ATTR_NAME: name},
blocking=True,
)
assert_shopping_list_data(hass, snapshot)
# sort ascending
events = async_capture_events(hass, EVENT_SHOPPING_LIST_UPDATED)
@@ -748,6 +886,7 @@ async def test_sort_list_service(hass: HomeAssistant, sl_setup) -> None:
{ATTR_REVERSE: False},
blocking=True,
)
assert_shopping_list_data(hass, snapshot)
assert hass.data[DOMAIN].items[0][ATTR_NAME] == "aaa"
assert hass.data[DOMAIN].items[1][ATTR_NAME] == "ddd"
@@ -761,6 +900,7 @@ async def test_sort_list_service(hass: HomeAssistant, sl_setup) -> None:
{ATTR_REVERSE: True},
blocking=True,
)
assert_shopping_list_data(hass, snapshot)
assert hass.data[DOMAIN].items[0][ATTR_NAME] == "zzz"
assert hass.data[DOMAIN].items[1][ATTR_NAME] == "ddd"

View File

@@ -18,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import entity_registry as er
from .conftest import MockSoCo, group_speakers, ungroup_speakers
from .conftest import MockSoCo, create_zgs_sonos_event, group_speakers, ungroup_speakers
async def test_media_player_join(
@@ -134,6 +134,7 @@ async def test_media_player_join_timeout(
"Timeout while waiting for Sonos player to join the "
"group Living Room: Living Room, Bedroom"
)
with (
patch(
"homeassistant.components.sonos.speaker.asyncio.timeout", instant_timeout
@@ -247,3 +248,65 @@ async def test_media_player_unjoin_already_unjoined(
# Should not have called unjoin, since the speakers are already unjoined.
assert soco_bedroom.unjoin.call_count == 0
assert soco_living_room.unjoin.call_count == 0
async def test_unjoin_completes_when_coordinator_receives_event_first(
hass: HomeAssistant,
sonos_setup_two_speakers: list[MockSoCo],
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test that unjoin completes even when only coordinator receives ZGS event."""
soco_living_room = sonos_setup_two_speakers[0]
soco_bedroom = sonos_setup_two_speakers[1]
# First, group the speakers together
group_speakers(soco_living_room, soco_bedroom)
await hass.async_block_till_done(wait_background_tasks=True)
# Verify initial grouped state
expected_group = ["media_player.living_room", "media_player.bedroom"]
assert (
hass.states.get("media_player.living_room").attributes["group_members"]
== expected_group
)
assert (
hass.states.get("media_player.bedroom").attributes["group_members"]
== expected_group
)
unjoin_complete_event = asyncio.Event()
def mock_unjoin(*args, **kwargs) -> None:
hass.loop.call_soon_threadsafe(unjoin_complete_event.set)
soco_bedroom.unjoin = Mock(side_effect=mock_unjoin)
with caplog.at_level(logging.WARNING):
caplog.clear()
await hass.services.async_call(
MP_DOMAIN,
SERVICE_UNJOIN,
{ATTR_ENTITY_ID: "media_player.bedroom"},
blocking=False,
)
await unjoin_complete_event.wait()
# Fire ZGS event only to coordinator to test clearing of bedroom speaker
ungroup_event = create_zgs_sonos_event(
"zgs_two_single.xml",
soco_living_room,
soco_bedroom,
create_uui_ds_in_group=False,
)
soco_living_room.zoneGroupTopology.subscribe.return_value._callback(
ungroup_event
)
await hass.async_block_till_done(wait_background_tasks=True)
# Should complete without warnings or timeout errors
assert len(caplog.records) == 0
assert soco_bedroom.unjoin.call_count == 1
state = hass.states.get("media_player.living_room")
assert state.attributes["group_members"] == ["media_player.living_room"]
state = hass.states.get("media_player.bedroom")
assert state.attributes["group_members"] == ["media_player.bedroom"]

View File

@@ -476,7 +476,7 @@ async def test_s20_start(
)
async def test_s20set_fan_speed(
async def test_s20_set_fan_speed(
hass: HomeAssistant, mock_list_devices, mock_get_status
) -> None:
"""Test s20 set fan speed."""

View File

@@ -1,13 +1,15 @@
"""Test the Tesla Fleet update platform."""
import copy
import time
from typing import Any
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.tesla_fleet.coordinator import VEHICLE_INTERVAL
from homeassistant.components.tesla_fleet.update import INSTALLING
from homeassistant.components.tesla_fleet.update import INSTALLING, SCHEDULED
from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN, SERVICE_INSTALL
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
@@ -19,6 +21,11 @@ from .const import COMMAND_OK, VEHICLE_DATA, VEHICLE_DATA_ALT
from tests.common import MockConfigEntry, async_fire_time_changed
def _get_software_update(data: dict[str, Any]) -> dict[str, Any]:
"""Get the software_update dict from vehicle data."""
return data["response"]["vehicle_state"]["software_update"]
async def test_update(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
@@ -70,14 +77,103 @@ async def test_update_services(
)
call.assert_called_once()
VEHICLE_INSTALLING = copy.deepcopy(VEHICLE_DATA)
VEHICLE_INSTALLING["response"]["vehicle_state"]["software_update"]["status"] = ( # type: ignore[index]
INSTALLING
)
mock_vehicle_data.return_value = VEHICLE_INSTALLING
vehicle_installing = copy.deepcopy(VEHICLE_DATA)
_get_software_update(vehicle_installing)["status"] = INSTALLING
mock_vehicle_data.return_value = vehicle_installing
freezer.tick(VEHICLE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
state = hass.states.get(entity_id)
assert state.attributes["in_progress"] is True # type: ignore[union-attr]
assert state is not None
assert state.attributes["in_progress"] is True
async def test_update_scheduled_far_future_not_in_progress(
hass: HomeAssistant,
normal_config_entry: MockConfigEntry,
mock_vehicle_data: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Tests that a scheduled update far in the future is not shown as in_progress."""
await setup_platform(hass, normal_config_entry, [Platform.UPDATE])
entity_id = "update.test_update"
# Verify initial state (available) is not in_progress
state = hass.states.get(entity_id)
assert state is not None
assert state.attributes["in_progress"] is False
# Simulate update being scheduled for 1 hour in the future
vehicle_scheduled = copy.deepcopy(VEHICLE_DATA)
software_update = _get_software_update(vehicle_scheduled)
software_update["status"] = SCHEDULED
# Set scheduled time to 1 hour from now (well beyond threshold)
software_update["scheduled_time_ms"] = int((time.time() + 3600) * 1000)
mock_vehicle_data.return_value = vehicle_scheduled
freezer.tick(VEHICLE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Scheduled update far in future should NOT be in_progress
state = hass.states.get(entity_id)
assert state is not None
assert state.attributes["in_progress"] is False
async def test_update_scheduled_soon_in_progress(
hass: HomeAssistant,
normal_config_entry: MockConfigEntry,
mock_vehicle_data: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Tests that a scheduled update within threshold is shown as in_progress."""
await setup_platform(hass, normal_config_entry, [Platform.UPDATE])
entity_id = "update.test_update"
# Simulate update being scheduled within threshold (1 minute from now)
vehicle_scheduled = copy.deepcopy(VEHICLE_DATA)
software_update = _get_software_update(vehicle_scheduled)
software_update["status"] = SCHEDULED
# Set scheduled time to 1 minute from now (within 2 minute threshold)
software_update["scheduled_time_ms"] = int((time.time() + 60) * 1000)
mock_vehicle_data.return_value = vehicle_scheduled
freezer.tick(VEHICLE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Scheduled update within threshold should be in_progress
state = hass.states.get(entity_id)
assert state is not None
assert state.attributes["in_progress"] is True
async def test_update_scheduled_no_time_not_in_progress(
hass: HomeAssistant,
normal_config_entry: MockConfigEntry,
mock_vehicle_data: AsyncMock,
freezer: FrozenDateTimeFactory,
) -> None:
"""Tests that a scheduled update without scheduled_time_ms is not in_progress."""
await setup_platform(hass, normal_config_entry, [Platform.UPDATE])
entity_id = "update.test_update"
# Simulate update being scheduled but without scheduled_time_ms
vehicle_scheduled = copy.deepcopy(VEHICLE_DATA)
_get_software_update(vehicle_scheduled)["status"] = SCHEDULED
# No scheduled_time_ms field
mock_vehicle_data.return_value = vehicle_scheduled
freezer.tick(VEHICLE_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Scheduled update without time should NOT be in_progress
state = hass.states.get(entity_id)
assert state is not None
assert state.attributes["in_progress"] is False

View File

@@ -1,7 +1,9 @@
"""Test the Teslemetry select platform."""
from copy import deepcopy
from unittest.mock import AsyncMock, patch
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode
@@ -12,13 +14,16 @@ from homeassistant.components.select import (
DOMAIN as SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
)
from homeassistant.components.teslemetry.coordinator import ENERGY_INFO_INTERVAL
from homeassistant.components.teslemetry.select import LOW
from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import assert_entities, reload_platform, setup_platform
from .const import COMMAND_OK, VEHICLE_DATA_ALT
from .const import COMMAND_OK, SITE_INFO, VEHICLE_DATA_ALT
from tests.common import async_fire_time_changed
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
@@ -164,3 +169,135 @@ async def test_select_streaming(
):
state = hass.states.get(entity_id)
assert state.state == snapshot(name=entity_id)
async def test_export_rule_restore(
hass: HomeAssistant,
mock_site_info: AsyncMock,
) -> None:
"""Test export rule entity when value is missing due to VPP enrollment."""
# Mock energy site with missing export rule (VPP scenario)
vpp_site_info = deepcopy(SITE_INFO)
# Remove the customer_preferred_export_rule to simulate VPP enrollment
del vpp_site_info["response"]["components"]["customer_preferred_export_rule"]
mock_site_info.side_effect = lambda: vpp_site_info
# Set up platform
entry = await setup_platform(hass, [Platform.SELECT])
# Entity should exist but have no current option initially
entity_id = "select.energy_site_allow_export"
state = hass.states.get(entity_id)
assert state is not None
assert state.state == STATE_UNKNOWN
# Test service call works even when value is missing (VPP enrolled)
with patch(
"tesla_fleet_api.teslemetry.EnergySite.grid_import_export",
return_value=COMMAND_OK,
) as call:
await hass.services.async_call(
SELECT_DOMAIN,
SERVICE_SELECT_OPTION,
{
ATTR_ENTITY_ID: entity_id,
ATTR_OPTION: EnergyExportMode.BATTERY_OK.value,
},
blocking=True,
)
state = hass.states.get(entity_id)
assert state.state == EnergyExportMode.BATTERY_OK.value
call.assert_called_once()
# Reload the platform to test state restoration
await reload_platform(hass, entry, [Platform.SELECT])
# The entity should restore the previous state since API value is still missing
state = hass.states.get(entity_id)
assert state.state == EnergyExportMode.BATTERY_OK.value
@pytest.mark.parametrize(
("previous_data", "new_data", "expected_state"),
[
# Path 1: Customer selected export option (has value)
(
{
"customer_preferred_export_rule": "battery_ok",
"non_export_configured": None,
},
{
"customer_preferred_export_rule": "pv_only",
"non_export_configured": None,
},
EnergyExportMode.PV_ONLY.value,
),
# Path 2: In VPP, Export is disabled (non_export_configured is True)
(
{
"customer_preferred_export_rule": "battery_ok",
"non_export_configured": None,
},
{
"customer_preferred_export_rule": None,
"non_export_configured": True,
},
EnergyExportMode.NEVER.value,
),
# Path 3: In VPP, Export enabled but state shows disabled (current_option is NEVER)
(
{
"customer_preferred_export_rule": "never",
"non_export_configured": None,
},
{
"customer_preferred_export_rule": None,
"non_export_configured": None,
},
STATE_UNKNOWN,
),
# Path 4: In VPP Mode, Export isn't disabled, use last known state
(
{
"customer_preferred_export_rule": "battery_ok",
"non_export_configured": None,
},
{
"customer_preferred_export_rule": None,
"non_export_configured": None,
},
EnergyExportMode.BATTERY_OK.value,
),
],
)
async def test_export_rule_update_attrs_logic(
hass: HomeAssistant,
freezer: FrozenDateTimeFactory,
mock_site_info: AsyncMock,
previous_data: dict,
new_data: str | None,
expected_state: str,
) -> None:
"""Test all logic paths in TeslemetryExportRuleSelectEntity._async_update_attrs."""
# Create site info with the test data
test_site_info = deepcopy(SITE_INFO)
test_site_info["response"]["components"].update(previous_data)
mock_site_info.side_effect = lambda: test_site_info
# Set up platform
await setup_platform(hass, [Platform.SELECT])
# Change the state
test_site_info = deepcopy(SITE_INFO)
test_site_info["response"]["components"].update(new_data)
mock_site_info.side_effect = lambda: test_site_info
# Coordinator refresh
freezer.tick(ENERGY_INFO_INTERVAL)
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Check the final state matches expected
state = hass.states.get("select.energy_site_allow_export")
assert state
assert state.state == expected_state

View File

@@ -1,24 +1,76 @@
"""Test helpers for Tibber."""
from collections.abc import AsyncGenerator
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
import tibber
from homeassistant.components.application_credentials import (
ClientCredential,
async_import_client_credential,
)
from homeassistant.components.recorder import Recorder
from homeassistant.components.tibber.const import DOMAIN
from homeassistant.components.tibber.const import AUTH_IMPLEMENTATION, DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
def create_tibber_device(
device_id: str = "device-id",
external_id: str = "external-id",
name: str = "Test Device",
brand: str = "Tibber",
model: str = "Gen1",
value: float | None = 72.0,
home_id: str = "home-id",
) -> tibber.data_api.TibberDevice:
"""Create a fake Tibber Data API device."""
device_data = {
"id": device_id,
"externalId": external_id,
"info": {
"name": name,
"brand": brand,
"model": model,
},
"capabilities": [
{
"id": "storage.stateOfCharge",
"value": value,
"description": "State of charge",
"unit": "%",
},
{
"id": "unknown.sensor.id",
"value": None,
"description": "Unknown",
"unit": "",
},
],
}
return tibber.data_api.TibberDevice(device_data, home_id=home_id)
@pytest.fixture
def config_entry(hass: HomeAssistant) -> MockConfigEntry:
"""Tibber config entry."""
config_entry = MockConfigEntry(
domain=DOMAIN,
data={CONF_ACCESS_TOKEN: "token"},
data={
CONF_ACCESS_TOKEN: "token",
AUTH_IMPLEMENTATION: DOMAIN,
"token": {
"access_token": "test-token",
"refresh_token": "refresh-token",
"token_type": "Bearer",
"expires_at": time.time() + 3600,
},
},
unique_id="tibber",
)
config_entry.add_to_hass(hass)
@@ -26,21 +78,69 @@ def config_entry(hass: HomeAssistant) -> MockConfigEntry:
@pytest.fixture
async def mock_tibber_setup(
recorder_mock: Recorder, config_entry: MockConfigEntry, hass: HomeAssistant
) -> AsyncGenerator[MagicMock]:
"""Mock tibber entry setup."""
def _tibber_patches() -> AsyncGenerator[tuple[MagicMock, MagicMock]]:
"""Patch the Tibber libraries used by the integration."""
unique_user_id = "unique_user_id"
title = "title"
tibber_mock = MagicMock()
tibber_mock.update_info = AsyncMock(return_value=True)
tibber_mock.user_id = PropertyMock(return_value=unique_user_id)
tibber_mock.name = PropertyMock(return_value=title)
tibber_mock.send_notification = AsyncMock()
tibber_mock.rt_disconnect = AsyncMock()
with (
patch(
"tibber.Tibber",
autospec=True,
) as mock_tibber,
patch(
"tibber.data_api.TibberDataAPI",
autospec=True,
) as mock_data_api_client,
):
tibber_mock = mock_tibber.return_value
tibber_mock.update_info = AsyncMock(return_value=True)
tibber_mock.user_id = unique_user_id
tibber_mock.name = title
tibber_mock.send_notification = AsyncMock()
tibber_mock.rt_disconnect = AsyncMock()
tibber_mock.get_homes = MagicMock(return_value=[])
with patch("tibber.Tibber", return_value=tibber_mock):
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
yield tibber_mock
data_api_client_mock = mock_data_api_client.return_value
data_api_client_mock.get_all_devices = AsyncMock(return_value={})
data_api_client_mock.update_devices = AsyncMock(return_value={})
yield tibber_mock, data_api_client_mock
@pytest.fixture
def tibber_mock(_tibber_patches: tuple[MagicMock, MagicMock]) -> MagicMock:
"""Return the patched Tibber connection mock."""
return _tibber_patches[0]
@pytest.fixture
def data_api_client_mock(_tibber_patches: tuple[MagicMock, MagicMock]) -> MagicMock:
"""Return the patched Tibber Data API client mock."""
return _tibber_patches[1]
@pytest.fixture
async def mock_tibber_setup(
recorder_mock: Recorder,
config_entry: MockConfigEntry,
hass: HomeAssistant,
tibber_mock: MagicMock,
setup_credentials: None,
) -> MagicMock:
"""Mock tibber entry setup."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return tibber_mock
@pytest.fixture
async def setup_credentials(hass: HomeAssistant) -> None:
"""Set up application credentials for the OAuth flow."""
assert await async_setup_component(hass, "application_credentials", {})
await async_import_client_credential(
hass,
DOMAIN,
ClientCredential("test-client-id", "test-client-secret"),
DOMAIN,
)

View File

@@ -1,7 +1,9 @@
"""Tests for Tibber config flow."""
from asyncio import TimeoutError
from unittest.mock import AsyncMock, MagicMock, PropertyMock, patch
import builtins
from http import HTTPStatus
from unittest.mock import AsyncMock, MagicMock, patch
from urllib.parse import parse_qs, urlparse
from aiohttp import ClientError
import pytest
@@ -13,16 +15,22 @@ from tibber import (
from homeassistant import config_entries
from homeassistant.components.recorder import Recorder
from homeassistant.components.tibber.application_credentials import TOKEN_URL
from homeassistant.components.tibber.config_flow import (
DATA_API_DEFAULT_SCOPES,
ERR_CLIENT,
ERR_TIMEOUT,
ERR_TOKEN,
)
from homeassistant.components.tibber.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.components.tibber.const import AUTH_IMPLEMENTATION, DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from tests.common import MockConfigEntry
from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@pytest.fixture(name="tibber_setup", autouse=True)
def tibber_setup_fixture():
@@ -31,6 +39,22 @@ def tibber_setup_fixture():
yield
def _mock_tibber(
tibber_mock: MagicMock,
*,
user_id: str = "unique_user_id",
title: str = "Mock Name",
update_side_effect: Exception | None = None,
) -> MagicMock:
"""Configure the patched Tibber GraphQL client."""
tibber_mock.user_id = user_id
tibber_mock.name = title
tibber_mock.update_info = AsyncMock()
if update_side_effect is not None:
tibber_mock.update_info.side_effect = update_side_effect
return tibber_mock
async def test_show_config_form(recorder_mock: Recorder, hass: HomeAssistant) -> None:
"""Test show configuration form."""
result = await hass.config_entries.flow.async_init(
@@ -41,77 +65,239 @@ async def test_show_config_form(recorder_mock: Recorder, hass: HomeAssistant) ->
assert result["step_id"] == "user"
async def test_create_entry(recorder_mock: Recorder, hass: HomeAssistant) -> None:
"""Test create entry from user input."""
test_data = {
CONF_ACCESS_TOKEN: "valid",
}
unique_user_id = "unique_user_id"
title = "title"
tibber_mock = MagicMock()
type(tibber_mock).update_info = AsyncMock(return_value=True)
type(tibber_mock).user_id = PropertyMock(return_value=unique_user_id)
type(tibber_mock).name = PropertyMock(return_value=title)
with patch("tibber.Tibber", return_value=tibber_mock):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == title
assert result["data"] == test_data
@pytest.mark.parametrize(
("exception", "expected_error"),
[
(TimeoutError, ERR_TIMEOUT),
(ClientError, ERR_CLIENT),
(builtins.TimeoutError(), ERR_TIMEOUT),
(ClientError(), ERR_CLIENT),
(InvalidLoginError(401), ERR_TOKEN),
(RetryableHttpExceptionError(503), ERR_CLIENT),
(FatalHttpExceptionError(404), ERR_CLIENT),
],
)
async def test_create_entry_exceptions(
recorder_mock: Recorder, hass: HomeAssistant, exception, expected_error
async def test_graphql_step_exceptions(
recorder_mock: Recorder,
hass: HomeAssistant,
tibber_mock: MagicMock,
exception: Exception,
expected_error: str,
) -> None:
"""Test create entry from user input."""
test_data = {
CONF_ACCESS_TOKEN: "valid",
}
"""Validate GraphQL errors are surfaced."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
unique_user_id = "unique_user_id"
title = "title"
tibber_mock = MagicMock()
type(tibber_mock).update_info = AsyncMock(side_effect=exception)
type(tibber_mock).user_id = PropertyMock(return_value=unique_user_id)
type(tibber_mock).name = PropertyMock(return_value=title)
with patch("tibber.Tibber", return_value=tibber_mock):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data
)
_mock_tibber(tibber_mock, update_side_effect=exception)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "invalid"}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
assert result["errors"][CONF_ACCESS_TOKEN] == expected_error
async def test_flow_entry_already_exists(
recorder_mock: Recorder, hass: HomeAssistant, config_entry
recorder_mock: Recorder,
hass: HomeAssistant,
config_entry,
tibber_mock: MagicMock,
) -> None:
"""Test user input for config_entry that already exists."""
test_data = {
CONF_ACCESS_TOKEN: "valid",
}
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch("tibber.Tibber.update_info", return_value=None):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}, data=test_data
)
_mock_tibber(tibber_mock, user_id="tibber")
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "valid"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
async def test_reauth_flow_steps(
recorder_mock: Recorder,
hass: HomeAssistant,
config_entry: MockConfigEntry,
) -> None:
"""Test the reauth flow goes through reauth_confirm to user step."""
reauth_flow = await config_entry.start_reauth_flow(hass)
assert reauth_flow["type"] is FlowResultType.FORM
assert reauth_flow["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(reauth_flow["flow_id"])
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "reauth_confirm"
result = await hass.config_entries.flow.async_configure(
reauth_flow["flow_id"],
user_input={},
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
async def test_oauth_create_entry_missing_configuration(
recorder_mock: Recorder,
hass: HomeAssistant,
) -> None:
"""Abort OAuth finalize if GraphQL step did not run."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
handler = hass.config_entries.flow._progress[result["flow_id"]]
flow_result = await handler.async_oauth_create_entry(
{CONF_TOKEN: {CONF_ACCESS_TOKEN: "rest-token"}}
)
assert flow_result["type"] is FlowResultType.ABORT
assert flow_result["reason"] == "missing_configuration"
async def test_oauth_create_entry_cannot_connect_userinfo(
recorder_mock: Recorder,
hass: HomeAssistant,
data_api_client_mock: MagicMock,
) -> None:
"""Abort OAuth finalize when Data API userinfo cannot be retrieved."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
handler = hass.config_entries.flow._progress[result["flow_id"]]
handler._access_token = "graphql-token"
data_api_client_mock.get_userinfo = AsyncMock(side_effect=ClientError())
flow_result = await handler.async_oauth_create_entry(
{CONF_TOKEN: {CONF_ACCESS_TOKEN: "rest-token"}}
)
assert flow_result["type"] is FlowResultType.ABORT
assert flow_result["reason"] == "cannot_connect"
async def test_data_api_requires_credentials(
recorder_mock: Recorder,
hass: HomeAssistant,
tibber_mock: MagicMock,
) -> None:
"""Abort when OAuth credentials are missing."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
_mock_tibber(tibber_mock)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "valid"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "missing_credentials"
@pytest.mark.usefixtures("setup_credentials", "current_request_with_host")
async def test_data_api_extra_authorize_scope(
hass: HomeAssistant,
tibber_mock: MagicMock,
) -> None:
"""Ensure the OAuth implementation requests Tibber scopes."""
with patch("homeassistant.components.recorder.async_setup", return_value=True):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
_mock_tibber(tibber_mock)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "valid"}
)
handler = hass.config_entries.flow._progress[result["flow_id"]]
assert handler.extra_authorize_data["scope"] == " ".join(
DATA_API_DEFAULT_SCOPES
)
@pytest.mark.usefixtures("setup_credentials", "current_request_with_host")
async def test_full_flow_success(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
aioclient_mock: AiohttpClientMocker,
tibber_mock: MagicMock,
data_api_client_mock: MagicMock,
) -> None:
"""Test configuring Tibber via GraphQL + OAuth."""
with patch("homeassistant.components.recorder.async_setup", return_value=True):
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
_mock_tibber(tibber_mock)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "graphql-token"}
)
assert result["type"] is FlowResultType.EXTERNAL_STEP
authorize_url = result["url"]
state = parse_qs(urlparse(authorize_url).query)["state"][0]
client = await hass_client_no_auth()
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
assert resp.status == HTTPStatus.OK
aioclient_mock.post(
TOKEN_URL,
json={
"access_token": "mock-access-token",
"refresh_token": "mock-refresh-token",
"token_type": "bearer",
"expires_in": 3600,
},
)
data_api_client_mock.get_userinfo = AsyncMock(
return_value={"name": "Mock Name"}
)
result = await hass.config_entries.flow.async_configure(result["flow_id"])
assert result["type"] is FlowResultType.CREATE_ENTRY
data = result["data"]
assert data[CONF_TOKEN]["access_token"] == "mock-access-token"
assert data[CONF_ACCESS_TOKEN] == "graphql-token"
assert data[AUTH_IMPLEMENTATION] == DOMAIN
assert result["title"] == "Mock Name"
async def test_data_api_abort_when_already_configured(
recorder_mock: Recorder,
hass: HomeAssistant,
tibber_mock: MagicMock,
) -> None:
"""Ensure only a single Data API entry can be configured."""
existing_entry = MockConfigEntry(
domain=DOMAIN,
data={
AUTH_IMPLEMENTATION: DOMAIN,
CONF_TOKEN: {"access_token": "existing"},
CONF_ACCESS_TOKEN: "stored-graphql",
},
unique_id="unique_user_id",
title="Existing",
)
existing_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
_mock_tibber(tibber_mock)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], {CONF_ACCESS_TOKEN: "new-token"}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"

View File

@@ -1,56 +1,178 @@
"""Test the Netatmo diagnostics."""
"""Test the Tibber diagnostics."""
from unittest.mock import patch
from unittest.mock import AsyncMock, MagicMock
import aiohttp
import pytest
import tibber
from homeassistant.components.recorder import Recorder
from homeassistant.components.tibber.diagnostics import (
async_get_config_entry_diagnostics,
)
from homeassistant.core import HomeAssistant
from homeassistant.setup import async_setup_component
from homeassistant.exceptions import ConfigEntryAuthFailed
from .conftest import create_tibber_device
from .test_common import mock_get_homes
from tests.common import MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
from tests.typing import ClientSessionGenerator
async def test_entry_diagnostics(
async def test_entry_diagnostics_empty(
recorder_mock: Recorder,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
config_entry,
config_entry: MockConfigEntry,
mock_tibber_setup: MagicMock,
) -> None:
"""Test config entry diagnostics."""
with patch(
"tibber.Tibber.update_info",
return_value=None,
):
assert await async_setup_component(hass, "tibber", {})
"""Test config entry diagnostics with no homes."""
tibber_mock = mock_tibber_setup
tibber_mock.get_homes.return_value = []
result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
assert isinstance(result, dict)
assert "homes" in result
assert "devices" in result
assert result["homes"] == []
assert result["devices"] == []
async def test_entry_diagnostics_with_homes(
recorder_mock: Recorder,
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
config_entry: MockConfigEntry,
mock_tibber_setup: MagicMock,
) -> None:
"""Test config entry diagnostics with homes."""
tibber_mock = mock_tibber_setup
tibber_mock.get_homes.side_effect = mock_get_homes
result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
assert isinstance(result, dict)
assert "homes" in result
assert "devices" in result
homes = result["homes"]
assert isinstance(homes, list)
assert len(homes) == 1
home = homes[0]
assert "last_data_timestamp" in home
assert "has_active_subscription" in home
assert "has_real_time_consumption" in home
assert "last_cons_data_timestamp" in home
assert "country" in home
assert home["has_active_subscription"] is True
assert home["has_real_time_consumption"] is False
assert home["country"] == "NO"
async def test_data_api_diagnostics_no_data(
recorder_mock: Recorder,
hass: HomeAssistant,
config_entry: MockConfigEntry,
data_api_client_mock: MagicMock,
setup_credentials: None,
) -> None:
"""Test Data API diagnostics when coordinator has no data."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
with patch(
"tibber.Tibber.get_homes",
return_value=[],
):
result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
data_api_client_mock.get_all_devices.assert_awaited_once()
data_api_client_mock.update_devices.assert_awaited_once()
assert result == {
"homes": [],
result = await async_get_config_entry_diagnostics(hass, config_entry)
assert isinstance(result, dict)
assert "homes" in result
assert "devices" in result
assert isinstance(result["homes"], list)
assert isinstance(result["devices"], list)
assert result["devices"] == []
async def test_data_api_diagnostics_with_devices(
recorder_mock: Recorder,
hass: HomeAssistant,
config_entry: MockConfigEntry,
data_api_client_mock: MagicMock,
setup_credentials: None,
) -> None:
"""Test Data API diagnostics with successful device retrieval."""
devices = {
"device-1": create_tibber_device(
device_id="device-1",
name="Device 1",
brand="Tibber",
model="Test Model",
),
"device-2": create_tibber_device(
device_id="device-2",
name="Device 2",
brand="Tibber",
model="Test Model",
),
}
with patch(
"tibber.Tibber.get_homes",
side_effect=mock_get_homes,
):
result = await get_diagnostics_for_config_entry(hass, hass_client, config_entry)
data_api_client_mock.get_all_devices = AsyncMock(return_value=devices)
data_api_client_mock.update_devices = AsyncMock(return_value=devices)
assert result == {
"homes": [
{
"last_data_timestamp": "2016-01-01T12:48:57",
"has_active_subscription": True,
"has_real_time_consumption": False,
"last_cons_data_timestamp": "2016-01-01T12:44:57",
"country": "NO",
}
],
}
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
result = await async_get_config_entry_diagnostics(hass, config_entry)
assert isinstance(result, dict)
assert "homes" in result
assert "devices" in result
devices_list = result["devices"]
assert isinstance(devices_list, list)
assert len(devices_list) == 2
device_1 = next((d for d in devices_list if d["id"] == "device-1"), None)
assert device_1 is not None
assert device_1["name"] == "Device 1"
assert device_1["brand"] == "Tibber"
assert device_1["model"] == "Test Model"
device_2 = next((d for d in devices_list if d["id"] == "device-2"), None)
assert device_2 is not None
assert device_2["name"] == "Device 2"
assert device_2["brand"] == "Tibber"
assert device_2["model"] == "Test Model"
@pytest.mark.parametrize(
"exception",
[
ConfigEntryAuthFailed("Auth failed"),
TimeoutError(),
aiohttp.ClientError("Connection error"),
tibber.InvalidLoginError(401),
tibber.RetryableHttpExceptionError(503),
tibber.FatalHttpExceptionError(404),
],
)
async def test_data_api_diagnostics_exceptions(
recorder_mock: Recorder,
hass: HomeAssistant,
config_entry: MockConfigEntry,
tibber_mock: MagicMock,
setup_credentials: None,
exception: Exception,
) -> None:
"""Test Data API diagnostics with various exception scenarios."""
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
tibber_mock.get_homes.side_effect = exception
with pytest.raises(type(exception)):
await async_get_config_entry_diagnostics(hass, config_entry)

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