Compare commits

..

55 Commits

Author SHA1 Message Date
Jan Bouwhuis
1244d8aa33 Fix reolink brightness scaling (#160106) 2026-01-01 21:56:35 +01:00
Pete Sage
38c37ab33c Improve Sonos wait to unjoin timeout (#160011) 2026-01-01 20:21:25 +01:00
Willem-Jan van Rootselaar
1636eab2e8 Add schema validation for set_hot_water_schedule service (#159990) 2026-01-01 20:16:54 +01:00
Miguel Camba
737a5811a9 Update voluptuous and voluptuous-openapi (#160073) 2026-01-01 20:07:06 +01:00
Austin Mroczek
5f2da20319 Bump total_connect_client to 2025.12.2 (#160075) 2026-01-01 20:02:56 +01:00
Michael Hansen
2aed4fb8e9 Bump intents to 2026.1.1 (#160099) 2026-01-01 19:58:37 +01:00
Lukas
2b10dc4545 Add reconfiguration flow to pooldose (#159978)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-01-01 17:20:33 +01:00
Maikel Punie
b5d22a63bb Velbus quality docs updates (#160092) 2026-01-01 17:02:30 +01:00
Maikel Punie
e8e19f47cd Velbus Exception translations (#159627)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-01-01 16:51:39 +01:00
Maikel Punie
97e6643cd7 Bump velbusaio to 2026.1.0 (#160087) 2026-01-01 16:50:28 +01:00
Ben Wolstencroft
ee4bb0eef5 Add support for health_overview API endpoint to Tractive integration (#157960)
Co-authored-by: Maciej Bieniek <bieniu@users.noreply.github.com>
2026-01-01 13:06:24 +01:00
Maikel Punie
f82bb8f0b8 Use brightness scale in velbus light (#160041) 2026-01-01 13:03:52 +01:00
cdnninja
79b368cfc3 add description to string vesync (#160003) 2025-12-31 22:20:50 +01:00
cdnninja
6da4a006f2 Add Auto Off Switch to VeSync (#160070) 2025-12-31 22:17:33 +01:00
Allen Porter
e5f3ccb38d Improve roborock test accuracy/robustness (#160021) 2025-12-31 16:32:53 +01:00
tronikos
560b91b93b Filter out duplicate voices without language code in Google Cloud (#160046) 2025-12-31 16:30:53 +01:00
Pete Sage
edd9f50562 bump soco to 0.30.14 for Sonos (#160050) 2025-12-31 16:25:55 +01:00
Paul Tarjan
a4b2e84b03 Fix Hikvision thread safety issue when calling async_write_ha_state (#160027) 2025-12-31 15:52:41 +01:00
rlippmann
9da07c2058 remove domain and service slots from Service object (#160039) 2025-12-31 13:34:02 +01:00
Simone Chemelli
8de6785182 Bump aioamazondevices to 11.0.2 (#160016)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-12-31 12:31:32 +01:00
Anders Melchiorsen
77f6fa8116 Bump eternalegypt to 0.0.18 (#160006) 2025-12-31 10:57:58 +01:00
Anders Melchiorsen
6b6f338e7e Fix netgear_lte unloading (#160008) 2025-12-31 10:53:24 +01:00
David Knowles
aa995fb590 Use WATER device_class for Hydrawise sensors (#160018) 2025-12-31 10:47:48 +01:00
Anders Melchiorsen
f0fee87b9e Move async_setup_services to async_setup for netgear_lte (#160007) 2025-12-31 10:43:59 +01:00
Erwin Douna
56ab3bf59b Bump pyfirefly 0.1.10 (#160028) 2025-12-31 09:04:40 +01:00
Luke Lashley
24e2720924 Don't prefer cache for Roborock device fetching (#160022) 2025-12-30 13:21:54 -08:00
Erwin Douna
bacc2f00af Bump portainer 1.0.19 (#160014) 2025-12-30 21:13:24 +01:00
Manu
6de2d6810b Convert store image URLs to https in Xbox media resolver (#160015) 2025-12-30 21:10:51 +01:00
Allen Porter
de07833d92 Update roborock binary sensor tests with snapshots (#159981) 2025-12-30 19:36:32 +01:00
Matthias Alphart
b4eff231c3 Update knx-frontend to 2025.12.30.151231 (#159999) 2025-12-30 18:49:02 +01:00
Luke Lashley
98fea46eea Add support for vacuum entity for Roborock Q7 (#159966) 2025-12-30 07:26:18 -08:00
divers33
18e8821891 Add podcast favorites support to Sonos media browser (#159961)
Co-authored-by: divers33 <divers33@users.noreply.github.com>
2025-12-30 15:14:53 +01:00
Sab44
cc2377d44d Bump librehardwaremonitor-api to version 1.7.2 (#159987) 2025-12-30 12:18:50 +01:00
doomsniper09
8370c6abfb Accept integer coordinates in has_location helper (#159835)
Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-12-30 12:06:23 +01:00
Panda-NZ
2d1a672de5 Add ambient temperature sensor to ToGrill (#159798) 2025-12-30 09:44:23 +01:00
Ernst Klamer
75ea42a834 bump xiaomi-ble to 1.4.1 (#159954) 2025-12-30 00:12:45 +01:00
Lukas
45491e17cd Pooldose Diagnostics (#159965) 2025-12-29 23:03:13 +01:00
Stefan H.
b994f03391 Migrate traccar_server to use entry.runtime_data (#156065)
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 22:16:01 +01:00
Kamil Breguła
473cb59013 Add translation of exceptions in met (#155765)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 22:12:40 +01:00
J. Nick Koston
9302926d99 Bump aioesphomeapi to 43.9.1 (#159960) 2025-12-29 11:09:37 -10:00
Branden Cash
d92516b7c9 Implement reconfigure config flow in SRP energy (#151542)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 21:52:25 +01:00
Luke Lashley
5b561213d3 Bump Python-Roborock to 4.1.0 (#159963) 2025-12-29 21:52:13 +01:00
Erwin Douna
0a16bd4919 Portainer fix stopped container for stats (#159964) 2025-12-29 21:51:46 +01:00
Michael
f74a6e2625 Record current Feedreader integration quality scale and set to silver (#143179)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 21:36:23 +01:00
Joost Lekkerkerker
ecc271409a Small cleanup in Feedreader (#159962) 2025-12-29 21:31:25 +01:00
Michael
1f63bc3231 Record current Synology DSM integration quality scale (#141245)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-29 21:24:18 +01:00
Joost Lekkerkerker
78adeb837e Inject session in Switchbot cloud (#159942) 2025-12-29 21:18:34 +01:00
Joost Lekkerkerker
bfacf462bf Add integration_type service to nuheat (#159845) 2025-12-29 21:12:23 +01:00
Joost Lekkerkerker
771d40dbf6 Add integration_type hub to permobil (#159872) 2025-12-29 21:12:05 +01:00
Joost Lekkerkerker
8e441242ad Add integration_type hub to pooldose (#159880) 2025-12-29 21:11:46 +01:00
Joost Lekkerkerker
b8a4237ab1 Add integration_type hub to poolsense (#159881) 2025-12-29 21:11:17 +01:00
Joost Lekkerkerker
e92af1ee76 Add integration_type device to ps4 (#159892) 2025-12-29 21:10:52 +01:00
Matthias Alphart
e561c1cebb Fix KNX translation references (#159959) 2025-12-29 20:50:53 +01:00
Franck Nijhof
d77f82f8e8 Bump version to 2026.2.0dev0 (#159956) 2025-12-29 20:38:24 +01:00
Joost Lekkerkerker
fcc3598d7f Add integration_type device to netgear (#159816) 2025-12-29 21:14:58 +02:00
122 changed files with 7185 additions and 1343 deletions

View File

@@ -40,7 +40,7 @@ env:
CACHE_VERSION: 2
UV_CACHE_VERSION: 1
MYPY_CACHE_VERSION: 1
HA_SHORT_VERSION: "2026.1"
HA_SHORT_VERSION: "2026.2"
DEFAULT_PYTHON: "3.13.11"
ALL_PYTHON_VERSIONS: "['3.13.11', '3.14.2']"
# 10.3 is the oldest supported version

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,6 +19,9 @@ from .coordinator import FeedReaderCoordinator
LOGGER = logging.getLogger(__name__)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
ATTR_CONTENT = "content"
ATTR_DESCRIPTION = "description"
ATTR_LINK = "link"
@@ -42,16 +45,15 @@ class FeedReaderEvent(CoordinatorEntity[FeedReaderCoordinator], EventEntity):
_attr_event_types = [EVENT_FEEDREADER]
_attr_name = None
_attr_has_entity_name = True
_attr_translation_key = "latest_feed"
_unrecorded_attributes = frozenset(
{ATTR_CONTENT, ATTR_DESCRIPTION, ATTR_TITLE, ATTR_LINK}
)
coordinator: FeedReaderCoordinator
def __init__(self, coordinator: FeedReaderCoordinator) -> None:
"""Initialize the feedreader event."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_latest_feed"
self._attr_translation_key = "latest_feed"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
name=coordinator.config_entry.title,

View File

@@ -0,0 +1,94 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No custom actions are defined.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage:
status: todo
comment: missing test for uniqueness of feed URL.
config-flow:
status: todo
comment: missing data descriptions
dependency-transparency: done
docs-actions:
status: exempt
comment: No custom actions are defined.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: No custom actions are defined.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: No authentication support.
test-coverage:
status: done
comment: Can use freezer for skipping time instead
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: No discovery support.
discovery:
status: exempt
comment: No discovery support.
docs-data-update: done
docs-examples: done
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: Each config entry, represents one service.
entity-category: done
entity-device-class:
status: exempt
comment: Matches no available event entity class.
entity-disabled-by-default:
status: exempt
comment: Only one entity per config entry.
entity-translations: todo
exception-translations: todo
icon-translations: done
reconfiguration-flow: done
repair-issues:
status: done
comment: Only one repair-issue for yaml-import defined.
stale-devices:
status: exempt
comment: Each config entry, represents one service.
# Platinum
async-dependency:
status: todo
comment: feedparser lib is not async.
inject-websession:
status: todo
comment: feedparser lib doesn't take a session as argument.
strict-typing:
status: todo
comment: feedparser lib is not fully typed.

View File

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

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["pyfirefly==0.1.8"]
"requirements": ["pyfirefly==0.1.10"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"requirements": ["librehardwaremonitor-api==1.6.0"]
"requirements": ["librehardwaremonitor-api==1.7.2"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -114,32 +114,72 @@ class PooldoseConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if not user_input:
return self.async_show_form(
step_id="user",
data_schema=SCHEMA_DEVICE,
if user_input is not None:
host = user_input[CONF_HOST]
serial_number, api_versions, errors = await self._validate_host(host)
if errors:
return self.async_show_form(
step_id="user",
data_schema=SCHEMA_DEVICE,
errors=errors,
# Handle API version info for error display; pass version info when available
# or None when api_versions is None to avoid displaying version details
description_placeholders={
"api_version_is": api_versions.get("api_version_is") or "",
"api_version_should": api_versions.get("api_version_should")
or "",
}
if api_versions
else None,
)
await self.async_set_unique_id(serial_number, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"PoolDose {serial_number}",
data={CONF_HOST: host},
)
host = user_input[CONF_HOST]
serial_number, api_versions, errors = await self._validate_host(host)
if errors:
return self.async_show_form(
step_id="user",
data_schema=SCHEMA_DEVICE,
errors=errors,
# Handle API version info for error display; pass version info when available
# or None when api_versions is None to avoid displaying version details
description_placeholders={
"api_version_is": api_versions.get("api_version_is") or "",
"api_version_should": api_versions.get("api_version_should") or "",
}
if api_versions
else None,
)
await self.async_set_unique_id(serial_number, raise_on_progress=False)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"PoolDose {serial_number}",
data={CONF_HOST: host},
return self.async_show_form(
step_id="user",
data_schema=SCHEMA_DEVICE,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfigure to change the device host/IP for an existing entry."""
if user_input is not None:
host = user_input[CONF_HOST]
serial_number, api_versions, errors = await self._validate_host(host)
if errors:
return self.async_show_form(
step_id="reconfigure",
data_schema=SCHEMA_DEVICE,
errors=errors,
# Handle API version info for error display identical to other steps
description_placeholders={
"api_version_is": api_versions.get("api_version_is") or "",
"api_version_should": api_versions.get("api_version_should")
or "",
}
if api_versions
else None,
)
# Ensure new serial number matches the existing entry unique_id (serial number)
if serial_number != self._get_reconfigure_entry().unique_id:
return self.async_abort(reason="wrong_device")
# Update the existing config entry with the new host and schedule reload
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data_updates={CONF_HOST: host}
)
return self.async_show_form(
step_id="reconfigure",
# Pre-fill with current host from the entry being reconfigured
data_schema=self.add_suggested_values_to_schema(
SCHEMA_DEVICE, self._get_reconfigure_entry().data
),
)

View File

@@ -0,0 +1,34 @@
"""Diagnostics support for Pooldose."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from . import PooldoseConfigEntry
TO_REDACT = {
"IP",
"MAC",
"WIFI_SSID",
"AP_SSID",
"SERIAL_NUMBER",
"DEVICE_ID",
"OWNERID",
"NAME",
"GROUPNAME",
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: PooldoseConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"device_info": async_redact_data(coordinator.device_info, TO_REDACT),
"data": coordinator.data,
}

View File

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

View File

@@ -41,7 +41,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: done
@@ -60,7 +60,7 @@ rules:
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: This integration does not provide repair issues, as it is designed for a single PoolDose device with a fixed configuration.

View File

@@ -4,7 +4,9 @@
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_device_info": "Unable to retrieve device information",
"no_serial_number": "No serial number found on the device"
"no_serial_number": "No serial number found on the device",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"wrong_device": "The provided device does not match the configured device"
},
"error": {
"api_not_set": "API version not found in device response. Device firmware may not be compatible with this integration.",
@@ -20,6 +22,14 @@
"description": "A PoolDose device was found on your network at {ip} with MAC address {mac}.\n\nDo you want to add {name} to Home Assistant?",
"title": "Confirm DHCP discovered PoolDose device"
},
"reconfigure": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "[%key:component::pooldose::config::step::user::data_description::host%]"
}
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -552,6 +552,7 @@ class RoborockB01Q7UpdateCoordinator(RoborockDataUpdateCoordinatorB01):
RoborockB01Props.CLEANING_TIME,
RoborockB01Props.REAL_CLEAN_TIME,
RoborockB01Props.HYPA,
RoborockB01Props.WIND,
]
async def _async_update_data(

View File

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

View File

@@ -3,7 +3,7 @@
import logging
from typing import Any
from roborock.data import RoborockStateCode
from roborock.data import RoborockStateCode, SCWindMapping, WorkStatusMapping
from roborock.exceptions import RoborockException
from roborock.roborock_typing import RoborockCommand
import voluptuous as vol
@@ -24,8 +24,12 @@ from .const import (
GET_VACUUM_CURRENT_POSITION_SERVICE_NAME,
SET_VACUUM_GOTO_POSITION_SERVICE_NAME,
)
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
from .entity import RoborockCoordinatedEntityV1
from .coordinator import (
RoborockB01Q7UpdateCoordinator,
RoborockConfigEntry,
RoborockDataUpdateCoordinator,
)
from .entity import RoborockCoordinatedEntityB01, RoborockCoordinatedEntityV1
_LOGGER = logging.getLogger(__name__)
@@ -57,6 +61,20 @@ STATE_CODE_TO_STATE = {
RoborockStateCode.device_offline: VacuumActivity.ERROR, # "Device offline"
}
Q7_STATE_CODE_TO_STATE = {
WorkStatusMapping.SLEEPING: VacuumActivity.IDLE,
WorkStatusMapping.WAITING_FOR_ORDERS: VacuumActivity.IDLE,
WorkStatusMapping.PAUSED: VacuumActivity.PAUSED,
WorkStatusMapping.DOCKING: VacuumActivity.RETURNING,
WorkStatusMapping.CHARGING: VacuumActivity.DOCKED,
WorkStatusMapping.SWEEP_MOPING: VacuumActivity.CLEANING,
WorkStatusMapping.SWEEP_MOPING_2: VacuumActivity.CLEANING,
WorkStatusMapping.MOPING: VacuumActivity.CLEANING,
WorkStatusMapping.UPDATING: VacuumActivity.DOCKED,
WorkStatusMapping.MOP_CLEANING: VacuumActivity.DOCKED,
WorkStatusMapping.MOP_AIRDRYING: VacuumActivity.DOCKED,
}
PARALLEL_UPDATES = 0
@@ -69,6 +87,11 @@ async def async_setup_entry(
async_add_entities(
RoborockVacuum(coordinator) for coordinator in config_entry.runtime_data.v1
)
async_add_entities(
RoborockQ7Vacuum(coordinator)
for coordinator in config_entry.runtime_data.b01
if isinstance(coordinator, RoborockB01Q7UpdateCoordinator)
)
platform = entity_platform.async_get_current_platform()
platform.async_register_entity_service(
@@ -241,3 +264,149 @@ class RoborockVacuum(RoborockCoordinatedEntityV1, StateVacuumEntity):
"x": robot_position.x,
"y": robot_position.y,
}
class RoborockQ7Vacuum(RoborockCoordinatedEntityB01, StateVacuumEntity):
"""General Representation of a Roborock vacuum."""
_attr_icon = "mdi:robot-vacuum"
_attr_supported_features = (
VacuumEntityFeature.PAUSE
| VacuumEntityFeature.STOP
| VacuumEntityFeature.RETURN_HOME
| VacuumEntityFeature.FAN_SPEED
| VacuumEntityFeature.SEND_COMMAND
| VacuumEntityFeature.LOCATE
| VacuumEntityFeature.STATE
| VacuumEntityFeature.START
)
_attr_translation_key = DOMAIN
_attr_name = None
coordinator: RoborockB01Q7UpdateCoordinator
def __init__(
self,
coordinator: RoborockB01Q7UpdateCoordinator,
) -> None:
"""Initialize a vacuum."""
StateVacuumEntity.__init__(self)
RoborockCoordinatedEntityB01.__init__(
self,
coordinator.duid_slug,
coordinator,
)
@property
def fan_speed_list(self) -> list[str]:
"""Get the list of available fan speeds."""
return SCWindMapping.keys()
@property
def activity(self) -> VacuumActivity | None:
"""Return the status of the vacuum cleaner."""
if self.coordinator.data.status is not None:
return Q7_STATE_CODE_TO_STATE.get(self.coordinator.data.status)
return None
@property
def fan_speed(self) -> str | None:
"""Return the fan speed of the vacuum cleaner."""
return self.coordinator.data.wind_name
async def async_start(self) -> None:
"""Start the vacuum."""
try:
await self.coordinator.api.start_clean()
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={
"command": "start_clean",
},
) from err
async def async_pause(self) -> None:
"""Pause the vacuum."""
try:
await self.coordinator.api.pause_clean()
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={
"command": "pause_clean",
},
) from err
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the vacuum."""
try:
await self.coordinator.api.stop_clean()
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={
"command": "stop_clean",
},
) from err
async def async_return_to_base(self, **kwargs: Any) -> None:
"""Send vacuum back to base."""
try:
await self.coordinator.api.return_to_dock()
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={
"command": "return_to_dock",
},
) from err
async def async_locate(self, **kwargs: Any) -> None:
"""Locate vacuum."""
try:
await self.coordinator.api.find_me()
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={
"command": "find_me",
},
) from err
async def async_set_fan_speed(self, fan_speed: str, **kwargs: Any) -> None:
"""Set vacuum fan speed."""
try:
await self.coordinator.api.set_fan_speed(
SCWindMapping.from_value(fan_speed)
)
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={
"command": "set_fan_speed",
},
) from err
async def async_send_command(
self,
command: str,
params: dict[str, Any] | list[Any] | None = None,
**kwargs: Any,
) -> None:
"""Send a command to a vacuum cleaner."""
try:
await self.coordinator.api.send(command, params)
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={
"command": command,
},
) from err

View File

@@ -37,6 +37,7 @@ SONOS_RADIO = "radio"
SONOS_SHARE = "share"
SONOS_OTHER_ITEM = "other items"
SONOS_AUDIO_BOOK = "audio book"
SONOS_PODCAST = "podcast"
MEDIA_TYPE_DIRECTORY = MediaClass.DIRECTORY
@@ -66,6 +67,7 @@ SONOS_TO_MEDIA_CLASSES = {
SONOS_COMPOSER: MediaClass.COMPOSER,
SONOS_GENRE: MediaClass.GENRE,
SONOS_PLAYLISTS: MediaClass.PLAYLIST,
SONOS_PODCAST: MediaClass.PODCAST,
SONOS_TRACKS: MediaClass.TRACK,
SONOS_SHARE: MediaClass.DIRECTORY,
"object.container": MediaClass.DIRECTORY,
@@ -75,6 +77,7 @@ SONOS_TO_MEDIA_CLASSES = {
"object.container.person.musicArtist": MediaClass.ARTIST,
"object.container.playlistContainer.sameArtist": MediaClass.ARTIST,
"object.container.playlistContainer": MediaClass.PLAYLIST,
"object.container.podcast": MediaClass.PODCAST,
"object.item": MediaClass.TRACK,
"object.item.audioItem.musicTrack": MediaClass.TRACK,
"object.item.audioItem.audioBroadcast": MediaClass.GENRE,
@@ -88,6 +91,7 @@ SONOS_TO_MEDIA_TYPES = {
SONOS_COMPOSER: MediaType.COMPOSER,
SONOS_GENRE: MediaType.GENRE,
SONOS_PLAYLISTS: MediaType.PLAYLIST,
SONOS_PODCAST: MediaType.PODCAST,
SONOS_TRACKS: MediaType.TRACK,
"object.container": MEDIA_TYPE_DIRECTORY,
"object.container.album.musicAlbum": MediaType.ALBUM,
@@ -96,6 +100,7 @@ SONOS_TO_MEDIA_TYPES = {
"object.container.person.musicArtist": MediaType.ARTIST,
"object.container.playlistContainer.sameArtist": MediaType.ARTIST,
"object.container.playlistContainer": MediaType.PLAYLIST,
"object.container.podcast": MediaType.PODCAST,
"object.item.audioItem.musicTrack": MediaType.TRACK,
"object.item.audioItem.audioBook": MediaType.TRACK,
}
@@ -125,6 +130,7 @@ SONOS_TYPES_MAPPING = {
"object.container.person.musicArtist": SONOS_ALBUM_ARTIST,
"object.container.playlistContainer.sameArtist": SONOS_ARTIST,
"object.container.playlistContainer": SONOS_PLAYLISTS,
"object.container.podcast": SONOS_PODCAST,
"object.item": SONOS_OTHER_ITEM,
"object.item.audioItem.musicTrack": SONOS_TRACKS,
"object.item.audioItem.audioBroadcast": SONOS_RADIO,
@@ -149,6 +155,7 @@ PLAYABLE_MEDIA_TYPES = [
MediaType.CONTRIBUTING_ARTIST,
MediaType.GENRE,
MediaType.PLAYLIST,
MediaType.PODCAST,
MediaType.TRACK,
]

View File

@@ -12,7 +12,7 @@
"quality_scale": "bronze",
"requirements": [
"defusedxml==0.7.1",
"soco==0.30.13",
"soco==0.30.14",
"sonos-websocket==0.1.3"
],
"ssdp": [

View File

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

View File

@@ -7,9 +7,14 @@ from typing import Any
from srpenergy.client import SrpEnergyClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .const import CONF_IS_TOU, DOMAIN, LOGGER
@@ -40,52 +45,71 @@ class SRPEnergyConfigFlow(ConfigFlow, domain=DOMAIN):
VERSION = 1
@callback
def _show_form(self, errors: dict[str, Any]) -> ConfigFlowResult:
"""Show the form to the user."""
LOGGER.debug("Show Form")
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(
CONF_NAME, default=self.hass.config.location_name
): str,
vol.Required(CONF_ID): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_IS_TOU, default=False): bool,
}
),
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle a flow initialized by the user."""
LOGGER.debug("Config entry")
errors: dict[str, str] = {}
if not user_input:
return self._show_form(errors)
if user_input:
try:
await validate_input(self.hass, user_input)
except ValueError:
# Thrown when the account id is malformed
errors["base"] = "invalid_account"
except InvalidAuth:
errors["base"] = "invalid_auth"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
else:
await self.async_set_unique_id(user_input[CONF_ID])
if self.source == SOURCE_USER:
self._abort_if_unique_id_configured()
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
try:
await validate_input(self.hass, user_input)
except ValueError:
# Thrown when the account id is malformed
errors["base"] = "invalid_account"
return self._show_form(errors)
except InvalidAuth:
errors["base"] = "invalid_auth"
return self._show_form(errors)
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
return self.async_abort(reason="unknown")
if self.source == SOURCE_USER:
return self.async_create_entry(
title=user_input[CONF_NAME],
data=user_input,
)
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data=user_input,
)
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
data_schema=vol.Schema(
{
vol.Required(CONF_ID): (
str
if self.source == SOURCE_USER
else self._get_reconfigure_entry().data[CONF_ID]
),
vol.Required(
CONF_NAME, default=self.hass.config.location_name
): str,
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Optional(CONF_IS_TOU, default=False): bool,
}
),
suggested_values=(
user_input or self._get_reconfigure_entry().data
if self.source == SOURCE_RECONFIGURE
else None
),
),
errors=errors,
)
await self.async_set_unique_id(user_input[CONF_ID])
self._abort_if_unique_id_configured()
return self.async_create_entry(title=user_input[CONF_NAME], data=user_input)
async def async_step_reconfigure(
self, user_input: dict[str, Any]
) -> ConfigFlowResult:
"""Handle reconfiguration."""
return await self.async_step_user()
class InvalidAuth(HomeAssistantError):

View File

@@ -2,6 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {

View File

@@ -20,6 +20,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_WEBHOOK_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN, ENTRY_TITLE
from .coordinator import SwitchBotCoordinator
@@ -309,7 +310,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
token = entry.data[CONF_API_TOKEN]
secret = entry.data[CONF_API_KEY]
api = SwitchBotAPI(token=token, secret=secret)
api = SwitchBotAPI(
token=token, secret=secret, session=async_get_clientsession(hass)
)
try:
devices = await api.list_devices()
except SwitchBotAuthenticationError as ex:

View File

@@ -0,0 +1,92 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: todo
config-flow:
status: todo
comment: |
`test_user` initializes flow with `None` data
`test_user` imports a fixture that already patches, but then patches again
`test_user` doesn't continue the old flow but creates a second one
`test_user` can be parametrized to test the false SSL part
`test_user_2sa` directly initialized the flow with form data
Flows should end in CREATE_ENTRY or ABORT
dependency-transparency: done
docs-actions: todo
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name:
status: todo
comment: button entities missing
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable:
status: done
comment: |
Handled by coordinator.
parallel-updates: todo
reauthentication-flow: done
test-coverage:
status: todo
comment: |
consts.py -> const.py
fixture could be autospecced and also be combined with the config flow one
Consider creating a fixture of the mock config entry
# Gold
devices:
status: done
comment: Could add serial number to camera device
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices:
status: todo
comment: cameras and disks can be replaced and removed
entity-category:
status: todo
comment: CPU load sounds like diagnostic data
entity-device-class: done
entity-disabled-by-default: done
entity-translations:
status: todo
comment: button still has names, can use placeholders
exception-translations: todo
icon-translations:
status: todo
comment: button still has icons
reconfiguration-flow: todo
repair-issues: done
stale-devices:
status: todo
comment: see dynamic-devices
# Platinum
async-dependency: done
inject-websession: done
strict-typing:
status: done
comment: Would be nice if we can get rid of getattr

View File

@@ -19,7 +19,7 @@ from homeassistant.const import CONF_ADDRESS, CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import AbortFlow
from .const import CONF_PROBE_COUNT, DOMAIN
from .const import CONF_HAS_AMBIENT, CONF_PROBE_COUNT, DOMAIN
from .coordinator import LOGGER
_TIMEOUT = 10
@@ -48,6 +48,7 @@ async def read_config_data(
CONF_MODEL: info.name,
CONF_ADDRESS: info.address,
CONF_PROBE_COUNT: packet_a0.probe_count,
CONF_HAS_AMBIENT: packet_a0.ambient,
}

View File

@@ -5,4 +5,5 @@ DOMAIN = "togrill"
MAX_PROBE_COUNT = 6
CONF_PROBE_COUNT = "probe_count"
CONF_HAS_AMBIENT = "has_ambient"
CONF_VERSION = "version"

View File

@@ -20,7 +20,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import ToGrillConfigEntry
from .const import CONF_PROBE_COUNT, MAX_PROBE_COUNT
from .const import CONF_HAS_AMBIENT, CONF_PROBE_COUNT, MAX_PROBE_COUNT
from .coordinator import ToGrillCoordinator
from .entity import ToGrillEntity
@@ -63,6 +63,27 @@ def _get_temperature_description(probe_number: int):
)
def _get_ambient_temperature(packet: Packet) -> StateType:
"""Extract ambient temperature from packet.
The ambient temperature is the last value in the temperatures list
when the device has an ambient sensor.
"""
assert isinstance(packet, PacketA1Notify)
if not packet.temperatures:
return None
# Ambient is always the last temperature value
temperature = packet.temperatures[-1]
if temperature is None:
return None
return temperature
def _ambient_supported(config: Mapping[str, Any]) -> bool:
"""Check if ambient sensor is supported."""
return config.get(CONF_HAS_AMBIENT, False)
ENTITY_DESCRIPTIONS = (
ToGrillSensorEntityDescription(
key="battery",
@@ -78,6 +99,17 @@ ENTITY_DESCRIPTIONS = (
_get_temperature_description(probe_number)
for probe_number in range(1, MAX_PROBE_COUNT + 1)
],
ToGrillSensorEntityDescription(
key="ambient_temperature",
translation_key="ambient_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
packet_type=PacketA1Notify.type,
packet_extract=_get_ambient_temperature,
entity_supported=_ambient_supported,
),
)

View File

@@ -98,6 +98,11 @@
"well_done": "Well done"
}
}
},
"sensor": {
"ambient_temperature": {
"name": "Ambient temperature"
}
}
},
"exceptions": {

View File

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

View File

@@ -24,7 +24,7 @@ from homeassistant.helpers.aiohttp_client import async_create_clientsession
from homeassistant.helpers.event import async_track_time_interval
from .const import CONF_EVENTS, DOMAIN
from .coordinator import TraccarServerCoordinator
from .coordinator import TraccarServerConfigEntry, TraccarServerCoordinator
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@@ -33,7 +33,9 @@ PLATFORMS: list[Platform] = [
]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_setup_entry(
hass: HomeAssistant, entry: TraccarServerConfigEntry
) -> bool:
"""Set up Traccar Server from a config entry."""
if CONF_API_TOKEN not in entry.data:
raise ConfigEntryAuthFailed(
@@ -61,8 +63,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator.async_config_entry_first_refresh()
hass.data.setdefault(DOMAIN, {})
hass.data[DOMAIN][entry.entry_id] = coordinator
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(entry.add_update_listener(async_reload_entry))
@@ -86,14 +87,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(
hass: HomeAssistant, entry: TraccarServerConfigEntry
) -> bool:
"""Unload a config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
async def async_reload_entry(hass: HomeAssistant, entry: ConfigEntry) -> None:
async def async_reload_entry(
hass: HomeAssistant, entry: TraccarServerConfigEntry
) -> None:
"""Handle an options update."""
await hass.config_entries.async_reload(entry.entry_id)

View File

@@ -13,13 +13,11 @@ from homeassistant.components.binary_sensor import (
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import TraccarServerCoordinator
from .coordinator import TraccarServerConfigEntry, TraccarServerCoordinator
from .entity import TraccarServerEntity
@@ -54,18 +52,18 @@ TRACCAR_SERVER_BINARY_SENSOR_ENTITY_DESCRIPTIONS: tuple[
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: TraccarServerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up binary sensor entities."""
coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
TraccarServerBinarySensor(
coordinator=coordinator,
device=entry["device"],
device=device_entry["device"],
description=description,
)
for entry in coordinator.data.values()
for device_entry in coordinator.data.values()
for description in TRACCAR_SERVER_BINARY_SENSOR_ENTITY_DESCRIPTIONS
)

View File

@@ -35,6 +35,8 @@ from .const import (
)
from .helpers import get_device, get_first_geofence, get_geofence_ids
type TraccarServerConfigEntry = ConfigEntry[TraccarServerCoordinator]
class TraccarServerCoordinatorDataDevice(TypedDict):
"""Traccar Server coordinator data."""
@@ -51,12 +53,12 @@ type TraccarServerCoordinatorData = dict[int, TraccarServerCoordinatorDataDevice
class TraccarServerCoordinator(DataUpdateCoordinator[TraccarServerCoordinatorData]):
"""Class to manage fetching Traccar Server data."""
config_entry: ConfigEntry
config_entry: TraccarServerConfigEntry
def __init__(
self,
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: TraccarServerConfigEntry,
client: ApiClient,
) -> None:
"""Initialize global Traccar Server data updater."""

View File

@@ -5,25 +5,24 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.device_tracker import TrackerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import ATTR_CATEGORY, ATTR_TRACCAR_ID, ATTR_TRACKER, DOMAIN
from .coordinator import TraccarServerCoordinator
from .coordinator import TraccarServerConfigEntry
from .entity import TraccarServerEntity
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: TraccarServerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up device tracker entities."""
coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
TraccarServerDeviceTracker(coordinator, entry["device"])
for entry in coordinator.data.values()
TraccarServerDeviceTracker(coordinator, device_entry["device"])
for device_entry in coordinator.data.values()
)

View File

@@ -5,13 +5,11 @@ from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import REDACTED, async_redact_data
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ADDRESS, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr, entity_registry as er
from .const import DOMAIN
from .coordinator import TraccarServerCoordinator
from .coordinator import TraccarServerConfigEntry, TraccarServerCoordinator
KEYS_TO_REDACT = {
"area", # This is the polygon area of a geofence
@@ -39,10 +37,10 @@ def _entity_state(
async def async_get_config_entry_diagnostics(
hass: HomeAssistant,
config_entry: ConfigEntry,
config_entry: TraccarServerConfigEntry,
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator: TraccarServerCoordinator = hass.data[DOMAIN][config_entry.entry_id]
coordinator = config_entry.runtime_data
entity_registry = er.async_get(hass)
entities = er.async_entries_for_config_entry(
@@ -71,11 +69,11 @@ async def async_get_config_entry_diagnostics(
async def async_get_device_diagnostics(
hass: HomeAssistant,
entry: ConfigEntry,
entry: TraccarServerConfigEntry,
device: dr.DeviceEntry,
) -> dict[str, Any]:
"""Return device diagnostics."""
coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
entity_registry = er.async_get(hass)
entities = er.async_entries_for_device(
@@ -85,6 +83,7 @@ async def async_get_device_diagnostics(
)
await hass.config_entries.async_reload(entry.entry_id)
return async_redact_data(
{
"subscription_status": coordinator.client.subscription_status,

View File

@@ -14,14 +14,12 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfLength, UnitOfSpeed
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
from .coordinator import TraccarServerCoordinator
from .coordinator import TraccarServerConfigEntry, TraccarServerCoordinator
from .entity import TraccarServerEntity
@@ -82,18 +80,18 @@ TRACCAR_SERVER_SENSOR_ENTITY_DESCRIPTIONS: tuple[
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
entry: TraccarServerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensor entities."""
coordinator: TraccarServerCoordinator = hass.data[DOMAIN][entry.entry_id]
coordinator = entry.runtime_data
async_add_entities(
TraccarServerSensor(
coordinator=coordinator,
device=entry["device"],
device=device_entry["device"],
description=description,
)
for entry in coordinator.data.values()
for device_entry in coordinator.data.values()
for description in TRACCAR_SERVER_SENSOR_ENTITY_DESCRIPTIONS
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -24,10 +24,12 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.color import brightness_to_value, value_to_brightness
from . import VelbusConfigEntry
from .entity import VelbusEntity, api_call
BRIGHTNESS_SCALE = (1, 100)
PARALLEL_UPDATES = 0
@@ -65,7 +67,7 @@ class VelbusLight(VelbusEntity, LightEntity):
@property
def brightness(self) -> int:
"""Return the brightness of the light."""
return int((self._channel.get_dimmer_state() * 255) / 100)
return value_to_brightness(BRIGHTNESS_SCALE, self._channel.get_dimmer_state())
@api_call
async def async_turn_on(self, **kwargs: Any) -> None:
@@ -75,7 +77,10 @@ class VelbusLight(VelbusEntity, LightEntity):
if kwargs[ATTR_BRIGHTNESS] == 0:
brightness = 0
else:
brightness = max(int((kwargs[ATTR_BRIGHTNESS] * 100) / 255), 1)
brightness = max(
1,
int(brightness_to_value(BRIGHTNESS_SCALE, kwargs[ATTR_BRIGHTNESS])),
)
attr, *args = (
"set_dimmer_state",
brightness,

View File

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

View File

@@ -25,8 +25,8 @@ rules:
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: todo
integration-owner: done
log-when-unavailable: done
@@ -56,7 +56,7 @@ rules:
entity-device-class: todo
entity-disabled-by-default: done
entity-translations: todo
exception-translations: todo
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues:

View File

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

View File

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

View File

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

View File

@@ -64,6 +64,16 @@ SENSOR_DESCRIPTIONS: Final[tuple[VeSyncSwitchEntityDescription, ...]] = (
on_fn=lambda device: device.toggle_child_lock(True),
off_fn=lambda device: device.toggle_child_lock(False),
),
VeSyncSwitchEntityDescription(
key="auto_off_config",
is_on=lambda device: device.state.automatic_stop_config,
exists_fn=(
lambda device: rgetattr(device, "state.automatic_stop_config") is not None
),
translation_key="auto_off_config",
on_fn=lambda device: device.toggle_automatic_stop(True),
off_fn=lambda device: device.toggle_automatic_stop(False),
),
)

View File

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

View File

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

View File

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

View File

@@ -2412,10 +2412,8 @@ class Service:
__slots__ = [
"description_placeholders",
"domain",
"job",
"schema",
"service",
"supports_response",
]

View File

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

View File

@@ -19,8 +19,8 @@ def has_location(state: State) -> bool:
"""
return (
isinstance(state, State)
and isinstance(state.attributes.get(ATTR_LATITUDE), float)
and isinstance(state.attributes.get(ATTR_LONGITUDE), float)
and isinstance(state.attributes.get(ATTR_LATITUDE), (float, int))
and isinstance(state.attributes.get(ATTR_LONGITUDE), (float, int))
)

View File

@@ -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

View File

@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2026.1.0b0"
version = "2026.2.0.dev0"
license = "Apache-2.0"
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
description = "Open-source home automation platform running on Python 3."
@@ -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",

6
requirements.txt generated
View File

@@ -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

26
requirements_all.txt generated
View File

@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.4
# homeassistant.components.alexa_devices
aioamazondevices==10.0.0
aioamazondevices==11.0.2
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -252,7 +252,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==43.9.0
aioesphomeapi==43.9.1
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -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
@@ -1391,7 +1391,7 @@ libpyfoscamcgi==0.0.9
libpyvivotek==0.6.1
# homeassistant.components.libre_hardware_monitor
librehardwaremonitor-api==1.6.0
librehardwaremonitor-api==1.7.2
# homeassistant.components.mikrotik
librouteros==3.2.0
@@ -2046,7 +2046,7 @@ pyfibaro==0.8.3
pyfido==2.1.2
# homeassistant.components.firefly_iii
pyfirefly==0.1.8
pyfirefly==0.1.10
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.46
@@ -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.1.0
# homeassistant.components.smarttub
python-smarttub==0.0.46
@@ -2887,7 +2887,7 @@ smart-meter-texas==0.5.5
snapcast==2.3.6
# homeassistant.components.sonos
soco==0.30.13
soco==0.30.14
# homeassistant.components.solaredge_local
solaredge-local==0.2.3
@@ -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.0
# homeassistant.components.venstar
venstarcolortouch==0.21
@@ -3213,7 +3213,7 @@ wsdot==0.0.1
wyoming==1.7.2
# homeassistant.components.xiaomi_ble
xiaomi-ble==1.2.0
xiaomi-ble==1.4.1
# homeassistant.components.knx
xknx==3.13.0

View File

@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
aioairzone==1.0.4
# homeassistant.components.alexa_devices
aioamazondevices==10.0.0
aioamazondevices==11.0.2
# homeassistant.components.ambient_network
# homeassistant.components.ambient_station
@@ -243,7 +243,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==43.9.0
aioesphomeapi==43.9.1
# homeassistant.components.matrix
# homeassistant.components.slack
@@ -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
@@ -1222,7 +1222,7 @@ libpyfoscamcgi==0.0.9
libpyvivotek==0.6.1
# homeassistant.components.libre_hardware_monitor
librehardwaremonitor-api==1.6.0
librehardwaremonitor-api==1.7.2
# homeassistant.components.mikrotik
librouteros==3.2.0
@@ -1732,7 +1732,7 @@ pyfibaro==0.8.3
pyfido==2.1.2
# homeassistant.components.firefly_iii
pyfirefly==0.1.8
pyfirefly==0.1.10
# homeassistant.components.fireservicerota
pyfireservicerota==0.0.46
@@ -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.1.0
# homeassistant.components.smarttub
python-smarttub==0.0.46
@@ -2414,7 +2414,7 @@ smart-meter-texas==0.5.5
snapcast==2.3.6
# homeassistant.components.sonos
soco==0.30.13
soco==0.30.14
# homeassistant.components.solaredge
solaredge-web==0.0.1
@@ -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.0
# homeassistant.components.venstar
venstarcolortouch==0.21
@@ -2683,7 +2683,7 @@ wsdot==0.0.1
wyoming==1.7.2
# homeassistant.components.xiaomi_ble
xiaomi-ble==1.2.0
xiaomi-ble==1.4.1
# homeassistant.components.knx
xknx==3.13.0

View File

@@ -359,7 +359,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"fail2ban",
"familyhub",
"fastdotcom",
"feedreader",
"ffmpeg_motion",
"ffmpeg_noise",
"fibaro",
@@ -932,7 +931,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"switchmate",
"syncthing",
"synology_chat",
"synology_dsm",
"synology_srm",
"syslog",
"system_bridge",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,245 @@
# serializer version: 1
# name: test_entry_diagnostics
dict({
'data': dict({
'binary_sensor': dict({
'alarm_ofa_cl': dict({
'value': False,
}),
'alarm_ofa_orp': dict({
'value': False,
}),
'alarm_ofa_ph': dict({
'value': False,
}),
'flow_rate_alarm': dict({
'value': False,
}),
'orp_level_alarm': dict({
'value': False,
}),
'ph_level_alarm': dict({
'value': False,
}),
'pump_alarm': dict({
'value': True,
}),
'relay_alarm': dict({
'value': True,
}),
'relay_aux1': dict({
'value': False,
}),
'relay_aux2': dict({
'value': False,
}),
'relay_aux3': dict({
'value': False,
}),
}),
'number': dict({
'cl_target': dict({
'max': 65535,
'min': 0,
'step': 0.01,
'unit': 'ppm',
'value': 1,
}),
'ofa_cl_lower': dict({
'max': 10,
'min': 0,
'step': 0.1,
'unit': 'ppm',
'value': 0.2,
}),
'ofa_cl_upper': dict({
'max': 10,
'min': 0,
'step': 0.1,
'unit': 'ppm',
'value': 0.9,
}),
'ofa_orp_lower': dict({
'max': 1000,
'min': 0,
'step': 1,
'unit': 'mV',
'value': 600,
}),
'ofa_orp_upper': dict({
'max': 1000,
'min': 0,
'step': 1,
'unit': 'mV',
'value': 800,
}),
'ofa_ph_lower': dict({
'max': 14,
'min': 0,
'step': 0.1,
'unit': None,
'value': 6,
}),
'ofa_ph_upper': dict({
'max': 14,
'min': 0,
'step': 0.1,
'unit': None,
'value': 8,
}),
'orp_target': dict({
'max': 850,
'min': 400,
'step': 1,
'unit': 'mV',
'value': 680,
}),
'ph_target': dict({
'max': 8,
'min': 6,
'step': 0.1,
'unit': None,
'value': 6.5,
}),
}),
'select': dict({
'cl_type_dosing_method': dict({
'value': 'timed',
}),
'cl_type_dosing_set': dict({
'value': 'high',
}),
'flow_rate_unit': dict({
'value': 'L/s',
}),
'orp_type_dosing_method': dict({
'value': 'on_off',
}),
'orp_type_dosing_set': dict({
'value': 'low',
}),
'ph_type_dosing_method': dict({
'value': 'proportional',
}),
'ph_type_dosing_set': dict({
'value': 'acid',
}),
'water_meter_unit': dict({
'value': 'm3',
}),
}),
'sensor': dict({
'cl': dict({
'unit': 'ppm',
'value': 1.2,
}),
'cl_type_dosing': dict({
'unit': None,
'value': 'low',
}),
'flow_rate': dict({
'unit': 'L/s',
'value': 150,
}),
'ofa_orp_time': dict({
'unit': 'min',
'value': 0,
}),
'ofa_ph_time': dict({
'unit': 'min',
'value': 0,
}),
'orp': dict({
'unit': 'mV',
'value': 718,
}),
'orp_calibration_offset': dict({
'unit': 'mV',
'value': 0,
}),
'orp_calibration_slope': dict({
'unit': 'mV',
'value': 0.96,
}),
'orp_calibration_type': dict({
'unit': None,
'value': '1_point',
}),
'orp_type_dosing': dict({
'unit': None,
'value': 'low',
}),
'peristaltic_cl_dosing': dict({
'unit': None,
'value': 'off',
}),
'peristaltic_orp_dosing': dict({
'unit': None,
'value': 'proportional',
}),
'peristaltic_ph_dosing': dict({
'unit': None,
'value': 'proportional',
}),
'ph': dict({
'unit': None,
'value': 6.8,
}),
'ph_calibration_offset': dict({
'unit': 'mV',
'value': 8,
}),
'ph_calibration_slope': dict({
'unit': 'mV',
'value': 57.34,
}),
'ph_calibration_type': dict({
'unit': None,
'value': '2_points',
}),
'ph_type_dosing': dict({
'unit': None,
'value': 'alcalyne',
}),
'temperature': dict({
'unit': '°C',
'value': 25,
}),
'water_meter_total_permanent': dict({
'unit': 'm3',
'value': 12345.67,
}),
'water_meter_total_resettable': dict({
'unit': 'm3',
'value': 123.45,
}),
}),
'switch': dict({
'frequency_input': dict({
'value': False,
}),
'pause_dosing': dict({
'value': False,
}),
'pump_monitoring': dict({
'value': True,
}),
}),
}),
'device_info': dict({
'API_VERSION': 'v1/',
'DEVICE_ID': '**REDACTED**',
'FW_CODE': '539187',
'FW_VERSION': '1.30',
'GROUPNAME': '**REDACTED**',
'IP': '**REDACTED**',
'MAC': '',
'MODEL': 'POOL DOSE',
'MODEL_ID': 'PDPR1H1HAW100',
'NAME': '**REDACTED**',
'OWNERID': '**REDACTED**',
'SERIAL_NUMBER': '**REDACTED**',
'SW_VERSION': '2.10',
}),
})
# ---

View File

@@ -1,8 +1,10 @@
"""Test the PoolDose config flow."""
from datetime import timedelta
from typing import Any
from unittest.mock import AsyncMock
from freezegun.api import FrozenDateTimeFactory
import pytest
from homeassistant.components.pooldose.const import DOMAIN
@@ -14,7 +16,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .conftest import RequestStatus
from tests.common import MockConfigEntry
from tests.common import MockConfigEntry, async_fire_time_changed
async def test_full_flow(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
@@ -426,3 +428,80 @@ async def test_dhcp_preserves_existing_mac(
assert entry.data[CONF_HOST] == "192.168.0.123" # IP was updated
assert entry.data[CONF_MAC] == "existing11aabb" # MAC remains unchanged
assert entry.data[CONF_MAC] != "different22ccdd" # Not updated to new MAC
async def _start_reconfigure_flow(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, host_ip: str
) -> Any:
"""Initialize a reconfigure flow for PoolDose and submit new host."""
mock_config_entry.add_to_hass(hass)
reconfigure_result = await mock_config_entry.start_reconfigure_flow(hass)
assert reconfigure_result["type"] is FlowResultType.FORM
assert reconfigure_result["step_id"] == "reconfigure"
return await hass.config_entries.flow.async_configure(
reconfigure_result["flow_id"], {CONF_HOST: host_ip}
)
async def test_reconfigure_flow_success(
hass: HomeAssistant,
mock_pooldose_client: AsyncMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
freezer: FrozenDateTimeFactory,
) -> None:
"""Test successful reconfigure updates host and reloads entry."""
# Ensure the mocked device returns the same serial number as the
# config entry so the reconfigure flow matches the device
mock_pooldose_client.device_info = {"SERIAL_NUMBER": mock_config_entry.unique_id}
result = await _start_reconfigure_flow(hass, mock_config_entry, "192.168.0.200")
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
# Config entry should have updated host
assert mock_config_entry.data.get(CONF_HOST) == "192.168.0.200"
freezer.tick(timedelta(seconds=5))
async_fire_time_changed(hass)
await hass.async_block_till_done()
# Config entry should have updated host
entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert entry is not None
assert entry.data.get(CONF_HOST) == "192.168.0.200"
async def test_reconfigure_flow_cannot_connect(
hass: HomeAssistant,
mock_pooldose_client: AsyncMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure shows cannot_connect when device unreachable."""
mock_pooldose_client.connect.return_value = RequestStatus.HOST_UNREACHABLE
result = await _start_reconfigure_flow(hass, mock_config_entry, "192.168.0.200")
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
async def test_reconfigure_flow_wrong_device(
hass: HomeAssistant,
mock_pooldose_client: AsyncMock,
mock_setup_entry: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test reconfigure aborts when serial number doesn't match existing entry."""
# Return device info with different serial number
mock_pooldose_client.device_info = {"SERIAL_NUMBER": "OTHER123"}
result = await _start_reconfigure_flow(hass, mock_config_entry, "192.168.0.200")
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "wrong_device"

View File

@@ -0,0 +1,20 @@
"""Test Pooldose diagnostics."""
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
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(
hass: HomeAssistant,
hass_client: ClientSessionGenerator,
init_integration: MockConfigEntry,
snapshot: SnapshotAssertion,
) -> None:
"""Test config entry diagnostics."""
result = await get_diagnostics_for_config_entry(hass, hass_client, init_integration)
assert result == snapshot

View File

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

View File

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

View File

@@ -22,6 +22,7 @@ from roborock.data import (
RoborockBase,
RoborockDyadStateCode,
ValleyElectricityTimer,
WorkStatusMapping,
ZeoError,
ZeoState,
)
@@ -110,7 +111,33 @@ def create_zeo_trait() -> Mock:
def create_b01_q7_trait() -> Mock:
"""Create B01 Q7 trait for B01 devices."""
b01_trait = AsyncMock()
b01_trait.query_values.return_value = Q7_B01_PROPS
b01_trait._props_data = deepcopy(Q7_B01_PROPS)
async def query_values_side_effect(protocols):
return b01_trait._props_data
b01_trait.query_values = AsyncMock(side_effect=query_values_side_effect)
# Add API methods that update the state when called
async def start_clean_side_effect():
b01_trait._props_data.status = WorkStatusMapping.SWEEP_MOPING
async def pause_clean_side_effect():
b01_trait._props_data.status = WorkStatusMapping.PAUSED
async def stop_clean_side_effect():
b01_trait._props_data.status = WorkStatusMapping.WAITING_FOR_ORDERS
async def return_to_dock_side_effect():
b01_trait._props_data.status = WorkStatusMapping.DOCKING
b01_trait.start_clean = AsyncMock(side_effect=start_clean_side_effect)
b01_trait.pause_clean = AsyncMock(side_effect=pause_clean_side_effect)
b01_trait.stop_clean = AsyncMock(side_effect=stop_clean_side_effect)
b01_trait.return_to_dock = AsyncMock(side_effect=return_to_dock_side_effect)
b01_trait.find_me = AsyncMock()
b01_trait.set_fan_speed = AsyncMock()
b01_trait.send = AsyncMock()
return b01_trait
@@ -148,6 +175,20 @@ class FakeDevice(RoborockDevice):
"""Close the device."""
def set_trait_attributes(
trait: AsyncMock,
dataclass_template: RoborockBase,
init_none: bool = False,
) -> None:
"""Set attributes on a mock roborock trait."""
template_copy = deepcopy(dataclass_template)
for attr_name in dir(template_copy):
if attr_name.startswith("_"):
continue
attr_value = getattr(template_copy, attr_name) if not init_none else None
setattr(trait, attr_name, attr_value)
def make_mock_trait(
trait_spec: type[V1TraitMixin] | None = None,
dataclass_template: RoborockBase | None = None,
@@ -156,12 +197,14 @@ def make_mock_trait(
trait = AsyncMock(spec=trait_spec or V1TraitMixin)
if dataclass_template is not None:
# Copy all attributes and property methods (e.g. computed properties)
template_copy = deepcopy(dataclass_template)
for attr_name in dir(template_copy):
if attr_name.startswith("_"):
continue
setattr(trait, attr_name, getattr(template_copy, attr_name))
trait.refresh = AsyncMock()
# on the first call to refresh(). The object starts uninitialized.
set_trait_attributes(trait, dataclass_template, init_none=True)
async def refresh() -> None:
if dataclass_template is not None:
set_trait_attributes(trait, dataclass_template)
trait.refresh = AsyncMock(side_effect=refresh)
return trait

View File

@@ -0,0 +1,491 @@
# serializer version: 1
# name: test_binary_sensors[binary_sensor.roborock_s7_2_charging-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.roborock_s7_2_charging',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.BATTERY_CHARGING: 'battery_charging'>,
'original_icon': None,
'original_name': 'Charging',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'battery_charging_device_2',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_2_charging-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery_charging',
'friendly_name': 'Roborock S7 2 Charging',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.roborock_s7_2_charging',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_2_cleaning-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.roborock_s7_2_cleaning',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'Cleaning',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'in_cleaning',
'unique_id': 'in_cleaning_device_2',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_2_cleaning-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'Roborock S7 2 Cleaning',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.roborock_s7_2_cleaning',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_2_mop_attached-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.roborock_s7_2_mop_attached',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'Mop attached',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'mop_attached',
'unique_id': 'water_box_carriage_status_device_2',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_2_mop_attached-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'Roborock S7 2 Mop attached',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.roborock_s7_2_mop_attached',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_2_water_box_attached-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.roborock_s7_2_water_box_attached',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'Water box attached',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'water_box_attached',
'unique_id': 'water_box_status_device_2',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_2_water_box_attached-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'Roborock S7 2 Water box attached',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.roborock_s7_2_water_box_attached',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_2_water_shortage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.roborock_s7_2_water_shortage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Water shortage',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'water_shortage',
'unique_id': 'water_shortage_device_2',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_2_water_shortage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Roborock S7 2 Water shortage',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.roborock_s7_2_water_shortage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_charging-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.roborock_s7_maxv_charging',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.BATTERY_CHARGING: 'battery_charging'>,
'original_icon': None,
'original_name': 'Charging',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': 'battery_charging_abc123',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_charging-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'battery_charging',
'friendly_name': 'Roborock S7 MaxV Charging',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.roborock_s7_maxv_charging',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_cleaning-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.roborock_s7_maxv_cleaning',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.RUNNING: 'running'>,
'original_icon': None,
'original_name': 'Cleaning',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'in_cleaning',
'unique_id': 'in_cleaning_abc123',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_cleaning-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'running',
'friendly_name': 'Roborock S7 MaxV Cleaning',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.roborock_s7_maxv_cleaning',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_mop_attached-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.roborock_s7_maxv_mop_attached',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'Mop attached',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'mop_attached',
'unique_id': 'water_box_carriage_status_abc123',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_mop_attached-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'Roborock S7 MaxV Mop attached',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.roborock_s7_maxv_mop_attached',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_water_box_attached-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.roborock_s7_maxv_water_box_attached',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.CONNECTIVITY: 'connectivity'>,
'original_icon': None,
'original_name': 'Water box attached',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'water_box_attached',
'unique_id': 'water_box_status_abc123',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_water_box_attached-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'connectivity',
'friendly_name': 'Roborock S7 MaxV Water box attached',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.roborock_s7_maxv_water_box_attached',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_water_shortage-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'binary_sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'binary_sensor.roborock_s7_maxv_water_shortage',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
}),
'original_device_class': <BinarySensorDeviceClass.PROBLEM: 'problem'>,
'original_icon': None,
'original_name': 'Water shortage',
'platform': 'roborock',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'water_shortage',
'unique_id': 'water_shortage_abc123',
'unit_of_measurement': None,
})
# ---
# name: test_binary_sensors[binary_sensor.roborock_s7_maxv_water_shortage-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'problem',
'friendly_name': 'Roborock S7 MaxV Water shortage',
}),
'context': <ANY>,
'entity_id': 'binary_sensor.roborock_s7_maxv_water_shortage',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'off',
})
# ---

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