mirror of
https://github.com/home-assistant/core.git
synced 2026-01-13 10:38:20 +00:00
Compare commits
55 Commits
tibber_bin
...
2026.1.0b3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4d55939f53 | ||
|
|
e5e7546d49 | ||
|
|
e560795d04 | ||
|
|
15b0342bd7 | ||
|
|
8d05a5f3d4 | ||
|
|
358ad29b59 | ||
|
|
5c4f99b828 | ||
|
|
b3f123c715 | ||
|
|
85c2351af2 | ||
|
|
ec19529c99 | ||
|
|
d5ebd02afe | ||
|
|
37d82ab795 | ||
|
|
5d08481137 | ||
|
|
0861b7541d | ||
|
|
abf7078842 | ||
|
|
c4012fae4e | ||
|
|
d6082ab6c3 | ||
|
|
77367e415f | ||
|
|
6c006c68c1 | ||
|
|
026fdeb4ce | ||
|
|
1034218e6e | ||
|
|
a21062f502 | ||
|
|
2e157f1bc6 | ||
|
|
a697e63b8c | ||
|
|
d28d55c7db | ||
|
|
8863488286 | ||
|
|
53cfdef1ac | ||
|
|
42ea7ecbd6 | ||
|
|
d58d08c350 | ||
|
|
65a259b9df | ||
|
|
cbfbfbee13 | ||
|
|
e503b37ddc | ||
|
|
217eef39f3 | ||
|
|
dcdbce9b21 | ||
|
|
71db8fe185 | ||
|
|
9b96cb66d5 | ||
|
|
78bccbbbc2 | ||
|
|
b0a8f9575c | ||
|
|
61104a9970 | ||
|
|
8d13dbdd0c | ||
|
|
9afb41004e | ||
|
|
cdd542f6e6 | ||
|
|
f520686002 | ||
|
|
e4d09bb615 | ||
|
|
10f6ccf6cc | ||
|
|
d9fa67b16f | ||
|
|
cf228ae02b | ||
|
|
cb4d62ab9a | ||
|
|
d2f75aec04 | ||
|
|
a609fbc07b | ||
|
|
1b9c7ae0ac | ||
|
|
492f2117fb | ||
|
|
2346f83635 | ||
|
|
8925bfb182 | ||
|
|
8f2b1f0eff |
@@ -13,5 +13,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyairobotrest"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["pyairobotrest==0.1.0"]
|
||||
"requirements": ["pyairobotrest==0.2.0"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==10.0.0"]
|
||||
"requirements": ["aioamazondevices==11.0.2"]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
],
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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/"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
"trigger": "mdi:air-humidifier-off"
|
||||
},
|
||||
"turned_on": {
|
||||
"trigger": "mdi:air-humidifier-on"
|
||||
"trigger": "mdi:air-humidifier"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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%]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"update_failed": {
|
||||
"message": "Update of data from the web site failed: {error}"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eternalegypt"],
|
||||
"requirements": ["eternalegypt==0.0.16"]
|
||||
"requirements": ["eternalegypt==0.0.18"]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -4,3 +4,5 @@ DOMAIN = "portainer"
|
||||
DEFAULT_NAME = "Portainer"
|
||||
|
||||
ENDPOINT_STATUS_DOWN = 2
|
||||
|
||||
CONTAINER_STATE_RUNNING = "running"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyportainer==1.0.17"]
|
||||
"requirements": ["pyportainer==1.0.19"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
15
homeassistant/components/tibber/application_credentials.py
Normal file
15
homeassistant/components/tibber/application_credentials.py
Normal 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,
|
||||
)
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)),
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"velbus-protocol"
|
||||
],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["velbus-aio==2025.12.0"],
|
||||
"requirements": ["velbus-aio==2026.1.1"],
|
||||
"usb": [
|
||||
{
|
||||
"pid": "0B1B",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyvlx"],
|
||||
"requirements": ["pyvlx==0.2.26"]
|
||||
"requirements": ["pyvlx==0.2.27"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiowebdav2"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["aiowebdav2==0.4.6"]
|
||||
"requirements": ["aiowebdav2==0.5.0"]
|
||||
}
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -39,6 +39,7 @@ APPLICATION_CREDENTIALS = [
|
||||
"spotify",
|
||||
"tesla_fleet",
|
||||
"teslemetry",
|
||||
"tibber",
|
||||
"twitch",
|
||||
"volvo",
|
||||
"watts",
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
8
requirements.txt
generated
@@ -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
28
requirements_all.txt
generated
@@ -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
|
||||
|
||||
28
requirements_test_all.txt
generated
28
requirements_test_all.txt
generated
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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'>,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
717
tests/components/shopping_list/snapshots/test_init.ambr
Normal file
717
tests/components/shopping_list/snapshots/test_init.ambr
Normal 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',
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user