mirror of
https://github.com/home-assistant/core.git
synced 2026-01-02 05:08:14 +00:00
Compare commits
55 Commits
2026.1.0b0
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1244d8aa33 | ||
|
|
38c37ab33c | ||
|
|
1636eab2e8 | ||
|
|
737a5811a9 | ||
|
|
5f2da20319 | ||
|
|
2aed4fb8e9 | ||
|
|
2b10dc4545 | ||
|
|
b5d22a63bb | ||
|
|
e8e19f47cd | ||
|
|
97e6643cd7 | ||
|
|
ee4bb0eef5 | ||
|
|
f82bb8f0b8 | ||
|
|
79b368cfc3 | ||
|
|
6da4a006f2 | ||
|
|
e5f3ccb38d | ||
|
|
560b91b93b | ||
|
|
edd9f50562 | ||
|
|
a4b2e84b03 | ||
|
|
9da07c2058 | ||
|
|
8de6785182 | ||
|
|
77f6fa8116 | ||
|
|
6b6f338e7e | ||
|
|
aa995fb590 | ||
|
|
f0fee87b9e | ||
|
|
56ab3bf59b | ||
|
|
24e2720924 | ||
|
|
bacc2f00af | ||
|
|
6de2d6810b | ||
|
|
de07833d92 | ||
|
|
b4eff231c3 | ||
|
|
98fea46eea | ||
|
|
18e8821891 | ||
|
|
cc2377d44d | ||
|
|
8370c6abfb | ||
|
|
2d1a672de5 | ||
|
|
75ea42a834 | ||
|
|
45491e17cd | ||
|
|
b994f03391 | ||
|
|
473cb59013 | ||
|
|
9302926d99 | ||
|
|
d92516b7c9 | ||
|
|
5b561213d3 | ||
|
|
0a16bd4919 | ||
|
|
f74a6e2625 | ||
|
|
ecc271409a | ||
|
|
1f63bc3231 | ||
|
|
78adeb837e | ||
|
|
bfacf462bf | ||
|
|
771d40dbf6 | ||
|
|
8e441242ad | ||
|
|
b8a4237ab1 | ||
|
|
e92af1ee76 | ||
|
|
e561c1cebb | ||
|
|
d77f82f8e8 | ||
|
|
fcc3598d7f |
2
.github/workflows/ci.yaml
vendored
2
.github/workflows/ci.yaml
vendored
@@ -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
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==10.0.0"]
|
||||
"requirements": ["aioamazondevices==11.0.2"]
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from bsblan import BSBLANError, DaySchedule, DHWSchedule, TimeSlot
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -33,28 +34,27 @@ ATTR_SUNDAY_SLOTS = "sunday_slots"
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE = "set_hot_water_schedule"
|
||||
|
||||
|
||||
def _parse_time_value(value: time | str) -> time:
|
||||
"""Parse a time value from either a time object or string.
|
||||
# Schema for a single time slot
|
||||
_SLOT_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("start_time"): cv.time,
|
||||
vol.Required("end_time"): cv.time,
|
||||
}
|
||||
)
|
||||
|
||||
Raises ServiceValidationError if the format is invalid.
|
||||
"""
|
||||
if isinstance(value, time):
|
||||
return value
|
||||
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
parts = value.split(":")
|
||||
return time(int(parts[0]), int(parts[1]))
|
||||
except (ValueError, IndexError):
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_time_format",
|
||||
) from None
|
||||
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_time_format",
|
||||
)
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(ATTR_DEVICE_ID): cv.string,
|
||||
vol.Optional(ATTR_MONDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_TUESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_WEDNESDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_THURSDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_FRIDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_SATURDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
vol.Optional(ATTR_SUNDAY_SLOTS): vol.All(cv.ensure_list, [_SLOT_SCHEMA]),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def _convert_time_slots_to_day_schedule(
|
||||
@@ -62,8 +62,8 @@ def _convert_time_slots_to_day_schedule(
|
||||
) -> DaySchedule | None:
|
||||
"""Convert list of time slot dicts to a DaySchedule object.
|
||||
|
||||
Example: [{"start_time": "06:00", "end_time": "08:00"},
|
||||
{"start_time": "17:00", "end_time": "21:00"}]
|
||||
Example: [{"start_time": time(6, 0), "end_time": time(8, 0)},
|
||||
{"start_time": time(17, 0), "end_time": time(21, 0)}]
|
||||
becomes: DaySchedule with two TimeSlot objects
|
||||
|
||||
None returns None (don't modify this day).
|
||||
@@ -77,31 +77,27 @@ def _convert_time_slots_to_day_schedule(
|
||||
|
||||
time_slots = []
|
||||
for slot in slots:
|
||||
start = slot.get("start_time")
|
||||
end = slot.get("end_time")
|
||||
start_time = slot["start_time"]
|
||||
end_time = slot["end_time"]
|
||||
|
||||
if start and end:
|
||||
start_time = _parse_time_value(start)
|
||||
end_time = _parse_time_value(end)
|
||||
|
||||
# Validate that end time is after start time
|
||||
if end_time <= start_time:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="end_time_before_start_time",
|
||||
translation_placeholders={
|
||||
"start_time": start_time.strftime("%H:%M"),
|
||||
"end_time": end_time.strftime("%H:%M"),
|
||||
},
|
||||
)
|
||||
|
||||
time_slots.append(TimeSlot(start=start_time, end=end_time))
|
||||
LOGGER.debug(
|
||||
"Created time slot: %s-%s",
|
||||
start_time.strftime("%H:%M"),
|
||||
end_time.strftime("%H:%M"),
|
||||
# Validate that end time is after start time
|
||||
if end_time <= start_time:
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="end_time_before_start_time",
|
||||
translation_placeholders={
|
||||
"start_time": start_time.strftime("%H:%M"),
|
||||
"end_time": end_time.strftime("%H:%M"),
|
||||
},
|
||||
)
|
||||
|
||||
time_slots.append(TimeSlot(start=start_time, end=end_time))
|
||||
LOGGER.debug(
|
||||
"Created time slot: %s-%s",
|
||||
start_time.strftime("%H:%M"),
|
||||
end_time.strftime("%H:%M"),
|
||||
)
|
||||
|
||||
LOGGER.debug("Created DaySchedule with %d slots", len(time_slots))
|
||||
return DaySchedule(slots=time_slots)
|
||||
|
||||
@@ -214,4 +210,5 @@ def async_setup_services(hass: HomeAssistant) -> None:
|
||||
DOMAIN,
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
set_hot_water_schedule,
|
||||
schema=SERVICE_SET_HOT_WATER_SCHEDULE_SCHEMA,
|
||||
)
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2025.12.2"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.1"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==43.9.0",
|
||||
"aioesphomeapi==43.9.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.4.0"
|
||||
],
|
||||
|
||||
@@ -9,14 +9,12 @@ from homeassistant.util.hass_dict import HassKey
|
||||
from .const import DOMAIN
|
||||
from .coordinator import FeedReaderConfigEntry, FeedReaderCoordinator, StoredData
|
||||
|
||||
CONF_URLS = "urls"
|
||||
|
||||
MY_KEY: HassKey[StoredData] = HassKey(DOMAIN)
|
||||
FEEDREADER_KEY: HassKey[StoredData] = HassKey(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry) -> bool:
|
||||
"""Set up Feedreader from a config entry."""
|
||||
storage = hass.data.setdefault(MY_KEY, StoredData(hass))
|
||||
storage = hass.data.setdefault(FEEDREADER_KEY, StoredData(hass))
|
||||
if not storage.is_initialized:
|
||||
await storage.async_setup()
|
||||
|
||||
@@ -42,5 +40,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: FeedReaderConfigEntry)
|
||||
)
|
||||
# if this is the last entry, remove the storage
|
||||
if len(entries) == 1:
|
||||
hass.data.pop(MY_KEY)
|
||||
hass.data.pop(FEEDREADER_KEY)
|
||||
return await hass.config_entries.async_unload_platforms(entry, [Platform.EVENT])
|
||||
|
||||
@@ -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,
|
||||
|
||||
94
homeassistant/components/feedreader/quality_scale.yaml
Normal file
94
homeassistant/components/feedreader/quality_scale.yaml
Normal 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.
|
||||
@@ -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": {
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyfirefly==0.1.8"]
|
||||
"requirements": ["pyfirefly==0.1.10"]
|
||||
}
|
||||
|
||||
@@ -48,6 +48,8 @@ async def async_tts_voices(
|
||||
list_voices_response = await client.list_voices()
|
||||
for voice in list_voices_response.voices:
|
||||
language_code = voice.language_codes[0]
|
||||
if not voice.name.startswith(language_code):
|
||||
continue
|
||||
if language_code not in voices:
|
||||
voices[language_code] = []
|
||||
voices[language_code].append(voice.name)
|
||||
|
||||
@@ -24,7 +24,7 @@ from homeassistant.const import (
|
||||
CONF_SSL,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant, callback
|
||||
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
@@ -227,7 +227,10 @@ class HikvisionBinarySensor(BinarySensorEntity):
|
||||
# Register callback with pyhik
|
||||
self._camera.add_update_callback(self._update_callback, self._callback_id)
|
||||
|
||||
@callback
|
||||
def _update_callback(self, msg: str) -> None:
|
||||
"""Update the sensor's state when callback is triggered."""
|
||||
self.async_write_ha_state()
|
||||
"""Update the sensor's state when callback is triggered.
|
||||
|
||||
This is called from pyhik's event stream thread, so we use
|
||||
schedule_update_ha_state which is thread-safe.
|
||||
"""
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
@@ -67,21 +67,21 @@ FLOW_CONTROLLER_SENSORS: tuple[HydrawiseSensorEntityDescription, ...] = (
|
||||
HydrawiseSensorEntityDescription(
|
||||
key="daily_total_water_use",
|
||||
translation_key="daily_total_water_use",
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
device_class=SensorDeviceClass.WATER,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda sensor: _get_water_use(sensor).total_use,
|
||||
),
|
||||
HydrawiseSensorEntityDescription(
|
||||
key="daily_active_water_use",
|
||||
translation_key="daily_active_water_use",
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
device_class=SensorDeviceClass.WATER,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda sensor: _get_water_use(sensor).total_active_use,
|
||||
),
|
||||
HydrawiseSensorEntityDescription(
|
||||
key="daily_inactive_water_use",
|
||||
translation_key="daily_inactive_water_use",
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
device_class=SensorDeviceClass.WATER,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda sensor: _get_water_use(sensor).total_inactive_use,
|
||||
),
|
||||
@@ -91,7 +91,7 @@ FLOW_ZONE_SENSORS: tuple[SensorEntityDescription, ...] = (
|
||||
HydrawiseSensorEntityDescription(
|
||||
key="daily_active_water_use",
|
||||
translation_key="daily_active_water_use",
|
||||
device_class=SensorDeviceClass.VOLUME,
|
||||
device_class=SensorDeviceClass.WATER,
|
||||
suggested_display_precision=1,
|
||||
value_fn=lambda sensor: float(
|
||||
_get_water_use(sensor).active_use_by_zone_id.get(sensor.zone.id, 0.0)
|
||||
@@ -204,7 +204,7 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity):
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit_of_measurement of the sensor."""
|
||||
if self.entity_description.device_class != SensorDeviceClass.VOLUME:
|
||||
if self.entity_description.device_class != SensorDeviceClass.WATER:
|
||||
return self.entity_description.native_unit_of_measurement
|
||||
return (
|
||||
UnitOfVolume.GALLONS
|
||||
@@ -217,7 +217,7 @@ class HydrawiseSensor(HydrawiseEntity, SensorEntity):
|
||||
"""Icon of the entity based on the value."""
|
||||
if (
|
||||
self.entity_description.key in FLOW_MEASUREMENT_KEYS
|
||||
and self.entity_description.device_class == SensorDeviceClass.VOLUME
|
||||
and self.entity_description.device_class == SensorDeviceClass.WATER
|
||||
and round(self.state, 2) == 0.0
|
||||
):
|
||||
return "mdi:water-outline"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.13.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.12.28.215221"
|
||||
"knx-frontend==2025.12.30.151231"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -154,6 +154,27 @@
|
||||
}
|
||||
},
|
||||
"config_panel": {
|
||||
"dashboard": {
|
||||
"connection_flow": {
|
||||
"description": "Reconfigure KNX connection or import a new KNX keyring file",
|
||||
"title": "Connection settings"
|
||||
},
|
||||
"options_flow": {
|
||||
"description": "Configure integration settings",
|
||||
"title": "Integration options"
|
||||
},
|
||||
"project_upload": {
|
||||
"description": "Import a KNX project file to help configure group addresses and datapoint types",
|
||||
"title": "[%key:component::knx::config_panel::dialogs::project_upload::title%]"
|
||||
}
|
||||
},
|
||||
"dialogs": {
|
||||
"project_upload": {
|
||||
"description": "Details such as group address names, datapoint types, devices and group objects are extracted from your project file. The ETS project file itself and its optional password are not stored.\n\n`.knxproj` files exported by ETS 4, 5 or 6 are supported.",
|
||||
"file_upload_label": "ETS project file",
|
||||
"title": "Import ETS project"
|
||||
}
|
||||
},
|
||||
"dpt": {
|
||||
"options": {
|
||||
"5": "Generic 1-byte unsigned integer",
|
||||
@@ -845,9 +866,9 @@
|
||||
},
|
||||
"mode": {
|
||||
"description": "Select how the entity is displayed in Home Assistant.",
|
||||
"label": "[%common::config_flow::data::mode%]",
|
||||
"label": "[%key:common::config_flow::data::mode%]",
|
||||
"options": {
|
||||
"password": "[%common::config_flow::data::password%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"text": "[%key:component::text::entity_component::_::state_attributes::mode::state::text%]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,8 +80,6 @@ async def register_panel(hass: HomeAssistant) -> None:
|
||||
hass=hass,
|
||||
frontend_url_path=DOMAIN,
|
||||
webcomponent_name=knx_panel.webcomponent_name,
|
||||
sidebar_title=DOMAIN.upper(),
|
||||
sidebar_icon="mdi:bus-electric",
|
||||
module_url=f"{URL_BASE}/{knx_panel.entrypoint_js}",
|
||||
embed_iframe=True,
|
||||
require_admin=True,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -116,8 +116,12 @@ class MetDataUpdateCoordinator(DataUpdateCoordinator[MetWeatherData]):
|
||||
"""Fetch data from Met."""
|
||||
try:
|
||||
return await self.weather.fetch_data()
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Update failed: {err}") from err
|
||||
except CannotConnect as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="update_failed",
|
||||
translation_placeholders={"error": str(err)},
|
||||
) from err
|
||||
|
||||
def track_home(self) -> None:
|
||||
"""Start tracking changes to HA home setting."""
|
||||
|
||||
@@ -19,6 +19,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"update_failed": {
|
||||
"message": "Update of data from the web site failed: {error}"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
"step": {
|
||||
"init": {
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@hacf-fr", "@Quentame", "@starkillerOG"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/netgear",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pynetgear"],
|
||||
"requirements": ["pynetgear==0.10.10"],
|
||||
|
||||
@@ -51,7 +51,6 @@ ALL_BINARY_SENSORS = [
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.NOTIFY,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
@@ -61,6 +60,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up Netgear LTE component."""
|
||||
hass.data[DATA_HASS_CONFIG] = config
|
||||
async_setup_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
@@ -96,19 +96,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry) -
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
async_setup_services(hass)
|
||||
|
||||
await discovery.async_load_platform(
|
||||
hass,
|
||||
Platform.NOTIFY,
|
||||
DOMAIN,
|
||||
{CONF_NAME: entry.title, "modem": modem},
|
||||
{CONF_NAME: entry.title, "modem": modem, "entry": entry},
|
||||
hass.data[DATA_HASS_CONFIG],
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
entry, [platform for platform in PLATFORMS if platform != Platform.NOTIFY]
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
@@ -118,7 +114,5 @@ async def async_unload_entry(hass: HomeAssistant, entry: NetgearLTEConfigEntry)
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if not hass.config_entries.async_loaded_entries(DOMAIN):
|
||||
hass.data.pop(DOMAIN, None)
|
||||
for service_name in hass.services.async_services()[DOMAIN]:
|
||||
hass.services.async_remove(DOMAIN, service_name)
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -14,7 +14,7 @@ from homeassistant.const import CONF_HOST, CONF_PASSWORD
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import DEFAULT_HOST, DOMAIN, LOGGER, MANUFACTURER
|
||||
from .const import DEFAULT_HOST, DOMAIN, MANUFACTURER
|
||||
|
||||
|
||||
class NetgearLTEFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@@ -72,9 +72,6 @@ class NetgearLTEFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
info = await modem.information()
|
||||
except Error as ex:
|
||||
raise InputValidationError("cannot_connect") from ex
|
||||
except Exception as ex:
|
||||
LOGGER.exception("Unexpected exception")
|
||||
raise InputValidationError("unknown") from ex
|
||||
await modem.logout()
|
||||
return info
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eternalegypt"],
|
||||
"requirements": ["eternalegypt==0.0.16"]
|
||||
"requirements": ["eternalegypt==0.0.18"]
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ class NetgearNotifyService(BaseNotificationService):
|
||||
"""Initialize the service."""
|
||||
self.config = config
|
||||
self.modem: Modem = discovery_info["modem"]
|
||||
discovery_info["entry"].async_on_unload(self.async_unregister_services)
|
||||
|
||||
async def async_send_message(self, message="", **kwargs):
|
||||
"""Send a message to a user."""
|
||||
|
||||
@@ -4,6 +4,7 @@ import voluptuous as vol
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
|
||||
from .const import (
|
||||
@@ -14,7 +15,6 @@ from .const import (
|
||||
AUTOCONNECT_MODES,
|
||||
DOMAIN,
|
||||
FAILOVER_MODES,
|
||||
LOGGER,
|
||||
)
|
||||
from .coordinator import NetgearLTEConfigEntry
|
||||
|
||||
@@ -56,8 +56,11 @@ async def _service_handler(call: ServiceCall) -> None:
|
||||
break
|
||||
|
||||
if not entry or not (modem := entry.runtime_data.modem).token:
|
||||
LOGGER.error("%s: host %s unavailable", call.service, host)
|
||||
return
|
||||
raise ServiceValidationError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="config_entry_not_found",
|
||||
translation_placeholders={"service": call.service},
|
||||
)
|
||||
|
||||
if call.service == SERVICE_DELETE_SMS:
|
||||
for sms_id in call.data[ATTR_SMS_ID]:
|
||||
|
||||
@@ -71,6 +71,11 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"config_entry_not_found": {
|
||||
"message": "Failed to perform action \"{service}\". Config entry for target not found"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"connect_lte": {
|
||||
"description": "Asks the modem to establish the LTE connection.",
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
}
|
||||
],
|
||||
"documentation": "https://www.home-assistant.io/integrations/nuheat",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["nuheat"],
|
||||
"requirements": ["nuheat==1.0.1"]
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@IsakNyberg"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/permobil",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["mypermobil==0.1.8"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
34
homeassistant/components/pooldose/diagnostics.py
Normal file
34
homeassistant/components/pooldose/diagnostics.py
Normal 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,
|
||||
}
|
||||
@@ -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"]
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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%]"
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@haemishkyd"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/poolsense",
|
||||
"integration_type": "device",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["poolsense"],
|
||||
"requirements": ["poolsense==0.0.8"]
|
||||
|
||||
@@ -15,6 +15,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import PortainerConfigEntry
|
||||
from .const import CONTAINER_STATE_RUNNING
|
||||
from .coordinator import PortainerContainerData, PortainerCoordinator
|
||||
from .entity import (
|
||||
PortainerContainerEntity,
|
||||
@@ -41,7 +42,7 @@ CONTAINER_SENSORS: tuple[PortainerContainerBinarySensorEntityDescription, ...] =
|
||||
PortainerContainerBinarySensorEntityDescription(
|
||||
key="status",
|
||||
translation_key="status",
|
||||
state_fn=lambda data: data.container.state == "running",
|
||||
state_fn=lambda data: data.container.state == CONTAINER_STATE_RUNNING,
|
||||
device_class=BinarySensorDeviceClass.RUNNING,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
|
||||
@@ -4,3 +4,5 @@ DOMAIN = "portainer"
|
||||
DEFAULT_NAME = "Portainer"
|
||||
|
||||
ENDPOINT_STATUS_DOWN = 2
|
||||
|
||||
CONTAINER_STATE_RUNNING = "running"
|
||||
|
||||
@@ -24,7 +24,7 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, ENDPOINT_STATUS_DOWN
|
||||
from .const import CONTAINER_STATE_RUNNING, DOMAIN, ENDPOINT_STATUS_DOWN
|
||||
|
||||
type PortainerConfigEntry = ConfigEntry[PortainerCoordinator]
|
||||
|
||||
@@ -158,10 +158,11 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
|
||||
),
|
||||
)
|
||||
for container in containers
|
||||
if container.state == CONTAINER_STATE_RUNNING
|
||||
]
|
||||
|
||||
container_stats_gather = await asyncio.gather(
|
||||
*[task for _, task in container_stats_task],
|
||||
*[task for _, task in container_stats_task]
|
||||
)
|
||||
for (container, _), container_stats in zip(
|
||||
container_stats_task, container_stats_gather, strict=False
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyportainer==1.0.17"]
|
||||
"requirements": ["pyportainer==1.0.19"]
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
"codeowners": ["@ktnrg45"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/ps4",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pyps4_2ndscreen"],
|
||||
"requirements": ["pyps4-2ndscreen==1.3.1"]
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.components.light import (
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util import color as color_util
|
||||
|
||||
from .entity import (
|
||||
ReolinkChannelCoordinatorEntity,
|
||||
@@ -157,16 +158,16 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity):
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
"""Return the brightness of this light between 0.255."""
|
||||
"""Return the brightness of this light between 1.255."""
|
||||
assert self.entity_description.get_brightness_fn is not None
|
||||
|
||||
bright_pct = self.entity_description.get_brightness_fn(
|
||||
self._host.api, self._channel
|
||||
)
|
||||
if bright_pct is None:
|
||||
if not bright_pct:
|
||||
return None
|
||||
|
||||
return round(255 * bright_pct / 100.0)
|
||||
return color_util.value_to_brightness((1, 100), bright_pct)
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int | None:
|
||||
@@ -189,7 +190,7 @@ class ReolinkLightEntity(ReolinkChannelCoordinatorEntity, LightEntity):
|
||||
if (
|
||||
brightness := kwargs.get(ATTR_BRIGHTNESS)
|
||||
) is not None and self.entity_description.set_brightness_fn is not None:
|
||||
brightness_pct = int(brightness / 255.0 * 100)
|
||||
brightness_pct = round(color_util.brightness_to_value((1, 100), brightness))
|
||||
await self.entity_description.set_brightness_fn(
|
||||
self._host.api, self._channel, brightness_pct
|
||||
)
|
||||
|
||||
@@ -79,6 +79,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: RoborockConfigEntry) ->
|
||||
map_scale=MAP_SCALE,
|
||||
),
|
||||
mqtt_session_unauthorized_hook=lambda: entry.async_start_reauth(hass),
|
||||
prefer_cache=False,
|
||||
)
|
||||
except RoborockInvalidCredentials as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
|
||||
@@ -552,6 +552,7 @@ class RoborockB01Q7UpdateCoordinator(RoborockDataUpdateCoordinatorB01):
|
||||
RoborockB01Props.CLEANING_TIME,
|
||||
RoborockB01Props.REAL_CLEAN_TIME,
|
||||
RoborockB01Props.HYPA,
|
||||
RoborockB01Props.WIND,
|
||||
]
|
||||
|
||||
async def _async_update_data(
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
92
homeassistant/components/synology_dsm/quality_scale.yaml
Normal file
92
homeassistant/components/synology_dsm/quality_scale.yaml
Normal 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
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -5,4 +5,5 @@ DOMAIN = "togrill"
|
||||
MAX_PROBE_COUNT = 6
|
||||
|
||||
CONF_PROBE_COUNT = "probe_count"
|
||||
CONF_HAS_AMBIENT = "has_ambient"
|
||||
CONF_VERSION = "version"
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -98,6 +98,11 @@
|
||||
"well_done": "Well done"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"ambient_temperature": {
|
||||
"name": "Ambient temperature"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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()
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ from .const import (
|
||||
SERVER_UNAVAILABLE,
|
||||
SWITCH_KEY_MAP,
|
||||
TRACKER_HARDWARE_STATUS_UPDATED,
|
||||
TRACKER_HEALTH_OVERVIEW_UPDATED,
|
||||
TRACKER_POSITION_UPDATED,
|
||||
TRACKER_SWITCH_STATUS_UPDATED,
|
||||
TRACKER_WELLNESS_STATUS_UPDATED,
|
||||
@@ -64,6 +65,7 @@ class Trackables:
|
||||
tracker_details: dict
|
||||
hw_info: dict
|
||||
pos_report: dict
|
||||
health_overview: dict
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
@@ -114,6 +116,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: TractiveConfigEntry) ->
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
# Send initial health overview data to sensors after platforms are set up
|
||||
for item in filtered_trackables:
|
||||
if item.health_overview:
|
||||
tractive.send_health_overview_update(item.health_overview)
|
||||
|
||||
async def cancel_listen_task(_: Event) -> None:
|
||||
await tractive.unsubscribe()
|
||||
|
||||
@@ -144,9 +151,13 @@ async def _generate_trackables(
|
||||
return None
|
||||
|
||||
tracker = client.tracker(trackable["device_id"])
|
||||
trackable_pet = client.trackable_object(trackable["_id"])
|
||||
|
||||
tracker_details, hw_info, pos_report = await asyncio.gather(
|
||||
tracker.details(), tracker.hw_info(), tracker.pos_report()
|
||||
tracker_details, hw_info, pos_report, health_overview = await asyncio.gather(
|
||||
tracker.details(),
|
||||
tracker.hw_info(),
|
||||
tracker.pos_report(),
|
||||
trackable_pet.health_overview(),
|
||||
)
|
||||
|
||||
if not tracker_details.get("_id"):
|
||||
@@ -154,7 +165,9 @@ async def _generate_trackables(
|
||||
f"Tractive API returns incomplete data for tracker {trackable['device_id']}",
|
||||
)
|
||||
|
||||
return Trackables(tracker, trackable, tracker_details, hw_info, pos_report)
|
||||
return Trackables(
|
||||
tracker, trackable, tracker_details, hw_info, pos_report, health_overview
|
||||
)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: TractiveConfigEntry) -> bool:
|
||||
@@ -226,6 +239,9 @@ class TractiveClient:
|
||||
if server_was_unavailable:
|
||||
_LOGGER.debug("Tractive is back online")
|
||||
server_was_unavailable = False
|
||||
if event["message"] == "health_overview":
|
||||
self.send_health_overview_update(event)
|
||||
continue
|
||||
if event["message"] == "wellness_overview":
|
||||
self._send_wellness_update(event)
|
||||
continue
|
||||
@@ -316,6 +332,27 @@ class TractiveClient:
|
||||
TRACKER_WELLNESS_STATUS_UPDATED, event["pet_id"], payload
|
||||
)
|
||||
|
||||
def send_health_overview_update(self, event: dict[str, Any]) -> None:
|
||||
"""Handle health_overview events from Tractive API."""
|
||||
# The health_overview response can be at root level or wrapped in 'content'
|
||||
# Handle both structures for compatibility
|
||||
data = event.get("content", event)
|
||||
|
||||
activity = data.get("activity", {})
|
||||
sleep = data.get("sleep", {})
|
||||
|
||||
payload = {
|
||||
ATTR_DAILY_GOAL: activity.get("minutesGoal"),
|
||||
ATTR_MINUTES_ACTIVE: activity.get("minutesActive"),
|
||||
ATTR_MINUTES_DAY_SLEEP: sleep.get("minutesDaySleep"),
|
||||
ATTR_MINUTES_NIGHT_SLEEP: sleep.get("minutesNightSleep"),
|
||||
# Calm minutes can be used as rest indicator
|
||||
ATTR_MINUTES_REST: sleep.get("minutesCalm"),
|
||||
}
|
||||
self._dispatch_tracker_event(
|
||||
TRACKER_HEALTH_OVERVIEW_UPDATED, data["petId"], payload
|
||||
)
|
||||
|
||||
def _send_position_update(self, event: dict[str, Any]) -> None:
|
||||
payload = {
|
||||
"latitude": event["position"]["latlong"][0],
|
||||
|
||||
@@ -28,6 +28,7 @@ TRACKER_HARDWARE_STATUS_UPDATED = f"{DOMAIN}_tracker_hardware_status_updated"
|
||||
TRACKER_POSITION_UPDATED = f"{DOMAIN}_tracker_position_updated"
|
||||
TRACKER_SWITCH_STATUS_UPDATED = f"{DOMAIN}_tracker_switch_updated"
|
||||
TRACKER_WELLNESS_STATUS_UPDATED = f"{DOMAIN}_tracker_wellness_updated"
|
||||
TRACKER_HEALTH_OVERVIEW_UPDATED = f"{DOMAIN}_tracker_health_overview_updated"
|
||||
|
||||
SERVER_UNAVAILABLE = f"{DOMAIN}_server_unavailable"
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ from .const import (
|
||||
ATTR_SLEEP_LABEL,
|
||||
ATTR_TRACKER_STATE,
|
||||
TRACKER_HARDWARE_STATUS_UPDATED,
|
||||
TRACKER_HEALTH_OVERVIEW_UPDATED,
|
||||
TRACKER_WELLNESS_STATUS_UPDATED,
|
||||
)
|
||||
from .entity import TractiveEntity
|
||||
@@ -115,14 +116,14 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = (
|
||||
key=ATTR_MINUTES_ACTIVE,
|
||||
translation_key="activity_time",
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
|
||||
signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
TractiveSensorEntityDescription(
|
||||
key=ATTR_MINUTES_REST,
|
||||
translation_key="rest_time",
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
|
||||
signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
TractiveSensorEntityDescription(
|
||||
@@ -136,20 +137,20 @@ SENSOR_TYPES: tuple[TractiveSensorEntityDescription, ...] = (
|
||||
key=ATTR_DAILY_GOAL,
|
||||
translation_key="daily_goal",
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
|
||||
signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED,
|
||||
),
|
||||
TractiveSensorEntityDescription(
|
||||
key=ATTR_MINUTES_DAY_SLEEP,
|
||||
translation_key="minutes_day_sleep",
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
|
||||
signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
TractiveSensorEntityDescription(
|
||||
key=ATTR_MINUTES_NIGHT_SLEEP,
|
||||
translation_key="minutes_night_sleep",
|
||||
native_unit_of_measurement=UnitOfTime.MINUTES,
|
||||
signal_prefix=TRACKER_WELLNESS_STATUS_UPDATED,
|
||||
signal_prefix=TRACKER_HEALTH_OVERVIEW_UPDATED,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
),
|
||||
TractiveSensorEntityDescription(
|
||||
|
||||
@@ -105,7 +105,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bo
|
||||
try:
|
||||
await controller.connect()
|
||||
except VelbusConnectionFailed as error:
|
||||
raise ConfigEntryNotReady("Cannot connect to Velbus") from error
|
||||
raise ConfigEntryNotReady(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="connection_failed",
|
||||
) from error
|
||||
|
||||
task = hass.async_create_task(velbus_scan_task(controller, hass, entry.entry_id))
|
||||
entry.runtime_data = VelbusData(controller=controller, scan_task=task)
|
||||
|
||||
@@ -65,7 +65,7 @@ class VelbusClimate(VelbusEntity, ClimateEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> int | None:
|
||||
def current_temperature(self) -> float | None:
|
||||
"""Return the current temperature."""
|
||||
return self._channel.get_state()
|
||||
|
||||
|
||||
@@ -66,6 +66,7 @@ class VelbusEntity(Entity):
|
||||
self._channel.remove_on_status_update(self._on_update)
|
||||
|
||||
async def _on_update(self) -> None:
|
||||
"""Handle status updates from the channel."""
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -80,8 +81,13 @@ def api_call[_T: VelbusEntity, **_P](
|
||||
try:
|
||||
await func(self, *args, **kwargs)
|
||||
except OSError as exc:
|
||||
entity_name = self.name if isinstance(self.name, str) else "Unknown"
|
||||
raise HomeAssistantError(
|
||||
f"Could not execute {func.__name__} service for {self.name}"
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_call_failed",
|
||||
translation_placeholders={
|
||||
"entity": entity_name,
|
||||
},
|
||||
) from exc
|
||||
|
||||
return cmd_wrapper
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"velbus-protocol"
|
||||
],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["velbus-aio==2025.12.0"],
|
||||
"requirements": ["velbus-aio==2026.1.0"],
|
||||
"usb": [
|
||||
{
|
||||
"pid": "0B1B",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -5,9 +5,7 @@ rules:
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow:
|
||||
status: todo
|
||||
comment: Missing data descriptions
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions: done
|
||||
docs-high-level-description: done
|
||||
|
||||
@@ -15,6 +15,10 @@
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::email%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::vesync::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::vesync::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "The VeSync integration needs to re-authenticate your account",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
@@ -23,6 +27,11 @@
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::email%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "Password associated with your VeSync account",
|
||||
"username": "Email address associated with your VeSync account"
|
||||
},
|
||||
"description": "Enter the account used in the vesync app. 2FA is not supported and must be disabled.",
|
||||
"title": "Enter username and password"
|
||||
}
|
||||
}
|
||||
@@ -106,6 +115,9 @@
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"auto_off_config": {
|
||||
"name": "Auto Off"
|
||||
},
|
||||
"child_lock": {
|
||||
"name": "Child lock"
|
||||
},
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -209,7 +209,7 @@ class XboxSource(MediaSource):
|
||||
if images is not None:
|
||||
try:
|
||||
return PlayMedia(
|
||||
images[int(identifier.media_id)].url,
|
||||
to_https(images[int(identifier.media_id)].url),
|
||||
MIME_TYPE_MAP[ATTR_SCREENSHOTS],
|
||||
)
|
||||
except (ValueError, IndexError):
|
||||
|
||||
@@ -25,5 +25,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/xiaomi_ble",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["xiaomi-ble==1.2.0"]
|
||||
"requirements": ["xiaomi-ble==1.4.1"]
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -2412,10 +2412,8 @@ class Service:
|
||||
|
||||
__slots__ = [
|
||||
"description_placeholders",
|
||||
"domain",
|
||||
"job",
|
||||
"schema",
|
||||
"service",
|
||||
"supports_response",
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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))
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
6
requirements.txt
generated
@@ -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
26
requirements_all.txt
generated
@@ -190,7 +190,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.4
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==10.0.0
|
||||
aioamazondevices==11.0.2
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -252,7 +252,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==43.9.0
|
||||
aioesphomeapi==43.9.1
|
||||
|
||||
# homeassistant.components.matrix
|
||||
# homeassistant.components.slack
|
||||
@@ -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
|
||||
|
||||
26
requirements_test_all.txt
generated
26
requirements_test_all.txt
generated
@@ -181,7 +181,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.4
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==10.0.0
|
||||
aioamazondevices==11.0.2
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -243,7 +243,7 @@ aioelectricitymaps==1.1.1
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==43.9.0
|
||||
aioesphomeapi==43.9.1
|
||||
|
||||
# homeassistant.components.matrix
|
||||
# homeassistant.components.slack
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -6,6 +6,7 @@ from unittest.mock import MagicMock
|
||||
|
||||
from bsblan import BSBLANError, DaySchedule, TimeSlot
|
||||
import pytest
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.bsblan.const import DOMAIN
|
||||
from homeassistant.components.bsblan.services import (
|
||||
@@ -198,9 +199,7 @@ async def test_no_config_entry_for_device(
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"monday_slots": [
|
||||
{"start_time": time(6, 0), "end_time": time(8, 0)},
|
||||
],
|
||||
"monday_slots": [{"start_time": time(6, 0), "end_time": time(8, 0)}],
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
@@ -274,14 +273,10 @@ async def test_api_error(
|
||||
[
|
||||
(time(13, 0), time(11, 0), "end_time_before_start_time"),
|
||||
("13:00", "11:00", "end_time_before_start_time"),
|
||||
("invalid", "08:00", "invalid_time_format"),
|
||||
("06:00", "not-a-time", "invalid_time_format"),
|
||||
],
|
||||
ids=[
|
||||
"time_objects_end_before_start",
|
||||
"strings_end_before_start",
|
||||
"invalid_start_time_format",
|
||||
"invalid_end_time_format",
|
||||
],
|
||||
)
|
||||
async def test_time_validation_errors(
|
||||
@@ -395,22 +390,20 @@ async def test_non_standard_time_types(
|
||||
device_entry: dr.DeviceEntry,
|
||||
) -> None:
|
||||
"""Test service with non-standard time types raises error."""
|
||||
# Test with integer time values (shouldn't happen but need coverage)
|
||||
with pytest.raises(ServiceValidationError) as exc_info:
|
||||
# Test with integer time values - schema validation will reject these
|
||||
with pytest.raises(vol.MultipleInvalid):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
SERVICE_SET_HOT_WATER_SCHEDULE,
|
||||
{
|
||||
"device_id": device_entry.id,
|
||||
"monday_slots": [
|
||||
{"start_time": 600, "end_time": 800}, # Non-standard types
|
||||
{"start_time": 600, "end_time": 800},
|
||||
],
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert exc_info.value.translation_key == "invalid_time_format"
|
||||
|
||||
|
||||
async def test_async_setup_services(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -81,6 +81,7 @@ def mock_api_tts() -> AsyncMock:
|
||||
voices=[
|
||||
cloud_tts.Voice(language_codes=["en-US"], name="en-US-Standard-A"),
|
||||
cloud_tts.Voice(language_codes=["en-US"], name="en-US-Standard-B"),
|
||||
cloud_tts.Voice(language_codes=["en-US"], name="Standard-A"),
|
||||
cloud_tts.Voice(language_codes=["el-GR"], name="el-GR-Standard-A"),
|
||||
]
|
||||
)
|
||||
|
||||
@@ -294,6 +294,10 @@ async def test_binary_sensor_update_callback(
|
||||
callback_func = add_callback_call[0][0]
|
||||
callback_func("motion detected")
|
||||
|
||||
# Wait for the event loop to process the scheduled state update
|
||||
# (callback uses call_soon_threadsafe to schedule update in event loop)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Verify state was updated
|
||||
state = hass.states.get("binary_sensor.front_camera_motion")
|
||||
assert state is not None
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
'suggested_unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>,
|
||||
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Daily active water use',
|
||||
'platform': 'hydrawise',
|
||||
@@ -44,7 +44,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by hydrawise.com',
|
||||
'device_class': 'volume',
|
||||
'device_class': 'water',
|
||||
'friendly_name': 'Home Controller Daily active water use',
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
@@ -139,7 +139,7 @@
|
||||
'suggested_unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>,
|
||||
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Daily inactive water use',
|
||||
'platform': 'hydrawise',
|
||||
@@ -155,7 +155,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by hydrawise.com',
|
||||
'device_class': 'volume',
|
||||
'device_class': 'water',
|
||||
'friendly_name': 'Home Controller Daily inactive water use',
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
@@ -196,7 +196,7 @@
|
||||
'suggested_unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>,
|
||||
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Daily total water use',
|
||||
'platform': 'hydrawise',
|
||||
@@ -212,7 +212,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by hydrawise.com',
|
||||
'device_class': 'volume',
|
||||
'device_class': 'water',
|
||||
'friendly_name': 'Home Controller Daily total water use',
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
@@ -253,7 +253,7 @@
|
||||
'suggested_unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>,
|
||||
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Daily active water use',
|
||||
'platform': 'hydrawise',
|
||||
@@ -269,7 +269,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by hydrawise.com',
|
||||
'device_class': 'volume',
|
||||
'device_class': 'water',
|
||||
'friendly_name': 'Zone One Daily active water use',
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
@@ -464,7 +464,7 @@
|
||||
'suggested_unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.VOLUME: 'volume'>,
|
||||
'original_device_class': <SensorDeviceClass.WATER: 'water'>,
|
||||
'original_icon': 'mdi:water-outline',
|
||||
'original_name': 'Daily active water use',
|
||||
'platform': 'hydrawise',
|
||||
@@ -480,7 +480,7 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'attribution': 'Data provided by hydrawise.com',
|
||||
'device_class': 'volume',
|
||||
'device_class': 'water',
|
||||
'friendly_name': 'Zone Two Daily active water use',
|
||||
'icon': 'mdi:water-outline',
|
||||
'unit_of_measurement': <UnitOfVolume.LITERS: 'L'>,
|
||||
|
||||
@@ -65,18 +65,3 @@ async def test_flow_user_cannot_connect(
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"]["base"] == "cannot_connect"
|
||||
|
||||
|
||||
async def test_flow_user_unknown_error(hass: HomeAssistant, unknown: None) -> None:
|
||||
"""Test unknown error."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={CONF_SOURCE: SOURCE_USER},
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
user_input=CONF_DATA,
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"]["base"] == "unknown"
|
||||
|
||||
@@ -2,9 +2,12 @@
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.netgear_lte.const import DOMAIN
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
|
||||
from .conftest import HOST
|
||||
|
||||
@@ -54,3 +57,15 @@ async def test_set_option(hass: HomeAssistant, setup_integration: None) -> None:
|
||||
blocking=True,
|
||||
)
|
||||
assert len(mock_client.mock_calls) == 1
|
||||
|
||||
entry = hass.config_entries.async_entries(DOMAIN)[0]
|
||||
await hass.config_entries.async_unload(entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
with pytest.raises(ServiceValidationError):
|
||||
await hass.services.async_call(
|
||||
DOMAIN,
|
||||
"delete_sms",
|
||||
{CONF_HOST: "no-match", "sms_id": 1},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
245
tests/components/pooldose/snapshots/test_diagnostics.ambr
Normal file
245
tests/components/pooldose/snapshots/test_diagnostics.ambr
Normal 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',
|
||||
}),
|
||||
})
|
||||
# ---
|
||||
@@ -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"
|
||||
|
||||
20
tests/components/pooldose/test_diagnostics.py
Normal file
20
tests/components/pooldose/test_diagnostics.py
Normal 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
|
||||
@@ -185,6 +185,7 @@ def _init_host_mock(host_mock: MagicMock) -> None:
|
||||
host_mock.baichuan.smart_ai_type_list.return_value = ["people"]
|
||||
host_mock.baichuan.smart_ai_index.return_value = 1
|
||||
host_mock.baichuan.smart_ai_name.return_value = "zone1"
|
||||
host_mock.whiteled_brightness.return_value = None
|
||||
|
||||
def ai_detect_type(channel: int, object_type: str) -> str | None:
|
||||
if object_type == "people":
|
||||
|
||||
@@ -74,6 +74,7 @@ async def test_light_turn_off(
|
||||
) -> None:
|
||||
"""Test light turn off service."""
|
||||
reolink_host.whiteled_color_temperature.return_value = 3000
|
||||
reolink_host.whiteled_brightness.return_value = 75
|
||||
|
||||
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
@@ -81,6 +82,8 @@ async def test_light_turn_off(
|
||||
assert config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
entity_id = f"{Platform.LIGHT}.{TEST_CAM_NAME}_floodlight"
|
||||
state = hass.states.get(entity_id)
|
||||
assert state and state.attributes.get(ATTR_BRIGHTNESS) == 191
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
@@ -107,6 +110,7 @@ async def test_light_turn_on(
|
||||
) -> None:
|
||||
"""Test light turn on service."""
|
||||
reolink_host.whiteled_color_temperature.return_value = 3000
|
||||
reolink_host.whiteled_brightness.return_value = None
|
||||
|
||||
with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]):
|
||||
assert await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
491
tests/components/roborock/snapshots/test_binary_sensor.ambr
Normal file
491
tests/components/roborock/snapshots/test_binary_sensor.ambr
Normal 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
Reference in New Issue
Block a user