mirror of
https://github.com/home-assistant/core.git
synced 2026-05-04 20:04:35 +02:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d2ba94e1bf | |||
| 9a4ed82399 | |||
| b5136d01aa | |||
| d3e05090ea | |||
| 7e75ca7af9 | |||
| 6616b5775f | |||
| 69b82d4c59 | |||
| 6b9709677a | |||
| a4e9c82c84 | |||
| de86bedb80 | |||
| 9111c6df90 | |||
| 751f6bddb1 | |||
| c9a61de0a1 | |||
| 01fb46d903 | |||
| d26f61c9fe | |||
| a47a144312 | |||
| 69cf4f99d1 | |||
| e6c757c187 | |||
| a36b0e2f3f | |||
| 1a7c6cd96c | |||
| ba3e538402 | |||
| b2cd08aa65 | |||
| 06dcd25a16 | |||
| fd36782bae | |||
| ed4573db57 | |||
| 78373a6483 | |||
| 8455c35bec | |||
| 00887a2f3f | |||
| f1ca7543fa | |||
| bb72b24ba9 | |||
| 322a27d992 | |||
| a3b516110b | |||
| 95ac5c0183 |
@@ -361,6 +361,7 @@ homeassistant.components.myuplink.*
|
||||
homeassistant.components.nam.*
|
||||
homeassistant.components.nanoleaf.*
|
||||
homeassistant.components.nasweb.*
|
||||
homeassistant.components.neato.*
|
||||
homeassistant.components.nest.*
|
||||
homeassistant.components.netatmo.*
|
||||
homeassistant.components.network.*
|
||||
|
||||
Generated
+1
-1
@@ -31,7 +31,7 @@ RUN \
|
||||
&& go2rtc --version
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.9.5
|
||||
RUN pip3 install uv==0.9.6
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -189,7 +189,7 @@ class BryantEvolutionClimate(ClimateEntity):
|
||||
return HVACAction.HEATING
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_parse_hvac_mode",
|
||||
translation_key="failed_to_parse_hvac_action",
|
||||
translation_placeholders={
|
||||
"mode_and_active": mode_and_active,
|
||||
"current_temperature": str(self.current_temperature),
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
},
|
||||
"exceptions": {
|
||||
"failed_to_parse_hvac_action": {
|
||||
"message": "Could not determine HVAC action: {mode_and_active}, {self.current_temperature}, {self.target_temperature_low}"
|
||||
"message": "Could not determine HVAC action: {mode_and_active}, {current_temperature}, {target_temperature_low}"
|
||||
},
|
||||
"failed_to_parse_hvac_mode": {
|
||||
"message": "Cannot parse response to HVACMode: {mode}"
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pycync==0.4.2"]
|
||||
"requirements": ["pycync==0.4.3"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["sleekxmppfs", "sucks", "deebot_client"],
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==16.1.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==16.3.0"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eheimdigital"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["eheimdigital==1.3.0"],
|
||||
"requirements": ["eheimdigital==1.4.0"],
|
||||
"zeroconf": [
|
||||
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
|
||||
]
|
||||
|
||||
@@ -40,7 +40,9 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> bool:
|
||||
client = Firefly(
|
||||
api_url=data[CONF_URL],
|
||||
api_key=data[CONF_API_KEY],
|
||||
session=async_get_clientsession(hass),
|
||||
session=async_get_clientsession(
|
||||
hass=hass, verify_ssl=data[CONF_VERIFY_SSL]
|
||||
),
|
||||
)
|
||||
await client.get_about()
|
||||
except FireflyAuthenticationError:
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251029.0"]
|
||||
"requirements": ["home-assistant-frontend==20251029.1"]
|
||||
}
|
||||
|
||||
@@ -620,7 +620,11 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
# Pop add-on data
|
||||
# Unload coordinator
|
||||
coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
|
||||
coordinator.unload()
|
||||
|
||||
# Pop coordinator
|
||||
hass.data.pop(ADDONS_COORDINATOR, None)
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -563,3 +563,8 @@ class HassioDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
self.async_set_updated_data(data)
|
||||
except SupervisorError as err:
|
||||
_LOGGER.warning("Could not refresh info for %s: %s", addon_slug, err)
|
||||
|
||||
@callback
|
||||
def unload(self) -> None:
|
||||
"""Clean up when config entry unloaded."""
|
||||
self.jobs.unload()
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -29,6 +30,8 @@ from .const import (
|
||||
)
|
||||
from .handler import get_supervisor_client
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class JobSubscription:
|
||||
@@ -45,7 +48,7 @@ class JobSubscription:
|
||||
event_callback: Callable[[Job], Any]
|
||||
uuid: str | None = None
|
||||
name: str | None = None
|
||||
reference: str | None | type[Any] = Any
|
||||
reference: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate at least one filter option is present."""
|
||||
@@ -58,7 +61,7 @@ class JobSubscription:
|
||||
"""Return true if job matches subscription filters."""
|
||||
if self.uuid:
|
||||
return job.uuid == self.uuid
|
||||
return job.name == self.name and self.reference in (Any, job.reference)
|
||||
return job.name == self.name and self.reference in (None, job.reference)
|
||||
|
||||
|
||||
class SupervisorJobs:
|
||||
@@ -70,6 +73,7 @@ class SupervisorJobs:
|
||||
self._supervisor_client = get_supervisor_client(hass)
|
||||
self._jobs: dict[UUID, Job] = {}
|
||||
self._subscriptions: set[JobSubscription] = set()
|
||||
self._dispatcher_disconnect: Callable[[], None] | None = None
|
||||
|
||||
@property
|
||||
def current_jobs(self) -> list[Job]:
|
||||
@@ -79,20 +83,24 @@ class SupervisorJobs:
|
||||
def subscribe(self, subscription: JobSubscription) -> CALLBACK_TYPE:
|
||||
"""Subscribe to updates for job. Return callback is used to unsubscribe.
|
||||
|
||||
If any jobs match the subscription at the time this is called, creates
|
||||
tasks to run their callback on it.
|
||||
If any jobs match the subscription at the time this is called, runs the
|
||||
callback on them.
|
||||
"""
|
||||
self._subscriptions.add(subscription)
|
||||
|
||||
# As these are callbacks they are safe to run in the event loop
|
||||
# We wrap these in an asyncio task so subscribing does not wait on the logic
|
||||
if matches := [job for job in self._jobs.values() if subscription.matches(job)]:
|
||||
|
||||
async def event_callback_async(job: Job) -> Any:
|
||||
return subscription.event_callback(job)
|
||||
|
||||
for match in matches:
|
||||
self._hass.async_create_task(event_callback_async(match))
|
||||
# Run the callback on each existing match
|
||||
# We catch all errors to prevent an error in one from stopping the others
|
||||
for match in [job for job in self._jobs.values() if subscription.matches(job)]:
|
||||
try:
|
||||
return subscription.event_callback(match)
|
||||
except Exception as err: # noqa: BLE001
|
||||
_LOGGER.error(
|
||||
"Error encountered processing Supervisor Job (%s %s %s) - %s",
|
||||
match.name,
|
||||
match.reference,
|
||||
match.uuid,
|
||||
err,
|
||||
)
|
||||
|
||||
return partial(self._subscriptions.discard, subscription)
|
||||
|
||||
@@ -131,7 +139,7 @@ class SupervisorJobs:
|
||||
|
||||
# If this is the first update register to receive Supervisor events
|
||||
if first_update:
|
||||
async_dispatcher_connect(
|
||||
self._dispatcher_disconnect = async_dispatcher_connect(
|
||||
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs
|
||||
)
|
||||
|
||||
@@ -158,3 +166,14 @@ class SupervisorJobs:
|
||||
for sub in self._subscriptions:
|
||||
if sub.matches(job):
|
||||
sub.event_callback(job)
|
||||
|
||||
# If the job is done, pop it from our cache if present after processing is done
|
||||
if job.done and job.uuid in self._jobs:
|
||||
del self._jobs[job.uuid]
|
||||
|
||||
@callback
|
||||
def unload(self) -> None:
|
||||
"""Unregister with dispatcher on config entry unload."""
|
||||
if self._dispatcher_disconnect:
|
||||
self._dispatcher_disconnect()
|
||||
self._dispatcher_disconnect = None
|
||||
|
||||
@@ -299,8 +299,8 @@ def _create_climate_ui(xknx: XKNX, conf: ConfigExtractor, name: str) -> XknxClim
|
||||
group_address_active_state=conf.get_state_and_passive(CONF_GA_ACTIVE),
|
||||
group_address_command_value_state=conf.get_state_and_passive(CONF_GA_VALVE),
|
||||
sync_state=sync_state,
|
||||
min_temp=conf.get(ClimateConf.MIN_TEMP),
|
||||
max_temp=conf.get(ClimateConf.MAX_TEMP),
|
||||
min_temp=conf.get(CONF_TARGET_TEMPERATURE, ClimateConf.MIN_TEMP),
|
||||
max_temp=conf.get(CONF_TARGET_TEMPERATURE, ClimateConf.MAX_TEMP),
|
||||
mode=climate_mode,
|
||||
group_address_fan_speed=conf.get_write(CONF_GA_FAN_SPEED),
|
||||
group_address_fan_speed_state=conf.get_state_and_passive(CONF_GA_FAN_SPEED),
|
||||
@@ -486,7 +486,7 @@ class _KnxClimate(ClimateEntity, _KnxEntityBase):
|
||||
ha_controller_modes.append(self._last_hvac_mode)
|
||||
ha_controller_modes.append(HVACMode.OFF)
|
||||
|
||||
hvac_modes = list(set(filter(None, ha_controller_modes)))
|
||||
hvac_modes = sorted(set(filter(None, ha_controller_modes)))
|
||||
return (
|
||||
hvac_modes
|
||||
if hvac_modes
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.10.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.10.26.81530"
|
||||
"knx-frontend==2025.10.31.195356"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["librehardwaremonitor-api==1.4.0"]
|
||||
"requirements": ["librehardwaremonitor-api==1.5.0"]
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorStateClass
|
||||
@@ -51,10 +53,10 @@ class LibreHardwareMonitorSensor(
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_name: str = sensor_data.name
|
||||
self.value: str | None = sensor_data.value
|
||||
self._attr_extra_state_attributes: dict[str, str] = {
|
||||
STATE_MIN_VALUE: self._format_number_value(sensor_data.min),
|
||||
STATE_MAX_VALUE: self._format_number_value(sensor_data.max),
|
||||
self._attr_native_value: str | None = sensor_data.value
|
||||
self._attr_extra_state_attributes: dict[str, Any] = {
|
||||
STATE_MIN_VALUE: sensor_data.min,
|
||||
STATE_MAX_VALUE: sensor_data.max,
|
||||
}
|
||||
self._attr_native_unit_of_measurement = sensor_data.unit
|
||||
self._attr_unique_id: str = f"{entry_id}_{sensor_data.sensor_id}"
|
||||
@@ -72,23 +74,12 @@ class LibreHardwareMonitorSensor(
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
if sensor_data := self.coordinator.data.sensor_data.get(self._sensor_id):
|
||||
self.value = sensor_data.value
|
||||
self._attr_native_value = sensor_data.value
|
||||
self._attr_extra_state_attributes = {
|
||||
STATE_MIN_VALUE: self._format_number_value(sensor_data.min),
|
||||
STATE_MAX_VALUE: self._format_number_value(sensor_data.max),
|
||||
STATE_MIN_VALUE: sensor_data.min,
|
||||
STATE_MAX_VALUE: sensor_data.max,
|
||||
}
|
||||
else:
|
||||
self.value = None
|
||||
self._attr_native_value = None
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the formatted sensor value or None if no value is available."""
|
||||
if self.value is not None and self.value != "-":
|
||||
return self._format_number_value(self.value)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_number_value(number_str: str) -> str:
|
||||
return number_str.replace(",", ".")
|
||||
|
||||
@@ -19,7 +19,6 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
REVOLUTIONS_PER_MINUTE,
|
||||
STATE_UNKNOWN,
|
||||
EntityCategory,
|
||||
UnitOfEnergy,
|
||||
UnitOfTemperature,
|
||||
@@ -762,40 +761,35 @@ class MieleSensor(MieleEntity, SensorEntity):
|
||||
class MieleRestorableSensor(MieleSensor, RestoreSensor):
|
||||
"""Representation of a Sensor whose internal state can be restored."""
|
||||
|
||||
_last_value: StateType
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
coordinator: MieleDataUpdateCoordinator,
|
||||
device_id: str,
|
||||
description: MieleSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator, device_id, description)
|
||||
self._last_value = None
|
||||
_attr_native_value: StateType
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""When entity is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
# recover last value from cache when adding entity
|
||||
last_value = await self.async_get_last_state()
|
||||
if last_value and last_value.state != STATE_UNKNOWN:
|
||||
self._last_value = last_value.state
|
||||
last_data = await self.async_get_last_sensor_data()
|
||||
if last_data:
|
||||
self._attr_native_value = last_data.native_value # type: ignore[assignment]
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor."""
|
||||
return self._last_value
|
||||
"""Return the state of the sensor.
|
||||
|
||||
def _update_last_value(self) -> None:
|
||||
"""Update the last value of the sensor."""
|
||||
self._last_value = self.entity_description.value_fn(self.device)
|
||||
It is necessary to override `native_value` to fall back to the default
|
||||
attribute-based implementation, instead of the function-based
|
||||
implementation in `MieleSensor`.
|
||||
"""
|
||||
return self._attr_native_value
|
||||
|
||||
def _update_native_value(self) -> None:
|
||||
"""Update the native value attribute of the sensor."""
|
||||
self._attr_native_value = self.entity_description.value_fn(self.device)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._update_last_value()
|
||||
self._update_native_value()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
|
||||
@@ -912,7 +906,7 @@ class MieleProgramIdSensor(MieleSensor):
|
||||
class MieleTimeSensor(MieleRestorableSensor):
|
||||
"""Representation of time sensors keeping state from cache."""
|
||||
|
||||
def _update_last_value(self) -> None:
|
||||
def _update_native_value(self) -> None:
|
||||
"""Update the last value of the sensor."""
|
||||
|
||||
current_value = self.entity_description.value_fn(self.device)
|
||||
@@ -923,7 +917,9 @@ class MieleTimeSensor(MieleRestorableSensor):
|
||||
current_status == StateStatus.PROGRAM_ENDED
|
||||
and self.entity_description.end_value_fn is not None
|
||||
):
|
||||
self._last_value = self.entity_description.end_value_fn(self._last_value)
|
||||
self._attr_native_value = self.entity_description.end_value_fn(
|
||||
self._attr_native_value
|
||||
)
|
||||
|
||||
# keep value when program ends if no function is specified
|
||||
elif current_status == StateStatus.PROGRAM_ENDED:
|
||||
@@ -931,11 +927,11 @@ class MieleTimeSensor(MieleRestorableSensor):
|
||||
|
||||
# force unknown when appliance is not working (some devices are keeping last value until a new cycle starts)
|
||||
elif current_status in (StateStatus.OFF, StateStatus.ON, StateStatus.IDLE):
|
||||
self._last_value = None
|
||||
self._attr_native_value = None
|
||||
|
||||
# otherwise, cache value and return it
|
||||
else:
|
||||
self._last_value = current_value
|
||||
self._attr_native_value = current_value
|
||||
|
||||
|
||||
class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
@@ -943,13 +939,13 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
|
||||
_is_reporting: bool = False
|
||||
|
||||
def _update_last_value(self) -> None:
|
||||
def _update_native_value(self) -> None:
|
||||
"""Update the last value of the sensor."""
|
||||
current_value = self.entity_description.value_fn(self.device)
|
||||
current_status = StateStatus(self.device.state_status)
|
||||
last_value = (
|
||||
float(cast(str, self._last_value))
|
||||
if self._last_value is not None and self._last_value != STATE_UNKNOWN
|
||||
float(cast(str, self._attr_native_value))
|
||||
if self._attr_native_value is not None
|
||||
else 0
|
||||
)
|
||||
|
||||
@@ -963,7 +959,7 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
StateStatus.SERVICE,
|
||||
):
|
||||
self._is_reporting = False
|
||||
self._last_value = None
|
||||
self._attr_native_value = None
|
||||
|
||||
# appliance might report the last value for consumption of previous cycle and it will report 0
|
||||
# only after a while, so it is necessary to force 0 until we see the 0 value coming from API, unless
|
||||
@@ -973,7 +969,7 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
and not self._is_reporting
|
||||
and last_value > 0
|
||||
):
|
||||
self._last_value = current_value
|
||||
self._attr_native_value = current_value
|
||||
self._is_reporting = True
|
||||
|
||||
elif (
|
||||
@@ -982,12 +978,12 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
and current_value is not None
|
||||
and cast(int, current_value) > 0
|
||||
):
|
||||
self._last_value = 0
|
||||
self._attr_native_value = 0
|
||||
|
||||
# keep value when program ends
|
||||
elif current_status == StateStatus.PROGRAM_ENDED:
|
||||
pass
|
||||
|
||||
else:
|
||||
self._last_value = current_value
|
||||
self._attr_native_value = current_value
|
||||
self._is_reporting = True
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Support for Neato botvac connected vacuum cleaners."""
|
||||
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from pybotvac import Account
|
||||
from pybotvac.exceptions import NeatoException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from . import api
|
||||
from .const import NEATO_DOMAIN, NEATO_LOGIN
|
||||
from .hub import NeatoHub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.CAMERA,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.VACUUM,
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up config entry."""
|
||||
hass.data.setdefault(NEATO_DOMAIN, {})
|
||||
if CONF_TOKEN not in entry.data:
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
implementation = (
|
||||
await config_entry_oauth2_flow.async_get_config_entry_implementation(
|
||||
hass, entry
|
||||
)
|
||||
)
|
||||
|
||||
session = config_entry_oauth2_flow.OAuth2Session(hass, entry, implementation)
|
||||
try:
|
||||
await session.async_ensure_token_valid()
|
||||
except aiohttp.ClientResponseError as ex:
|
||||
_LOGGER.debug("API error: %s (%s)", ex.code, ex.message)
|
||||
if ex.code in (401, 403):
|
||||
raise ConfigEntryAuthFailed("Token not valid, trigger renewal") from ex
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
neato_session = api.ConfigEntryAuth(hass, entry, implementation)
|
||||
hass.data[NEATO_DOMAIN][entry.entry_id] = neato_session
|
||||
hub = NeatoHub(hass, Account(neato_session))
|
||||
|
||||
await hub.async_update_entry_unique_id(entry)
|
||||
|
||||
try:
|
||||
await hass.async_add_executor_job(hub.update_robots)
|
||||
except NeatoException as ex:
|
||||
_LOGGER.debug("Failed to connect to Neato API")
|
||||
raise ConfigEntryNotReady from ex
|
||||
|
||||
hass.data[NEATO_LOGIN] = hub
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[NEATO_DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
@@ -0,0 +1,58 @@
|
||||
"""API for Neato Botvac bound to Home Assistant OAuth."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from asyncio import run_coroutine_threadsafe
|
||||
from typing import Any
|
||||
|
||||
import pybotvac
|
||||
|
||||
from homeassistant import config_entries, core
|
||||
from homeassistant.components.application_credentials import AuthImplementation
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
|
||||
class ConfigEntryAuth(pybotvac.OAuthSession): # type: ignore[misc]
|
||||
"""Provide Neato Botvac authentication tied to an OAuth2 based config entry."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: core.HomeAssistant,
|
||||
config_entry: config_entries.ConfigEntry,
|
||||
implementation: config_entry_oauth2_flow.AbstractOAuth2Implementation,
|
||||
) -> None:
|
||||
"""Initialize Neato Botvac Auth."""
|
||||
self.hass = hass
|
||||
self.session = config_entry_oauth2_flow.OAuth2Session(
|
||||
hass, config_entry, implementation
|
||||
)
|
||||
super().__init__(self.session.token, vendor=pybotvac.Neato())
|
||||
|
||||
def refresh_tokens(self) -> str:
|
||||
"""Refresh and return new Neato Botvac tokens."""
|
||||
run_coroutine_threadsafe(
|
||||
self.session.async_ensure_token_valid(), self.hass.loop
|
||||
).result()
|
||||
|
||||
return self.session.token["access_token"] # type: ignore[no-any-return]
|
||||
|
||||
|
||||
class NeatoImplementation(AuthImplementation):
|
||||
"""Neato implementation of LocalOAuth2Implementation.
|
||||
|
||||
We need this class because we have to add client_secret
|
||||
and scope to the authorization request.
|
||||
"""
|
||||
|
||||
@property
|
||||
def extra_authorize_data(self) -> dict[str, Any]:
|
||||
"""Extra data that needs to be appended to the authorize url."""
|
||||
return {"client_secret": self.client_secret}
|
||||
|
||||
async def async_generate_authorize_url(self, flow_id: str) -> str:
|
||||
"""Generate a url for the user to authorize.
|
||||
|
||||
We must make sure that the plus signs are not encoded.
|
||||
"""
|
||||
url = await super().async_generate_authorize_url(flow_id)
|
||||
return f"{url}&scope=public_profile+control_robots+maps"
|
||||
@@ -0,0 +1,28 @@
|
||||
"""Application credentials platform for neato."""
|
||||
|
||||
from pybotvac import Neato
|
||||
|
||||
from homeassistant.components.application_credentials import (
|
||||
AuthorizationServer,
|
||||
ClientCredential,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from . import api
|
||||
|
||||
|
||||
async def async_get_auth_implementation(
|
||||
hass: HomeAssistant, auth_domain: str, credential: ClientCredential
|
||||
) -> config_entry_oauth2_flow.AbstractOAuth2Implementation:
|
||||
"""Return auth implementation for a custom auth implementation."""
|
||||
vendor = Neato()
|
||||
return api.NeatoImplementation(
|
||||
hass,
|
||||
auth_domain,
|
||||
credential,
|
||||
AuthorizationServer(
|
||||
authorize_url=vendor.auth_endpoint,
|
||||
token_url=vendor.token_endpoint,
|
||||
),
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Support for Neato buttons."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pybotvac import Robot
|
||||
|
||||
from homeassistant.components.button import ButtonEntity
|
||||
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 NEATO_ROBOTS
|
||||
from .entity import NeatoEntity
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Neato button from config entry."""
|
||||
entities = [NeatoDismissAlertButton(robot) for robot in hass.data[NEATO_ROBOTS]]
|
||||
|
||||
async_add_entities(entities, True)
|
||||
|
||||
|
||||
class NeatoDismissAlertButton(NeatoEntity, ButtonEntity):
|
||||
"""Representation of a dismiss_alert button entity."""
|
||||
|
||||
_attr_translation_key = "dismiss_alert"
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
robot: Robot,
|
||||
) -> None:
|
||||
"""Initialize a dismiss_alert Neato button entity."""
|
||||
super().__init__(robot)
|
||||
self._attr_unique_id = f"{robot.serial}_dismiss_alert"
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
await self.hass.async_add_executor_job(self.robot.dismiss_current_alert)
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Support for loading picture from Neato."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pybotvac.exceptions import NeatoRobotException
|
||||
from pybotvac.robot import Robot
|
||||
from urllib3.response import HTTPResponse
|
||||
|
||||
from homeassistant.components.camera import Camera
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import NEATO_LOGIN, NEATO_MAP_DATA, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
|
||||
from .entity import NeatoEntity
|
||||
from .hub import NeatoHub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES)
|
||||
ATTR_GENERATED_AT = "generated_at"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Neato camera with config entry."""
|
||||
neato: NeatoHub = hass.data[NEATO_LOGIN]
|
||||
mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA)
|
||||
dev = [
|
||||
NeatoCleaningMap(neato, robot, mapdata)
|
||||
for robot in hass.data[NEATO_ROBOTS]
|
||||
if "maps" in robot.traits
|
||||
]
|
||||
|
||||
if not dev:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Adding robots for cleaning maps %s", dev)
|
||||
async_add_entities(dev, True)
|
||||
|
||||
|
||||
class NeatoCleaningMap(NeatoEntity, Camera):
|
||||
"""Neato cleaning map for last clean."""
|
||||
|
||||
_attr_translation_key = "cleaning_map"
|
||||
|
||||
def __init__(
|
||||
self, neato: NeatoHub, robot: Robot, mapdata: dict[str, Any] | None
|
||||
) -> None:
|
||||
"""Initialize Neato cleaning map."""
|
||||
super().__init__(robot)
|
||||
Camera.__init__(self)
|
||||
self.neato = neato
|
||||
self._mapdata = mapdata
|
||||
self._available = neato is not None
|
||||
self._robot_serial: str = self.robot.serial
|
||||
self._attr_unique_id = self.robot.serial
|
||||
self._generated_at: str | None = None
|
||||
self._image_url: str | None = None
|
||||
self._image: bytes | None = None
|
||||
|
||||
def camera_image(
|
||||
self, width: int | None = None, height: int | None = None
|
||||
) -> bytes | None:
|
||||
"""Return image response."""
|
||||
self.update()
|
||||
return self._image
|
||||
|
||||
def update(self) -> None:
|
||||
"""Check the contents of the map list."""
|
||||
|
||||
_LOGGER.debug("Running camera update for '%s'", self.entity_id)
|
||||
try:
|
||||
self.neato.update_robots()
|
||||
except NeatoRobotException as ex:
|
||||
if self._available: # Print only once when available
|
||||
_LOGGER.error(
|
||||
"Neato camera connection error for '%s': %s", self.entity_id, ex
|
||||
)
|
||||
self._image = None
|
||||
self._image_url = None
|
||||
self._available = False
|
||||
return
|
||||
|
||||
if self._mapdata:
|
||||
map_data: dict[str, Any] = self._mapdata[self._robot_serial]["maps"][0]
|
||||
if (image_url := map_data["url"]) == self._image_url:
|
||||
_LOGGER.debug(
|
||||
"The map image_url for '%s' is the same as old", self.entity_id
|
||||
)
|
||||
return
|
||||
|
||||
try:
|
||||
image: HTTPResponse = self.neato.download_map(image_url)
|
||||
except NeatoRobotException as ex:
|
||||
if self._available: # Print only once when available
|
||||
_LOGGER.error(
|
||||
"Neato camera connection error for '%s': %s", self.entity_id, ex
|
||||
)
|
||||
self._image = None
|
||||
self._image_url = None
|
||||
self._available = False
|
||||
return
|
||||
|
||||
self._image = image.read()
|
||||
self._image_url = image_url
|
||||
self._generated_at = map_data.get("generated_at")
|
||||
self._available = True
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the robot is available."""
|
||||
return self._available
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes of the vacuum cleaner."""
|
||||
data: dict[str, Any] = {}
|
||||
|
||||
if self._generated_at is not None:
|
||||
data[ATTR_GENERATED_AT] = self._generated_at
|
||||
|
||||
return data
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Config flow for Neato Botvac."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlowResult
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from .const import NEATO_DOMAIN
|
||||
|
||||
|
||||
class OAuth2FlowHandler(
|
||||
config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=NEATO_DOMAIN
|
||||
):
|
||||
"""Config flow to handle Neato Botvac OAuth2 authentication."""
|
||||
|
||||
DOMAIN = NEATO_DOMAIN
|
||||
|
||||
@property
|
||||
def logger(self) -> logging.Logger:
|
||||
"""Return logger."""
|
||||
return logging.getLogger(__name__)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Create an entry for the flow."""
|
||||
current_entries = self._async_current_entries()
|
||||
if self.source != SOURCE_REAUTH and current_entries:
|
||||
# Already configured
|
||||
return self.async_abort(reason="already_configured")
|
||||
|
||||
return await super().async_step_user(user_input=user_input)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Perform reauth upon migration of old entries."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm reauth upon migration of old entries."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="reauth_confirm")
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
|
||||
"""Create an entry for the flow. Update an entry if one already exist."""
|
||||
current_entries = self._async_current_entries()
|
||||
if self.source == SOURCE_REAUTH and current_entries:
|
||||
# Update entry
|
||||
self.hass.config_entries.async_update_entry(
|
||||
current_entries[0], title=self.flow_impl.name, data=data
|
||||
)
|
||||
self.hass.async_create_task(
|
||||
self.hass.config_entries.async_reload(current_entries[0].entry_id)
|
||||
)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
return self.async_create_entry(title=self.flow_impl.name, data=data)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Constants for Neato integration."""
|
||||
|
||||
NEATO_DOMAIN = "neato"
|
||||
|
||||
CONF_VENDOR = "vendor"
|
||||
NEATO_LOGIN = "neato_login"
|
||||
NEATO_MAP_DATA = "neato_map_data"
|
||||
NEATO_PERSISTENT_MAPS = "neato_persistent_maps"
|
||||
NEATO_ROBOTS = "neato_robots"
|
||||
|
||||
SCAN_INTERVAL_MINUTES = 1
|
||||
|
||||
MODE = {1: "Eco", 2: "Turbo"}
|
||||
|
||||
ACTION = {
|
||||
0: "Invalid",
|
||||
1: "House Cleaning",
|
||||
2: "Spot Cleaning",
|
||||
3: "Manual Cleaning",
|
||||
4: "Docking",
|
||||
5: "User Menu Active",
|
||||
6: "Suspended Cleaning",
|
||||
7: "Updating",
|
||||
8: "Copying logs",
|
||||
9: "Recovering Location",
|
||||
10: "IEC test",
|
||||
11: "Map cleaning",
|
||||
12: "Exploring map (creating a persistent map)",
|
||||
13: "Acquiring Persistent Map IDs",
|
||||
14: "Creating & Uploading Map",
|
||||
15: "Suspended Exploration",
|
||||
}
|
||||
|
||||
ERRORS = {
|
||||
"ui_error_battery_battundervoltlithiumsafety": "Replace battery",
|
||||
"ui_error_battery_critical": "Replace battery",
|
||||
"ui_error_battery_invalidsensor": "Replace battery",
|
||||
"ui_error_battery_lithiumadapterfailure": "Replace battery",
|
||||
"ui_error_battery_mismatch": "Replace battery",
|
||||
"ui_error_battery_nothermistor": "Replace battery",
|
||||
"ui_error_battery_overtemp": "Replace battery",
|
||||
"ui_error_battery_overvolt": "Replace battery",
|
||||
"ui_error_battery_undercurrent": "Replace battery",
|
||||
"ui_error_battery_undertemp": "Replace battery",
|
||||
"ui_error_battery_undervolt": "Replace battery",
|
||||
"ui_error_battery_unplugged": "Replace battery",
|
||||
"ui_error_brush_stuck": "Brush stuck",
|
||||
"ui_error_brush_overloaded": "Brush overloaded",
|
||||
"ui_error_bumper_stuck": "Bumper stuck",
|
||||
"ui_error_check_battery_switch": "Check battery",
|
||||
"ui_error_corrupt_scb": "Call customer service corrupt board",
|
||||
"ui_error_deck_debris": "Deck debris",
|
||||
"ui_error_dflt_app": "Check Neato app",
|
||||
"ui_error_disconnect_chrg_cable": "Disconnected charge cable",
|
||||
"ui_error_disconnect_usb_cable": "Disconnected USB cable",
|
||||
"ui_error_dust_bin_missing": "Dust bin missing",
|
||||
"ui_error_dust_bin_full": "Dust bin full",
|
||||
"ui_error_dust_bin_emptied": "Dust bin emptied",
|
||||
"ui_error_hardware_failure": "Hardware failure",
|
||||
"ui_error_ldrop_stuck": "Clear my path",
|
||||
"ui_error_lds_jammed": "Clear my path",
|
||||
"ui_error_lds_bad_packets": "Check Neato app",
|
||||
"ui_error_lds_disconnected": "Check Neato app",
|
||||
"ui_error_lds_missed_packets": "Check Neato app",
|
||||
"ui_error_lwheel_stuck": "Clear my path",
|
||||
"ui_error_navigation_backdrop_frontbump": "Clear my path",
|
||||
"ui_error_navigation_backdrop_leftbump": "Clear my path",
|
||||
"ui_error_navigation_backdrop_wheelextended": "Clear my path",
|
||||
"ui_error_navigation_noprogress": "Clear my path",
|
||||
"ui_error_navigation_origin_unclean": "Clear my path",
|
||||
"ui_error_navigation_pathproblems": "Cannot return to base",
|
||||
"ui_error_navigation_pinkycommsfail": "Clear my path",
|
||||
"ui_error_navigation_falling": "Clear my path",
|
||||
"ui_error_navigation_noexitstogo": "Clear my path",
|
||||
"ui_error_navigation_nomotioncommands": "Clear my path",
|
||||
"ui_error_navigation_rightdrop_leftbump": "Clear my path",
|
||||
"ui_error_navigation_undockingfailed": "Clear my path",
|
||||
"ui_error_picked_up": "Picked up",
|
||||
"ui_error_qa_fail": "Check Neato app",
|
||||
"ui_error_rdrop_stuck": "Clear my path",
|
||||
"ui_error_reconnect_failed": "Reconnect failed",
|
||||
"ui_error_rwheel_stuck": "Clear my path",
|
||||
"ui_error_stuck": "Stuck!",
|
||||
"ui_error_unable_to_return_to_base": "Unable to return to base",
|
||||
"ui_error_unable_to_see": "Clean vacuum sensors",
|
||||
"ui_error_vacuum_slip": "Clear my path",
|
||||
"ui_error_vacuum_stuck": "Clear my path",
|
||||
"ui_error_warning": "Error check app",
|
||||
"batt_base_connect_fail": "Battery failed to connect to base",
|
||||
"batt_base_no_power": "Battery base has no power",
|
||||
"batt_low": "Battery low",
|
||||
"batt_on_base": "Battery on base",
|
||||
"clean_tilt_on_start": "Clean the tilt on start",
|
||||
"dustbin_full": "Dust bin full",
|
||||
"dustbin_missing": "Dust bin missing",
|
||||
"gen_picked_up": "Picked up",
|
||||
"hw_fail": "Hardware failure",
|
||||
"hw_tof_sensor_sensor": "Hardware sensor disconnected",
|
||||
"lds_bad_packets": "Bad packets",
|
||||
"lds_deck_debris": "Debris on deck",
|
||||
"lds_disconnected": "Disconnected",
|
||||
"lds_jammed": "Jammed",
|
||||
"lds_missed_packets": "Missed packets",
|
||||
"maint_brush_stuck": "Brush stuck",
|
||||
"maint_brush_overload": "Brush overloaded",
|
||||
"maint_bumper_stuck": "Bumper stuck",
|
||||
"maint_customer_support_qa": "Contact customer support",
|
||||
"maint_vacuum_stuck": "Vacuum is stuck",
|
||||
"maint_vacuum_slip": "Vacuum is stuck",
|
||||
"maint_left_drop_stuck": "Vacuum is stuck",
|
||||
"maint_left_wheel_stuck": "Vacuum is stuck",
|
||||
"maint_right_drop_stuck": "Vacuum is stuck",
|
||||
"maint_right_wheel_stuck": "Vacuum is stuck",
|
||||
"not_on_charge_base": "Not on the charge base",
|
||||
"nav_robot_falling": "Clear my path",
|
||||
"nav_no_path": "Clear my path",
|
||||
"nav_path_problem": "Clear my path",
|
||||
"nav_backdrop_frontbump": "Clear my path",
|
||||
"nav_backdrop_leftbump": "Clear my path",
|
||||
"nav_backdrop_wheelextended": "Clear my path",
|
||||
"nav_floorplan_zone_path_blocked": "Clear my path",
|
||||
"nav_mag_sensor": "Clear my path",
|
||||
"nav_no_exit": "Clear my path",
|
||||
"nav_no_movement": "Clear my path",
|
||||
"nav_rightdrop_leftbump": "Clear my path",
|
||||
"nav_undocking_failed": "Clear my path",
|
||||
}
|
||||
|
||||
ALERTS = {
|
||||
"ui_alert_dust_bin_full": "Please empty dust bin",
|
||||
"ui_alert_recovering_location": "Returning to start",
|
||||
"ui_alert_battery_chargebasecommerr": "Battery error",
|
||||
"ui_alert_busy_charging": "Busy charging",
|
||||
"ui_alert_charging_base": "Base charging",
|
||||
"ui_alert_charging_power": "Charging power",
|
||||
"ui_alert_connect_chrg_cable": "Connect charge cable",
|
||||
"ui_alert_info_thank_you": "Thank you",
|
||||
"ui_alert_invalid": "Invalid check app",
|
||||
"ui_alert_old_error": "Old error",
|
||||
"ui_alert_swupdate_fail": "Update failed",
|
||||
"dustbin_full": "Please empty dust bin",
|
||||
"maint_brush_change": "Change the brush",
|
||||
"maint_filter_change": "Change the filter",
|
||||
"clean_completed_to_start": "Cleaning completed",
|
||||
"nav_floorplan_not_created": "No floorplan found",
|
||||
"nav_floorplan_load_fail": "Failed to load floorplan",
|
||||
"nav_floorplan_localization_fail": "Failed to load floorplan",
|
||||
"clean_incomplete_to_start": "Cleaning incomplete",
|
||||
"log_upload_failed": "Logs failed to upload",
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
"""Base entity for Neato."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pybotvac import Robot
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
from .const import NEATO_DOMAIN
|
||||
|
||||
|
||||
class NeatoEntity(Entity):
|
||||
"""Base Neato entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, robot: Robot) -> None:
|
||||
"""Initialize Neato entity."""
|
||||
self.robot = robot
|
||||
self._attr_device_info: DeviceInfo = DeviceInfo(
|
||||
identifiers={(NEATO_DOMAIN, self.robot.serial)},
|
||||
name=self.robot.name,
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Support for Neato botvac connected vacuum cleaners."""
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pybotvac import Account
|
||||
from urllib3.response import HTTPResponse
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from .const import NEATO_MAP_DATA, NEATO_PERSISTENT_MAPS, NEATO_ROBOTS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NeatoHub:
|
||||
"""A My Neato hub wrapper class."""
|
||||
|
||||
def __init__(self, hass: HomeAssistant, neato: Account) -> None:
|
||||
"""Initialize the Neato hub."""
|
||||
self._hass = hass
|
||||
self.my_neato: Account = neato
|
||||
|
||||
@Throttle(timedelta(minutes=1))
|
||||
def update_robots(self) -> None:
|
||||
"""Update the robot states."""
|
||||
_LOGGER.debug("Running HUB.update_robots %s", self._hass.data.get(NEATO_ROBOTS))
|
||||
self._hass.data[NEATO_ROBOTS] = self.my_neato.robots
|
||||
self._hass.data[NEATO_PERSISTENT_MAPS] = self.my_neato.persistent_maps
|
||||
self._hass.data[NEATO_MAP_DATA] = self.my_neato.maps
|
||||
|
||||
def download_map(self, url: str) -> HTTPResponse:
|
||||
"""Download a new map image."""
|
||||
map_image_data: HTTPResponse = self.my_neato.get_map_image(url)
|
||||
return map_image_data
|
||||
|
||||
async def async_update_entry_unique_id(self, entry: ConfigEntry) -> str:
|
||||
"""Update entry for unique_id."""
|
||||
|
||||
await self._hass.async_add_executor_job(self.my_neato.refresh_userdata)
|
||||
unique_id: str = self.my_neato.unique_id
|
||||
|
||||
if entry.unique_id == unique_id:
|
||||
return unique_id
|
||||
|
||||
_LOGGER.debug("Updating user unique_id for previous config entry")
|
||||
self._hass.config_entries.async_update_entry(entry, unique_id=unique_id)
|
||||
return unique_id
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"services": {
|
||||
"custom_cleaning": {
|
||||
"service": "mdi:broom"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "neato",
|
||||
"name": "Neato Botvac",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/neato",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pybotvac"],
|
||||
"requirements": ["pybotvac==0.0.28"]
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Support for Neato sensors."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pybotvac.exceptions import NeatoRobotException
|
||||
from pybotvac.robot import Robot
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import PERCENTAGE, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
|
||||
from .entity import NeatoEntity
|
||||
from .hub import NeatoHub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES)
|
||||
|
||||
BATTERY = "Battery"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Neato sensor using config entry."""
|
||||
neato: NeatoHub = hass.data[NEATO_LOGIN]
|
||||
dev = [NeatoSensor(neato, robot) for robot in hass.data[NEATO_ROBOTS]]
|
||||
|
||||
if not dev:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Adding robots for sensors %s", dev)
|
||||
async_add_entities(dev, True)
|
||||
|
||||
|
||||
class NeatoSensor(NeatoEntity, SensorEntity):
|
||||
"""Neato sensor."""
|
||||
|
||||
_attr_device_class = SensorDeviceClass.BATTERY
|
||||
_attr_entity_category = EntityCategory.DIAGNOSTIC
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_available: bool = False
|
||||
|
||||
def __init__(self, neato: NeatoHub, robot: Robot) -> None:
|
||||
"""Initialize Neato sensor."""
|
||||
super().__init__(robot)
|
||||
self._robot_serial: str = self.robot.serial
|
||||
self._attr_unique_id = self.robot.serial
|
||||
self._state: dict[str, Any] | None = None
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update Neato Sensor."""
|
||||
try:
|
||||
self._state = self.robot.state
|
||||
except NeatoRobotException as ex:
|
||||
if self._attr_available:
|
||||
_LOGGER.error(
|
||||
"Neato sensor connection error for '%s': %s", self.entity_id, ex
|
||||
)
|
||||
self._state = None
|
||||
self._attr_available = False
|
||||
return
|
||||
|
||||
self._attr_available = True
|
||||
_LOGGER.debug("self._state=%s", self._state)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the state."""
|
||||
if self._state is not None:
|
||||
return str(self._state["details"]["charge"])
|
||||
return None
|
||||
@@ -0,0 +1,32 @@
|
||||
custom_cleaning:
|
||||
target:
|
||||
entity:
|
||||
integration: neato
|
||||
domain: vacuum
|
||||
fields:
|
||||
mode:
|
||||
default: 2
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 2
|
||||
mode: box
|
||||
navigation:
|
||||
default: 1
|
||||
selector:
|
||||
number:
|
||||
min: 1
|
||||
max: 3
|
||||
mode: box
|
||||
category:
|
||||
default: 4
|
||||
selector:
|
||||
number:
|
||||
min: 2
|
||||
max: 4
|
||||
step: 2
|
||||
mode: box
|
||||
zone:
|
||||
example: "Kitchen"
|
||||
selector:
|
||||
text:
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]",
|
||||
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
|
||||
"no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]",
|
||||
"oauth_error": "[%key:common::config_flow::abort::oauth2_error%]",
|
||||
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
|
||||
"oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]",
|
||||
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"create_entry": {
|
||||
"default": "[%key:common::config_flow::create_entry::authenticated%]"
|
||||
},
|
||||
"step": {
|
||||
"pick_implementation": {
|
||||
"data": {
|
||||
"implementation": "[%key:common::config_flow::data::implementation%]"
|
||||
},
|
||||
"data_description": {
|
||||
"implementation": "[%key:common::config_flow::description::implementation%]"
|
||||
},
|
||||
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::description::confirm_setup%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"dismiss_alert": {
|
||||
"name": "Dismiss alert"
|
||||
}
|
||||
},
|
||||
"camera": {
|
||||
"cleaning_map": {
|
||||
"name": "Cleaning map"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"schedule": {
|
||||
"name": "Schedule"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"custom_cleaning": {
|
||||
"description": "Starts a custom cleaning of your house.",
|
||||
"fields": {
|
||||
"category": {
|
||||
"description": "Whether to use a persistent map or not for cleaning (i.e. No go lines): 2 for no map, 4 for map. Default to using map if not set (and fallback to no map if no map is found).",
|
||||
"name": "Use cleaning map"
|
||||
},
|
||||
"mode": {
|
||||
"description": "Sets the cleaning mode: 1 for eco and 2 for turbo. Defaults to turbo if not set.",
|
||||
"name": "Cleaning mode"
|
||||
},
|
||||
"navigation": {
|
||||
"description": "Sets the navigation mode: 1 for normal, 2 for extra care, 3 for deep. Defaults to normal if not set.",
|
||||
"name": "Navigation mode"
|
||||
},
|
||||
"zone": {
|
||||
"description": "Name of the zone to clean (only supported on the Botvac D7). Defaults to no zone i.e. complete house cleanup.",
|
||||
"name": "Zone"
|
||||
}
|
||||
},
|
||||
"name": "Custom cleaning"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Support for Neato Connected Vacuums switches."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pybotvac.exceptions import NeatoRobotException
|
||||
from pybotvac.robot import Robot
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, EntityCategory
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import NEATO_LOGIN, NEATO_ROBOTS, SCAN_INTERVAL_MINUTES
|
||||
from .entity import NeatoEntity
|
||||
from .hub import NeatoHub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES)
|
||||
|
||||
SWITCH_TYPE_SCHEDULE = "schedule"
|
||||
|
||||
SWITCH_TYPES = {SWITCH_TYPE_SCHEDULE: ["Schedule"]}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Neato switch with config entry."""
|
||||
neato: NeatoHub = hass.data[NEATO_LOGIN]
|
||||
dev = [
|
||||
NeatoConnectedSwitch(neato, robot, type_name)
|
||||
for robot in hass.data[NEATO_ROBOTS]
|
||||
for type_name in SWITCH_TYPES
|
||||
]
|
||||
|
||||
if not dev:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Adding switches %s", dev)
|
||||
async_add_entities(dev, True)
|
||||
|
||||
|
||||
class NeatoConnectedSwitch(NeatoEntity, SwitchEntity):
|
||||
"""Neato Connected Switches."""
|
||||
|
||||
_attr_translation_key = "schedule"
|
||||
_attr_available = False
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
def __init__(self, neato: NeatoHub, robot: Robot, switch_type: str) -> None:
|
||||
"""Initialize the Neato Connected switches."""
|
||||
super().__init__(robot)
|
||||
self.type = switch_type
|
||||
self._state: dict[str, Any] | None = None
|
||||
self._schedule_state: str | None = None
|
||||
self._clean_state = None
|
||||
self._attr_unique_id = self.robot.serial
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update the states of Neato switches."""
|
||||
_LOGGER.debug("Running Neato switch update for '%s'", self.entity_id)
|
||||
try:
|
||||
self._state = self.robot.state
|
||||
except NeatoRobotException as ex:
|
||||
if self._attr_available: # Print only once when available
|
||||
_LOGGER.error(
|
||||
"Neato switch connection error for '%s': %s", self.entity_id, ex
|
||||
)
|
||||
self._state = None
|
||||
self._attr_available = False
|
||||
return
|
||||
|
||||
self._attr_available = True
|
||||
_LOGGER.debug("self._state=%s", self._state)
|
||||
if self.type == SWITCH_TYPE_SCHEDULE:
|
||||
_LOGGER.debug("State: %s", self._state)
|
||||
if self._state is not None and self._state["details"]["isScheduleEnabled"]:
|
||||
self._schedule_state = STATE_ON
|
||||
else:
|
||||
self._schedule_state = STATE_OFF
|
||||
_LOGGER.debug(
|
||||
"Schedule state for '%s': %s", self.entity_id, self._schedule_state
|
||||
)
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if switch is on."""
|
||||
return bool(
|
||||
self.type == SWITCH_TYPE_SCHEDULE and self._schedule_state == STATE_ON
|
||||
)
|
||||
|
||||
def turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch on."""
|
||||
if self.type == SWITCH_TYPE_SCHEDULE:
|
||||
try:
|
||||
self.robot.enable_schedule()
|
||||
except NeatoRobotException as ex:
|
||||
_LOGGER.error(
|
||||
"Neato switch connection error '%s': %s", self.entity_id, ex
|
||||
)
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the switch off."""
|
||||
if self.type == SWITCH_TYPE_SCHEDULE:
|
||||
try:
|
||||
self.robot.disable_schedule()
|
||||
except NeatoRobotException as ex:
|
||||
_LOGGER.error(
|
||||
"Neato switch connection error '%s': %s", self.entity_id, ex
|
||||
)
|
||||
@@ -0,0 +1,388 @@
|
||||
"""Support for Neato Connected Vacuums."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pybotvac import Robot
|
||||
from pybotvac.exceptions import NeatoRobotException
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.vacuum import (
|
||||
ATTR_STATUS,
|
||||
StateVacuumEntity,
|
||||
VacuumActivity,
|
||||
VacuumEntityFeature,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import ATTR_MODE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, entity_platform
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import (
|
||||
ACTION,
|
||||
ALERTS,
|
||||
ERRORS,
|
||||
MODE,
|
||||
NEATO_LOGIN,
|
||||
NEATO_MAP_DATA,
|
||||
NEATO_PERSISTENT_MAPS,
|
||||
NEATO_ROBOTS,
|
||||
SCAN_INTERVAL_MINUTES,
|
||||
)
|
||||
from .entity import NeatoEntity
|
||||
from .hub import NeatoHub
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=SCAN_INTERVAL_MINUTES)
|
||||
|
||||
ATTR_CLEAN_START = "clean_start"
|
||||
ATTR_CLEAN_STOP = "clean_stop"
|
||||
ATTR_CLEAN_AREA = "clean_area"
|
||||
ATTR_CLEAN_BATTERY_START = "battery_level_at_clean_start"
|
||||
ATTR_CLEAN_BATTERY_END = "battery_level_at_clean_end"
|
||||
ATTR_CLEAN_SUSP_COUNT = "clean_suspension_count"
|
||||
ATTR_CLEAN_SUSP_TIME = "clean_suspension_time"
|
||||
ATTR_CLEAN_PAUSE_TIME = "clean_pause_time"
|
||||
ATTR_CLEAN_ERROR_TIME = "clean_error_time"
|
||||
ATTR_LAUNCHED_FROM = "launched_from"
|
||||
|
||||
ATTR_NAVIGATION = "navigation"
|
||||
ATTR_CATEGORY = "category"
|
||||
ATTR_ZONE = "zone"
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Neato vacuum with config entry."""
|
||||
neato: NeatoHub = hass.data[NEATO_LOGIN]
|
||||
mapdata: dict[str, Any] | None = hass.data.get(NEATO_MAP_DATA)
|
||||
persistent_maps: dict[str, Any] | None = hass.data.get(NEATO_PERSISTENT_MAPS)
|
||||
dev = [
|
||||
NeatoConnectedVacuum(neato, robot, mapdata, persistent_maps)
|
||||
for robot in hass.data[NEATO_ROBOTS]
|
||||
]
|
||||
|
||||
if not dev:
|
||||
return
|
||||
|
||||
_LOGGER.debug("Adding vacuums %s", dev)
|
||||
async_add_entities(dev, True)
|
||||
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
assert platform is not None
|
||||
|
||||
platform.async_register_entity_service(
|
||||
"custom_cleaning",
|
||||
{
|
||||
vol.Optional(ATTR_MODE, default=2): cv.positive_int,
|
||||
vol.Optional(ATTR_NAVIGATION, default=1): cv.positive_int,
|
||||
vol.Optional(ATTR_CATEGORY, default=4): cv.positive_int,
|
||||
vol.Optional(ATTR_ZONE): cv.string,
|
||||
},
|
||||
"neato_custom_cleaning",
|
||||
)
|
||||
|
||||
|
||||
class NeatoConnectedVacuum(NeatoEntity, StateVacuumEntity):
|
||||
"""Representation of a Neato Connected Vacuum."""
|
||||
|
||||
_attr_supported_features = (
|
||||
VacuumEntityFeature.BATTERY
|
||||
| VacuumEntityFeature.PAUSE
|
||||
| VacuumEntityFeature.RETURN_HOME
|
||||
| VacuumEntityFeature.STOP
|
||||
| VacuumEntityFeature.START
|
||||
| VacuumEntityFeature.CLEAN_SPOT
|
||||
| VacuumEntityFeature.STATE
|
||||
| VacuumEntityFeature.MAP
|
||||
| VacuumEntityFeature.LOCATE
|
||||
)
|
||||
_attr_name = None
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
neato: NeatoHub,
|
||||
robot: Robot,
|
||||
mapdata: dict[str, Any] | None,
|
||||
persistent_maps: dict[str, Any] | None,
|
||||
) -> None:
|
||||
"""Initialize the Neato Connected Vacuum."""
|
||||
super().__init__(robot)
|
||||
self._attr_available: bool = neato is not None
|
||||
self._mapdata = mapdata
|
||||
self._robot_has_map: bool = self.robot.has_persistent_maps
|
||||
self._robot_maps = persistent_maps
|
||||
self._robot_serial: str = self.robot.serial
|
||||
self._attr_unique_id: str = self.robot.serial
|
||||
self._status_state: str | None = None
|
||||
self._state: dict[str, Any] | None = None
|
||||
self._clean_time_start: str | None = None
|
||||
self._clean_time_stop: str | None = None
|
||||
self._clean_area: float | None = None
|
||||
self._clean_battery_start: int | None = None
|
||||
self._clean_battery_end: int | None = None
|
||||
self._clean_susp_charge_count: int | None = None
|
||||
self._clean_susp_time: int | None = None
|
||||
self._clean_pause_time: int | None = None
|
||||
self._clean_error_time: int | None = None
|
||||
self._launched_from: str | None = None
|
||||
self._robot_boundaries: list = []
|
||||
self._robot_stats: dict[str, Any] | None = None
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update the states of Neato Vacuums."""
|
||||
_LOGGER.debug("Running Neato Vacuums update for '%s'", self.entity_id)
|
||||
try:
|
||||
if self._robot_stats is None:
|
||||
self._robot_stats = self.robot.get_general_info().json().get("data")
|
||||
except NeatoRobotException:
|
||||
_LOGGER.warning("Couldn't fetch robot information of %s", self.entity_id)
|
||||
|
||||
try:
|
||||
self._state = self.robot.state
|
||||
except NeatoRobotException as ex:
|
||||
if self._attr_available: # print only once when available
|
||||
_LOGGER.error(
|
||||
"Neato vacuum connection error for '%s': %s", self.entity_id, ex
|
||||
)
|
||||
self._state = None
|
||||
self._attr_available = False
|
||||
return
|
||||
|
||||
if self._state is None:
|
||||
return
|
||||
self._attr_available = True
|
||||
_LOGGER.debug("self._state=%s", self._state)
|
||||
if "alert" in self._state:
|
||||
robot_alert = ALERTS.get(self._state["alert"])
|
||||
else:
|
||||
robot_alert = None
|
||||
if self._state["state"] == 1:
|
||||
if self._state["details"]["isCharging"]:
|
||||
self._attr_activity = VacuumActivity.DOCKED
|
||||
self._status_state = "Charging"
|
||||
elif (
|
||||
self._state["details"]["isDocked"]
|
||||
and not self._state["details"]["isCharging"]
|
||||
):
|
||||
self._attr_activity = VacuumActivity.DOCKED
|
||||
self._status_state = "Docked"
|
||||
else:
|
||||
self._attr_activity = VacuumActivity.IDLE
|
||||
self._status_state = "Stopped"
|
||||
|
||||
if robot_alert is not None:
|
||||
self._status_state = robot_alert
|
||||
elif self._state["state"] == 2:
|
||||
if robot_alert is None:
|
||||
self._attr_activity = VacuumActivity.CLEANING
|
||||
self._status_state = (
|
||||
f"{MODE.get(self._state['cleaning']['mode'])} "
|
||||
f"{ACTION.get(self._state['action'])}"
|
||||
)
|
||||
if (
|
||||
"boundary" in self._state["cleaning"]
|
||||
and "name" in self._state["cleaning"]["boundary"]
|
||||
):
|
||||
self._status_state += (
|
||||
f" {self._state['cleaning']['boundary']['name']}"
|
||||
)
|
||||
else:
|
||||
self._status_state = robot_alert
|
||||
elif self._state["state"] == 3:
|
||||
self._attr_activity = VacuumActivity.PAUSED
|
||||
self._status_state = "Paused"
|
||||
elif self._state["state"] == 4:
|
||||
self._attr_activity = VacuumActivity.ERROR
|
||||
self._status_state = ERRORS.get(self._state["error"])
|
||||
|
||||
self._attr_battery_level = self._state["details"]["charge"]
|
||||
|
||||
if self._mapdata is None or not self._mapdata.get(self._robot_serial, {}).get(
|
||||
"maps", []
|
||||
):
|
||||
return
|
||||
|
||||
mapdata: dict[str, Any] = self._mapdata[self._robot_serial]["maps"][0]
|
||||
self._clean_time_start = mapdata["start_at"]
|
||||
self._clean_time_stop = mapdata["end_at"]
|
||||
self._clean_area = mapdata["cleaned_area"]
|
||||
self._clean_susp_charge_count = mapdata["suspended_cleaning_charging_count"]
|
||||
self._clean_susp_time = mapdata["time_in_suspended_cleaning"]
|
||||
self._clean_pause_time = mapdata["time_in_pause"]
|
||||
self._clean_error_time = mapdata["time_in_error"]
|
||||
self._clean_battery_start = mapdata["run_charge_at_start"]
|
||||
self._clean_battery_end = mapdata["run_charge_at_end"]
|
||||
self._launched_from = mapdata["launched_from"]
|
||||
|
||||
if (
|
||||
self._robot_has_map
|
||||
and self._state
|
||||
and self._state["availableServices"]["maps"] != "basic-1"
|
||||
and self._robot_maps
|
||||
):
|
||||
allmaps: dict = self._robot_maps[self._robot_serial]
|
||||
_LOGGER.debug(
|
||||
"Found the following maps for '%s': %s", self.entity_id, allmaps
|
||||
)
|
||||
self._robot_boundaries = [] # Reset boundaries before refreshing boundaries
|
||||
for maps in allmaps:
|
||||
try:
|
||||
robot_boundaries = self.robot.get_map_boundaries(maps["id"]).json()
|
||||
except NeatoRobotException as ex:
|
||||
_LOGGER.error(
|
||||
"Could not fetch map boundaries for '%s': %s",
|
||||
self.entity_id,
|
||||
ex,
|
||||
)
|
||||
return
|
||||
|
||||
_LOGGER.debug(
|
||||
"Boundaries for robot '%s' in map '%s': %s",
|
||||
self.entity_id,
|
||||
maps["name"],
|
||||
robot_boundaries,
|
||||
)
|
||||
if "boundaries" in robot_boundaries["data"]:
|
||||
self._robot_boundaries += robot_boundaries["data"]["boundaries"]
|
||||
_LOGGER.debug(
|
||||
"List of boundaries for '%s': %s",
|
||||
self.entity_id,
|
||||
self._robot_boundaries,
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return the state attributes of the vacuum cleaner."""
|
||||
data: dict[str, Any] = {}
|
||||
|
||||
if self._status_state is not None:
|
||||
data[ATTR_STATUS] = self._status_state
|
||||
if self._clean_time_start is not None:
|
||||
data[ATTR_CLEAN_START] = self._clean_time_start
|
||||
if self._clean_time_stop is not None:
|
||||
data[ATTR_CLEAN_STOP] = self._clean_time_stop
|
||||
if self._clean_area is not None:
|
||||
data[ATTR_CLEAN_AREA] = self._clean_area
|
||||
if self._clean_susp_charge_count is not None:
|
||||
data[ATTR_CLEAN_SUSP_COUNT] = self._clean_susp_charge_count
|
||||
if self._clean_susp_time is not None:
|
||||
data[ATTR_CLEAN_SUSP_TIME] = self._clean_susp_time
|
||||
if self._clean_pause_time is not None:
|
||||
data[ATTR_CLEAN_PAUSE_TIME] = self._clean_pause_time
|
||||
if self._clean_error_time is not None:
|
||||
data[ATTR_CLEAN_ERROR_TIME] = self._clean_error_time
|
||||
if self._clean_battery_start is not None:
|
||||
data[ATTR_CLEAN_BATTERY_START] = self._clean_battery_start
|
||||
if self._clean_battery_end is not None:
|
||||
data[ATTR_CLEAN_BATTERY_END] = self._clean_battery_end
|
||||
if self._launched_from is not None:
|
||||
data[ATTR_LAUNCHED_FROM] = self._launched_from
|
||||
|
||||
return data
|
||||
|
||||
@property
|
||||
def device_info(self) -> DeviceInfo:
|
||||
"""Device info for neato robot."""
|
||||
device_info = self._attr_device_info
|
||||
if self._robot_stats:
|
||||
device_info["manufacturer"] = self._robot_stats["battery"]["vendor"]
|
||||
device_info["model"] = self._robot_stats["model"]
|
||||
device_info["sw_version"] = self._robot_stats["firmware"]
|
||||
return device_info
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start cleaning or resume cleaning."""
|
||||
if self._state:
|
||||
try:
|
||||
if self._state["state"] == 1:
|
||||
self.robot.start_cleaning()
|
||||
elif self._state["state"] == 3:
|
||||
self.robot.resume_cleaning()
|
||||
except NeatoRobotException as ex:
|
||||
_LOGGER.error(
|
||||
"Neato vacuum connection error for '%s': %s", self.entity_id, ex
|
||||
)
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause the vacuum."""
|
||||
try:
|
||||
self.robot.pause_cleaning()
|
||||
except NeatoRobotException as ex:
|
||||
_LOGGER.error(
|
||||
"Neato vacuum connection error for '%s': %s", self.entity_id, ex
|
||||
)
|
||||
|
||||
def return_to_base(self, **kwargs: Any) -> None:
|
||||
"""Set the vacuum cleaner to return to the dock."""
|
||||
try:
|
||||
if self._attr_activity == VacuumActivity.CLEANING:
|
||||
self.robot.pause_cleaning()
|
||||
self._attr_activity = VacuumActivity.RETURNING
|
||||
self.robot.send_to_base()
|
||||
except NeatoRobotException as ex:
|
||||
_LOGGER.error(
|
||||
"Neato vacuum connection error for '%s': %s", self.entity_id, ex
|
||||
)
|
||||
|
||||
def stop(self, **kwargs: Any) -> None:
|
||||
"""Stop the vacuum cleaner."""
|
||||
try:
|
||||
self.robot.stop_cleaning()
|
||||
except NeatoRobotException as ex:
|
||||
_LOGGER.error(
|
||||
"Neato vacuum connection error for '%s': %s", self.entity_id, ex
|
||||
)
|
||||
|
||||
def locate(self, **kwargs: Any) -> None:
|
||||
"""Locate the robot by making it emit a sound."""
|
||||
try:
|
||||
self.robot.locate()
|
||||
except NeatoRobotException as ex:
|
||||
_LOGGER.error(
|
||||
"Neato vacuum connection error for '%s': %s", self.entity_id, ex
|
||||
)
|
||||
|
||||
def clean_spot(self, **kwargs: Any) -> None:
|
||||
"""Run a spot cleaning starting from the base."""
|
||||
try:
|
||||
self.robot.start_spot_cleaning()
|
||||
except NeatoRobotException as ex:
|
||||
_LOGGER.error(
|
||||
"Neato vacuum connection error for '%s': %s", self.entity_id, ex
|
||||
)
|
||||
|
||||
def neato_custom_cleaning(
|
||||
self, mode: str, navigation: str, category: str, zone: str | None = None
|
||||
) -> None:
|
||||
"""Zone cleaning service call."""
|
||||
boundary_id = None
|
||||
if zone is not None:
|
||||
for boundary in self._robot_boundaries:
|
||||
if zone in boundary["name"]:
|
||||
boundary_id = boundary["id"]
|
||||
if boundary_id is None:
|
||||
_LOGGER.error(
|
||||
"Zone '%s' was not found for the robot '%s'", zone, self.entity_id
|
||||
)
|
||||
return
|
||||
_LOGGER.debug(
|
||||
"Start cleaning zone '%s' with robot %s", zone, self.entity_id
|
||||
)
|
||||
|
||||
self._attr_activity = VacuumActivity.CLEANING
|
||||
try:
|
||||
self.robot.start_cleaning(mode, navigation, category, boundary_id)
|
||||
except NeatoRobotException as ex:
|
||||
_LOGGER.error(
|
||||
"Neato vacuum connection error for '%s': %s", self.entity_id, ex
|
||||
)
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynintendoparental"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pynintendoparental==1.1.2"]
|
||||
"requirements": ["pynintendoparental==1.1.3"]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ from onedrive_personal_sdk.exceptions import (
|
||||
NotFoundError,
|
||||
OneDriveException,
|
||||
)
|
||||
from onedrive_personal_sdk.models.items import Item, ItemUpdate
|
||||
from onedrive_personal_sdk.models.items import ItemUpdate
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -202,9 +202,7 @@ async def _get_onedrive_client(
|
||||
)
|
||||
|
||||
|
||||
async def _handle_item_operation(
|
||||
func: Callable[[], Awaitable[Item]], folder: str
|
||||
) -> Item:
|
||||
async def _handle_item_operation[T](func: Callable[[], Awaitable[T]], folder: str) -> T:
|
||||
try:
|
||||
return await func()
|
||||
except NotFoundError:
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["onedrive_personal_sdk"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["onedrive-personal-sdk==0.0.14"]
|
||||
"requirements": ["onedrive-personal-sdk==0.0.15"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["openai==2.2.0", "python-open-router==0.3.1"]
|
||||
"requirements": ["openai==2.2.0", "python-open-router==0.3.2"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.15.8"]
|
||||
"requirements": ["opower==0.15.9"]
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle the local authentication step via config flow."""
|
||||
errors = {}
|
||||
description_placeholders = {
|
||||
"somfy-developer-mode-docs": "https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started"
|
||||
"somfy_developer_mode_docs": "https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started"
|
||||
}
|
||||
|
||||
if user_input:
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"token": "Token generated by the app used to control your device.",
|
||||
"verify_ssl": "Verify the SSL certificate. Select this only if you are connecting via the hostname."
|
||||
},
|
||||
"description": "By activating the [Developer Mode of your TaHoma box]({somfy-developer-mode-docs}), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\n1. Open the TaHoma By Somfy application on your device.\n2. Navigate to the Help & advanced features -> Advanced features menu in the application.\n3. Activate Developer Mode by tapping 7 times on the version number of your gateway (like 2025.1.4-11).\n4. Generate a token from the Developer Mode menu to authenticate your API calls.\n\n5. Enter the generated token below and update the host to include your Gateway PIN or the IP address of your gateway."
|
||||
"description": "By activating the [Developer Mode of your TaHoma box]({somfy_developer_mode_docs}), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\n1. Open the TaHoma By Somfy application on your device.\n2. Navigate to the Help & advanced features -> Advanced features menu in the application.\n3. Activate Developer Mode by tapping 7 times on the version number of your gateway (like 2025.1.4-11).\n4. Generate a token from the Developer Mode menu to authenticate your API calls.\n\n5. Enter the generated token below and update the host to include your Gateway PIN or the IP address of your gateway."
|
||||
},
|
||||
"local_or_cloud": {
|
||||
"data": {
|
||||
|
||||
@@ -38,9 +38,7 @@ async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
|
||||
client = Portainer(
|
||||
api_url=data[CONF_URL],
|
||||
api_key=data[CONF_API_TOKEN],
|
||||
session=async_get_clientsession(
|
||||
hass=hass, verify_ssl=data.get(CONF_VERIFY_SSL, True)
|
||||
),
|
||||
session=async_get_clientsession(hass=hass, verify_ssl=data[CONF_VERIFY_SSL]),
|
||||
)
|
||||
try:
|
||||
await client.get_endpoints()
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyportainer==1.0.11"]
|
||||
"requirements": ["pyportainer==1.0.12"]
|
||||
}
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.16.2"]
|
||||
"requirements": ["reolink-aio==0.16.3"]
|
||||
}
|
||||
|
||||
@@ -835,6 +835,7 @@
|
||||
"vehicle_type": {
|
||||
"name": "Vehicle type",
|
||||
"state": {
|
||||
"bus": "Bus",
|
||||
"motorcycle": "Motorcycle",
|
||||
"pickup_truck": "Pickup truck",
|
||||
"sedan": "Sedan",
|
||||
|
||||
@@ -417,7 +417,7 @@ def get_rpc_sub_device_name(
|
||||
"""Get name based on device and channel name."""
|
||||
if key in device.config and key != "em:0":
|
||||
# workaround for Pro 3EM, we don't want to get name for em:0
|
||||
if (zone_id := get_irrigation_zone_id(device.config, key)) is not None:
|
||||
if (zone_id := get_irrigation_zone_id(device, key)) is not None:
|
||||
# workaround for Irrigation controller, name stored in "service:0"
|
||||
if zone_name := device.config["service:0"]["zones"][zone_id]["name"]:
|
||||
return cast(str, zone_name)
|
||||
@@ -792,9 +792,13 @@ async def get_rpc_scripts_event_types(
|
||||
return script_events
|
||||
|
||||
|
||||
def get_irrigation_zone_id(config: dict[str, Any], key: str) -> int | None:
|
||||
def get_irrigation_zone_id(device: RpcDevice, key: str) -> int | None:
|
||||
"""Return the zone id if the component is an irrigation zone."""
|
||||
if key in config and (zone := get_rpc_role_by_key(config, key)).startswith("zone"):
|
||||
if (
|
||||
device.initialized
|
||||
and key in device.config
|
||||
and (zone := get_rpc_role_by_key(device.config, key)).startswith("zone")
|
||||
):
|
||||
return int(zone[4:])
|
||||
return None
|
||||
|
||||
@@ -837,7 +841,7 @@ def get_rpc_device_info(
|
||||
if (
|
||||
(
|
||||
component not in (*All_LIGHT_TYPES, "cover", "em1", "switch")
|
||||
and get_irrigation_zone_id(device.config, key) is None
|
||||
and get_irrigation_zone_id(device, key) is None
|
||||
)
|
||||
or idx is None
|
||||
or len(get_rpc_key_instances(device.status, component, all_lights=True)) < 2
|
||||
|
||||
@@ -24,7 +24,8 @@ from homeassistant.components.telegram_bot import (
|
||||
)
|
||||
from homeassistant.const import ATTR_LOCATION
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.reload import setup_reload_service
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.reload import async_setup_reload_service
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN, PLATFORMS
|
||||
@@ -45,14 +46,25 @@ PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def get_service(
|
||||
async def async_get_service(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> TelegramNotificationService:
|
||||
"""Get the Telegram notification service."""
|
||||
|
||||
setup_reload_service(hass, DOMAIN, PLATFORMS)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"migrate_notify",
|
||||
breaks_in_ha_version="2026.5.0",
|
||||
is_fixable=False,
|
||||
translation_key="migrate_notify",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/telegram_bot#notifiers",
|
||||
)
|
||||
|
||||
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
|
||||
chat_id = config.get(CONF_CHAT_ID)
|
||||
return TelegramNotificationService(hass, chat_id)
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"issues": {
|
||||
"migrate_notify": {
|
||||
"description": "The Telegram `notify` service has been migrated. A new `notify` entity per chat ID is available now.\n\nUpdate all affected automations to use the new `notify.send_message` action exposed by these new entities and then restart Home Assistant.",
|
||||
"title": "Migration of Telegram notify service"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload": {
|
||||
"description": "Reloads telegram notify services.",
|
||||
|
||||
@@ -108,8 +108,8 @@ from .const import (
|
||||
SERVICE_SEND_STICKER,
|
||||
SERVICE_SEND_VIDEO,
|
||||
SERVICE_SEND_VOICE,
|
||||
SIGNAL_UPDATE_EVENT,
|
||||
)
|
||||
from .helpers import signal
|
||||
|
||||
_FILE_TYPES = ("animation", "document", "photo", "sticker", "video", "voice")
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -169,7 +169,7 @@ class BaseTelegramBot:
|
||||
|
||||
_LOGGER.debug("Firing event %s: %s", event_type, event_data)
|
||||
self.hass.bus.async_fire(event_type, event_data, context=event_context)
|
||||
async_dispatcher_send(self.hass, SIGNAL_UPDATE_EVENT, event_type, event_data)
|
||||
async_dispatcher_send(self.hass, signal(self._bot), event_type, event_data)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@@ -551,7 +551,7 @@ class TelegramNotificationService:
|
||||
EVENT_TELEGRAM_SENT, event_data, context=context
|
||||
)
|
||||
async_dispatcher_send(
|
||||
self.hass, SIGNAL_UPDATE_EVENT, EVENT_TELEGRAM_SENT, event_data
|
||||
self.hass, signal(self.bot), EVENT_TELEGRAM_SENT, event_data
|
||||
)
|
||||
except TelegramError as exc:
|
||||
if not suppress_error:
|
||||
|
||||
@@ -14,9 +14,9 @@ from .const import (
|
||||
EVENT_TELEGRAM_COMMAND,
|
||||
EVENT_TELEGRAM_SENT,
|
||||
EVENT_TELEGRAM_TEXT,
|
||||
SIGNAL_UPDATE_EVENT,
|
||||
)
|
||||
from .entity import TelegramBotEntity
|
||||
from .helpers import signal
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -55,7 +55,7 @@ class TelegramBotEventEntity(TelegramBotEntity, EventEntity):
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
SIGNAL_UPDATE_EVENT,
|
||||
signal(self.config_entry.runtime_data.bot),
|
||||
self._async_handle_event,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Helper functions for Telegram bot integration."""
|
||||
|
||||
from telegram import Bot
|
||||
|
||||
from .const import SIGNAL_UPDATE_EVENT
|
||||
|
||||
|
||||
def signal(bot: Bot) -> str:
|
||||
"""Define signal name."""
|
||||
return f"{SIGNAL_UPDATE_EVENT}_{bot.id}"
|
||||
@@ -267,8 +267,12 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
|
||||
await self.async_set_preset_mode(preset_mode)
|
||||
return
|
||||
if percentage is None:
|
||||
percentage = 50
|
||||
await self.async_set_percentage(percentage)
|
||||
success = await self.device.turn_on()
|
||||
if not success:
|
||||
raise HomeAssistantError(self.device.last_response.message)
|
||||
self.schedule_update_ha_state()
|
||||
else:
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/vesync",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyvesync"],
|
||||
"requirements": ["pyvesync==3.1.2"]
|
||||
"requirements": ["pyvesync==3.1.4"]
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ NUMBER_DESCRIPTIONS: list[VeSyncNumberEntityDescription] = [
|
||||
mode=NumberMode.SLIDER,
|
||||
exists_fn=is_humidifier,
|
||||
set_value_fn=lambda device, value: device.set_mist_level(value),
|
||||
value_fn=lambda device: device.state.mist_level,
|
||||
value_fn=lambda device: device.state.mist_virtual_level,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -283,6 +283,14 @@
|
||||
"default": "mdi:alarm-light"
|
||||
}
|
||||
},
|
||||
"device_tracker": {
|
||||
"location": {
|
||||
"default": "mdi:car",
|
||||
"state": {
|
||||
"not_home": "mdi:car-arrow-right"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"availability": {
|
||||
"default": "mdi:car-connected"
|
||||
|
||||
@@ -210,6 +210,11 @@
|
||||
"name": "Honk & flash"
|
||||
}
|
||||
},
|
||||
"device_tracker": {
|
||||
"location": {
|
||||
"name": "Location"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"availability": {
|
||||
"name": "Car connection",
|
||||
|
||||
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2025
|
||||
MINOR_VERSION: Final = 11
|
||||
PATCH_VERSION: Final = "0.dev0"
|
||||
PATCH_VERSION: Final = "0b2"
|
||||
__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)
|
||||
|
||||
Generated
+10
@@ -23,9 +23,12 @@ SUPPORTED_REGIONS: Final[set[str]] = {
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-5",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"eu-north-1",
|
||||
"eu-south-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
@@ -40,6 +43,7 @@ SUPPORTED_REGIONS: Final[set[str]] = {
|
||||
SUPPORTED_VOICES: Final[set[str]] = {
|
||||
"Aditi",
|
||||
"Adriano",
|
||||
"Alba",
|
||||
"Amy",
|
||||
"Andres",
|
||||
"Aria",
|
||||
@@ -80,6 +84,9 @@ SUPPORTED_VOICES: Final[set[str]] = {
|
||||
"Ivy",
|
||||
"Jacek",
|
||||
"Jan",
|
||||
"Jasmine",
|
||||
"Jihye",
|
||||
"Jitka",
|
||||
"Joanna",
|
||||
"Joey",
|
||||
"Justin",
|
||||
@@ -111,14 +118,17 @@ SUPPORTED_VOICES: Final[set[str]] = {
|
||||
"Nicole",
|
||||
"Ola",
|
||||
"Olivia",
|
||||
"Patrick",
|
||||
"Pedro",
|
||||
"Penelope",
|
||||
"Raul",
|
||||
"Raveena",
|
||||
"Remi",
|
||||
"Ricardo",
|
||||
"Ruben",
|
||||
"Russell",
|
||||
"Ruth",
|
||||
"Sabrina",
|
||||
"Salli",
|
||||
"Seoyeon",
|
||||
"Sergio",
|
||||
|
||||
@@ -27,6 +27,7 @@ APPLICATION_CREDENTIALS = [
|
||||
"miele",
|
||||
"monzo",
|
||||
"myuplink",
|
||||
"neato",
|
||||
"nest",
|
||||
"netatmo",
|
||||
"ondilo_ico",
|
||||
|
||||
Generated
+1
@@ -428,6 +428,7 @@ FLOWS = {
|
||||
"nam",
|
||||
"nanoleaf",
|
||||
"nasweb",
|
||||
"neato",
|
||||
"nederlandse_spoorwegen",
|
||||
"nest",
|
||||
"netatmo",
|
||||
|
||||
@@ -4339,6 +4339,12 @@
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "opower"
|
||||
},
|
||||
"neato": {
|
||||
"name": "Neato Botvac",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"nederlandse_spoorwegen": {
|
||||
"name": "Nederlandse Spoorwegen (NS)",
|
||||
"integration_type": "service",
|
||||
|
||||
@@ -39,7 +39,7 @@ habluetooth==5.7.0
|
||||
hass-nabucasa==1.5.1
|
||||
hassil==3.4.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20251029.0
|
||||
home-assistant-frontend==20251029.1
|
||||
home-assistant-intents==2025.10.28
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
@@ -69,7 +69,7 @@ standard-telnetlib==3.13.0
|
||||
typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==1.5.2
|
||||
urllib3>=2.0
|
||||
uv==0.9.5
|
||||
uv==0.9.6
|
||||
voluptuous-openapi==0.1.0
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous==0.15.2
|
||||
|
||||
@@ -3366,6 +3366,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.neato.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.nest.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
+2
-2
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2025.11.0.dev0"
|
||||
version = "2025.11.0b2"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
@@ -75,7 +75,7 @@ dependencies = [
|
||||
"typing-extensions>=4.15.0,<5.0",
|
||||
"ulid-transform==1.5.2",
|
||||
"urllib3>=2.0",
|
||||
"uv==0.9.5",
|
||||
"uv==0.9.6",
|
||||
"voluptuous==0.15.2",
|
||||
"voluptuous-serialize==2.7.0",
|
||||
"voluptuous-openapi==0.1.0",
|
||||
|
||||
Generated
+1
-1
@@ -46,7 +46,7 @@ standard-telnetlib==3.13.0
|
||||
typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==1.5.2
|
||||
urllib3>=2.0
|
||||
uv==0.9.5
|
||||
uv==0.9.6
|
||||
voluptuous==0.15.2
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous-openapi==0.1.0
|
||||
|
||||
Generated
+16
-13
@@ -791,7 +791,7 @@ decora-wifi==1.4
|
||||
# decora==0.6
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==16.1.0
|
||||
deebot-client==16.3.0
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@@ -859,7 +859,7 @@ ebusdpy==0.0.17
|
||||
ecoaliface==0.4.0
|
||||
|
||||
# homeassistant.components.eheimdigital
|
||||
eheimdigital==1.3.0
|
||||
eheimdigital==1.4.0
|
||||
|
||||
# homeassistant.components.ekeybionyx
|
||||
ekey-bionyxpy==1.0.0
|
||||
@@ -1201,7 +1201,7 @@ hole==0.9.0
|
||||
holidays==0.83
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20251029.0
|
||||
home-assistant-frontend==20251029.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.10.28
|
||||
@@ -1334,7 +1334,7 @@ kiwiki-client==0.1.1
|
||||
knocki==0.4.2
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2025.10.26.81530
|
||||
knx-frontend==2025.10.31.195356
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
@@ -1376,7 +1376,7 @@ libpyfoscamcgi==0.0.8
|
||||
libpyvivotek==0.6.1
|
||||
|
||||
# homeassistant.components.libre_hardware_monitor
|
||||
librehardwaremonitor-api==1.4.0
|
||||
librehardwaremonitor-api==1.5.0
|
||||
|
||||
# homeassistant.components.mikrotik
|
||||
librouteros==3.2.0
|
||||
@@ -1630,7 +1630,7 @@ omnilogic==0.4.5
|
||||
ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
onedrive-personal-sdk==0.0.14
|
||||
onedrive-personal-sdk==0.0.15
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==4.0.4
|
||||
@@ -1670,7 +1670,7 @@ openwrt-luci-rpc==1.1.17
|
||||
openwrt-ubus-rpc==0.0.2
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.15.8
|
||||
opower==0.15.9
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==0.17.6
|
||||
@@ -1911,6 +1911,9 @@ pyblackbird==0.6
|
||||
# homeassistant.components.bluesound
|
||||
pyblu==2.0.5
|
||||
|
||||
# homeassistant.components.neato
|
||||
pybotvac==0.0.28
|
||||
|
||||
# homeassistant.components.braviatv
|
||||
pybravia==0.3.4
|
||||
|
||||
@@ -1948,7 +1951,7 @@ pycsspeechtts==1.0.8
|
||||
# pycups==2.0.4
|
||||
|
||||
# homeassistant.components.cync
|
||||
pycync==0.4.2
|
||||
pycync==0.4.3
|
||||
|
||||
# homeassistant.components.daikin
|
||||
pydaikin==2.17.1
|
||||
@@ -2225,7 +2228,7 @@ pynetio==0.1.9.1
|
||||
pynina==0.3.6
|
||||
|
||||
# homeassistant.components.nintendo_parental_controls
|
||||
pynintendoparental==1.1.2
|
||||
pynintendoparental==1.1.3
|
||||
|
||||
# homeassistant.components.nobo_hub
|
||||
pynobo==1.8.1
|
||||
@@ -2305,7 +2308,7 @@ pyplaato==0.0.19
|
||||
pypoint==3.0.0
|
||||
|
||||
# homeassistant.components.portainer
|
||||
pyportainer==1.0.11
|
||||
pyportainer==1.0.12
|
||||
|
||||
# homeassistant.components.probe_plus
|
||||
pyprobeplus==1.1.2
|
||||
@@ -2534,7 +2537,7 @@ python-mpd2==3.1.1
|
||||
python-mystrom==2.5.0
|
||||
|
||||
# homeassistant.components.open_router
|
||||
python-open-router==0.3.1
|
||||
python-open-router==0.3.2
|
||||
|
||||
# homeassistant.components.swiss_public_transport
|
||||
python-opendata-transport==0.5.0
|
||||
@@ -2632,7 +2635,7 @@ pyvera==0.3.16
|
||||
pyversasense==0.0.6
|
||||
|
||||
# homeassistant.components.vesync
|
||||
pyvesync==3.1.2
|
||||
pyvesync==3.1.4
|
||||
|
||||
# homeassistant.components.vizio
|
||||
pyvizio==0.1.61
|
||||
@@ -2722,7 +2725,7 @@ renault-api==0.5.0
|
||||
renson-endura-delta==1.7.2
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.16.2
|
||||
reolink-aio==0.16.3
|
||||
|
||||
# homeassistant.components.idteck_prox
|
||||
rfk101py==0.0.1
|
||||
|
||||
Generated
+16
-13
@@ -691,7 +691,7 @@ debugpy==1.8.16
|
||||
# decora==0.6
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==16.1.0
|
||||
deebot-client==16.3.0
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@@ -750,7 +750,7 @@ eagle100==0.1.1
|
||||
easyenergy==2.1.2
|
||||
|
||||
# homeassistant.components.eheimdigital
|
||||
eheimdigital==1.3.0
|
||||
eheimdigital==1.4.0
|
||||
|
||||
# homeassistant.components.ekeybionyx
|
||||
ekey-bionyxpy==1.0.0
|
||||
@@ -1050,7 +1050,7 @@ hole==0.9.0
|
||||
holidays==0.83
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20251029.0
|
||||
home-assistant-frontend==20251029.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.10.28
|
||||
@@ -1159,7 +1159,7 @@ kegtron-ble==1.0.2
|
||||
knocki==0.4.2
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2025.10.26.81530
|
||||
knx-frontend==2025.10.31.195356
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
@@ -1195,7 +1195,7 @@ letpot==0.6.3
|
||||
libpyfoscamcgi==0.0.8
|
||||
|
||||
# homeassistant.components.libre_hardware_monitor
|
||||
librehardwaremonitor-api==1.4.0
|
||||
librehardwaremonitor-api==1.5.0
|
||||
|
||||
# homeassistant.components.mikrotik
|
||||
librouteros==3.2.0
|
||||
@@ -1401,7 +1401,7 @@ omnilogic==0.4.5
|
||||
ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
onedrive-personal-sdk==0.0.14
|
||||
onedrive-personal-sdk==0.0.15
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==4.0.4
|
||||
@@ -1429,7 +1429,7 @@ openrgb-python==0.3.6
|
||||
openwebifpy==4.3.1
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.15.8
|
||||
opower==0.15.9
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==0.17.6
|
||||
@@ -1616,6 +1616,9 @@ pyblackbird==0.6
|
||||
# homeassistant.components.bluesound
|
||||
pyblu==2.0.5
|
||||
|
||||
# homeassistant.components.neato
|
||||
pybotvac==0.0.28
|
||||
|
||||
# homeassistant.components.braviatv
|
||||
pybravia==0.3.4
|
||||
|
||||
@@ -1641,7 +1644,7 @@ pycsspeechtts==1.0.8
|
||||
# pycups==2.0.4
|
||||
|
||||
# homeassistant.components.cync
|
||||
pycync==0.4.2
|
||||
pycync==0.4.3
|
||||
|
||||
# homeassistant.components.daikin
|
||||
pydaikin==2.17.1
|
||||
@@ -1861,7 +1864,7 @@ pynetgear==0.10.10
|
||||
pynina==0.3.6
|
||||
|
||||
# homeassistant.components.nintendo_parental_controls
|
||||
pynintendoparental==1.1.2
|
||||
pynintendoparental==1.1.3
|
||||
|
||||
# homeassistant.components.nobo_hub
|
||||
pynobo==1.8.1
|
||||
@@ -1932,7 +1935,7 @@ pyplaato==0.0.19
|
||||
pypoint==3.0.0
|
||||
|
||||
# homeassistant.components.portainer
|
||||
pyportainer==1.0.11
|
||||
pyportainer==1.0.12
|
||||
|
||||
# homeassistant.components.probe_plus
|
||||
pyprobeplus==1.1.2
|
||||
@@ -2107,7 +2110,7 @@ python-mpd2==3.1.1
|
||||
python-mystrom==2.5.0
|
||||
|
||||
# homeassistant.components.open_router
|
||||
python-open-router==0.3.1
|
||||
python-open-router==0.3.2
|
||||
|
||||
# homeassistant.components.swiss_public_transport
|
||||
python-opendata-transport==0.5.0
|
||||
@@ -2190,7 +2193,7 @@ pyuptimerobot==22.2.0
|
||||
pyvera==0.3.16
|
||||
|
||||
# homeassistant.components.vesync
|
||||
pyvesync==3.1.2
|
||||
pyvesync==3.1.4
|
||||
|
||||
# homeassistant.components.vizio
|
||||
pyvizio==0.1.61
|
||||
@@ -2268,7 +2271,7 @@ renault-api==0.5.0
|
||||
renson-endura-delta==1.7.2
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.16.2
|
||||
reolink-aio==0.16.3
|
||||
|
||||
# homeassistant.components.rflink
|
||||
rflink==0.0.67
|
||||
|
||||
Generated
+1
-1
@@ -14,7 +14,7 @@ WORKDIR "/github/workspace"
|
||||
COPY . /usr/src/homeassistant
|
||||
|
||||
# Uv is only needed during build
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.5,source=/uv,target=/bin/uv \
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.6,source=/uv,target=/bin/uv \
|
||||
# Uv creates a lock file in /tmp
|
||||
--mount=type=tmpfs,target=/tmp \
|
||||
# Required for PyTurboJPEG
|
||||
|
||||
@@ -673,6 +673,7 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
||||
"namecheapdns",
|
||||
"nanoleaf",
|
||||
"nasweb",
|
||||
"neato",
|
||||
"nederlandse_spoorwegen",
|
||||
"ness_alarm",
|
||||
"netatmo",
|
||||
@@ -1705,6 +1706,7 @@ INTEGRATIONS_WITHOUT_SCALE = [
|
||||
"namecheapdns",
|
||||
"nanoleaf",
|
||||
"nasweb",
|
||||
"neato",
|
||||
"nederlandse_spoorwegen",
|
||||
"nest",
|
||||
"ness_alarm",
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from functools import partial
|
||||
import json
|
||||
import re
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -131,10 +132,12 @@ def translation_value_validator(value: Any) -> str:
|
||||
|
||||
- prevents string with HTML
|
||||
- prevents strings with single quoted placeholders
|
||||
- prevents strings with placeholders using invalid identifiers
|
||||
- prevents combined translations
|
||||
"""
|
||||
string_value = cv.string_with_no_html(value)
|
||||
string_value = string_no_single_quoted_placeholders(string_value)
|
||||
string_value = validate_placeholders(string_value)
|
||||
if RE_COMBINED_REFERENCE.search(string_value):
|
||||
raise vol.Invalid("the string should not contain combined translations")
|
||||
if string_value != string_value.strip():
|
||||
@@ -151,6 +154,19 @@ def string_no_single_quoted_placeholders(value: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def validate_placeholders(value: str) -> str:
|
||||
"""Validate that placeholders in translations use valid identifiers."""
|
||||
formatter = string.Formatter()
|
||||
|
||||
for _, field_name, _, _ in formatter.parse(value):
|
||||
if field_name: # skip literal text segments
|
||||
if not field_name.isidentifier():
|
||||
raise vol.Invalid(
|
||||
"placeholders must be valid identifiers ([a-zA-Z_][a-zA-Z0-9_]*)"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def gen_data_entry_schema(
|
||||
*,
|
||||
config: Config,
|
||||
|
||||
@@ -577,6 +577,7 @@
|
||||
"ourgroceries": 469,
|
||||
"vicare": 1495,
|
||||
"thermopro": 639,
|
||||
"neato": 935,
|
||||
"roon": 405,
|
||||
"renault": 1287,
|
||||
"bthome": 4166,
|
||||
|
||||
@@ -274,10 +274,14 @@ async def test_reauth(hass: HomeAssistant) -> None:
|
||||
|
||||
async def test_reconfigure(hass: HomeAssistant) -> None:
|
||||
"""Test the reconfiguration form."""
|
||||
with patch(
|
||||
BIMMER_CONNECTED_LOGIN_PATCH,
|
||||
side_effect=login_sideeffect,
|
||||
autospec=True,
|
||||
with (
|
||||
patch(
|
||||
BIMMER_CONNECTED_LOGIN_PATCH, side_effect=login_sideeffect, autospec=True
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.bmw_connected_drive.async_setup_entry",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
config_entry = MockConfigEntry(**FIXTURE_CONFIG_ENTRY)
|
||||
config_entry.add_to_hass(hass)
|
||||
|
||||
@@ -6,6 +6,7 @@ from deebot_client.event_bus import EventBus
|
||||
from deebot_client.events.map import CachedMapInfoEvent, MajorMapEvent, Map
|
||||
from deebot_client.events.water_info import WaterAmount, WaterAmountEvent
|
||||
from deebot_client.events.work_mode import WorkMode, WorkModeEvent
|
||||
from deebot_client.rs.map import RotationAngle # pylint: disable=no-name-in-module
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
@@ -40,8 +41,16 @@ async def notify_events(hass: HomeAssistant, event_bus: EventBus):
|
||||
event_bus.notify(
|
||||
CachedMapInfoEvent(
|
||||
{
|
||||
Map(id="1", name="", using=False, built=False),
|
||||
Map(id="2", name="Map 2", using=True, built=True),
|
||||
Map(
|
||||
id="1", name="", using=False, built=False, angle=RotationAngle.DEG_0
|
||||
),
|
||||
Map(
|
||||
id="2",
|
||||
name="Map 2",
|
||||
using=True,
|
||||
built=True,
|
||||
angle=RotationAngle.DEG_0,
|
||||
),
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
@@ -0,0 +1,345 @@
|
||||
"""Test supervisor jobs manager."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from datetime import datetime
|
||||
import os
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import uuid4
|
||||
|
||||
from aiohasupervisor.models import Job, JobsInfo
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.hassio.const import ADDONS_COORDINATOR
|
||||
from homeassistant.components.hassio.coordinator import HassioDataUpdateCoordinator
|
||||
from homeassistant.components.hassio.jobs import JobSubscription
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from .test_init import MOCK_ENVIRON
|
||||
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fixture_supervisor_environ() -> Generator[None]:
|
||||
"""Mock os environ for supervisor."""
|
||||
with patch.dict(os.environ, MOCK_ENVIRON):
|
||||
yield
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("all_setup_requests")
|
||||
async def test_job_manager_setup(hass: HomeAssistant, jobs_info: AsyncMock) -> None:
|
||||
"""Test setup of job manager."""
|
||||
jobs_info.return_value = JobsInfo(
|
||||
ignore_conditions=[],
|
||||
jobs=[
|
||||
Job(
|
||||
name="test_job",
|
||||
reference=None,
|
||||
uuid=uuid4(),
|
||||
progress=0,
|
||||
stage=None,
|
||||
done=False,
|
||||
errors=[],
|
||||
created=datetime.now(),
|
||||
extra=None,
|
||||
child_jobs=[
|
||||
Job(
|
||||
name="test_inner_job",
|
||||
reference=None,
|
||||
uuid=uuid4(),
|
||||
progress=0,
|
||||
stage=None,
|
||||
done=False,
|
||||
errors=[],
|
||||
created=datetime.now(),
|
||||
extra=None,
|
||||
child_jobs=[],
|
||||
)
|
||||
],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = await async_setup_component(hass, "hassio", {})
|
||||
assert result
|
||||
jobs_info.assert_called_once()
|
||||
|
||||
data_coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
|
||||
assert len(data_coordinator.jobs.current_jobs) == 2
|
||||
assert data_coordinator.jobs.current_jobs[0].name == "test_job"
|
||||
assert data_coordinator.jobs.current_jobs[1].name == "test_inner_job"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("all_setup_requests")
|
||||
async def test_disconnect_on_config_entry_reload(
|
||||
hass: HomeAssistant, jobs_info: AsyncMock
|
||||
) -> None:
|
||||
"""Test dispatcher subscription disconnects on config entry reload."""
|
||||
result = await async_setup_component(hass, "hassio", {})
|
||||
assert result
|
||||
jobs_info.assert_called_once()
|
||||
|
||||
jobs_info.reset_mock()
|
||||
data_coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
|
||||
await hass.config_entries.async_reload(data_coordinator.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
jobs_info.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("all_setup_requests")
|
||||
async def test_job_manager_ws_updates(
|
||||
hass: HomeAssistant, jobs_info: AsyncMock, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test job updates sync from Supervisor WS messages."""
|
||||
result = await async_setup_component(hass, "hassio", {})
|
||||
assert result
|
||||
jobs_info.assert_called_once()
|
||||
|
||||
jobs_info.reset_mock()
|
||||
client = await hass_ws_client(hass)
|
||||
data_coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
|
||||
assert not data_coordinator.jobs.current_jobs
|
||||
|
||||
# Make an example listener
|
||||
job_data: Job | None = None
|
||||
|
||||
@callback
|
||||
def mock_subcription_callback(job: Job) -> None:
|
||||
nonlocal job_data
|
||||
job_data = job
|
||||
|
||||
subscription = JobSubscription(
|
||||
mock_subcription_callback, name="test_job", reference="test"
|
||||
)
|
||||
unsubscribe = data_coordinator.jobs.subscribe(subscription)
|
||||
|
||||
# Send start of job update
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {
|
||||
"name": "test_job",
|
||||
"reference": "test",
|
||||
"uuid": (uuid := uuid4().hex),
|
||||
"progress": 0,
|
||||
"stage": None,
|
||||
"done": False,
|
||||
"errors": [],
|
||||
"created": (created := datetime.now().isoformat()),
|
||||
"extra": None,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert job_data.name == "test_job"
|
||||
assert job_data.reference == "test"
|
||||
assert job_data.progress == 0
|
||||
assert job_data.done is False
|
||||
# One job in the cache
|
||||
assert len(data_coordinator.jobs.current_jobs) == 1
|
||||
|
||||
# Example progress update
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 2,
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {
|
||||
"name": "test_job",
|
||||
"reference": "test",
|
||||
"uuid": uuid,
|
||||
"progress": 50,
|
||||
"stage": None,
|
||||
"done": False,
|
||||
"errors": [],
|
||||
"created": created,
|
||||
"extra": None,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert job_data.name == "test_job"
|
||||
assert job_data.reference == "test"
|
||||
assert job_data.progress == 50
|
||||
assert job_data.done is False
|
||||
# Same job, same number of jobs in cache
|
||||
assert len(data_coordinator.jobs.current_jobs) == 1
|
||||
|
||||
# Unrelated job update - name change, subscriber should not receive
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 3,
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {
|
||||
"name": "bad_job",
|
||||
"reference": "test",
|
||||
"uuid": uuid4().hex,
|
||||
"progress": 0,
|
||||
"stage": None,
|
||||
"done": False,
|
||||
"errors": [],
|
||||
"created": created,
|
||||
"extra": None,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert job_data.name == "test_job"
|
||||
assert job_data.reference == "test"
|
||||
# New job, cache increases
|
||||
assert len(data_coordinator.jobs.current_jobs) == 2
|
||||
|
||||
# Unrelated job update - reference change, subscriber should not receive
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 4,
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {
|
||||
"name": "test_job",
|
||||
"reference": "bad",
|
||||
"uuid": uuid4().hex,
|
||||
"progress": 0,
|
||||
"stage": None,
|
||||
"done": False,
|
||||
"errors": [],
|
||||
"created": created,
|
||||
"extra": None,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert job_data.name == "test_job"
|
||||
assert job_data.reference == "test"
|
||||
# New job, cache increases
|
||||
assert len(data_coordinator.jobs.current_jobs) == 3
|
||||
|
||||
# Unsubscribe mock listener, should not receive final update
|
||||
unsubscribe()
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 5,
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "job",
|
||||
"data": {
|
||||
"name": "test_job",
|
||||
"reference": "test",
|
||||
"uuid": uuid,
|
||||
"progress": 100,
|
||||
"stage": None,
|
||||
"done": True,
|
||||
"errors": [],
|
||||
"created": created,
|
||||
"extra": None,
|
||||
},
|
||||
},
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert job_data.name == "test_job"
|
||||
assert job_data.reference == "test"
|
||||
assert job_data.progress == 50
|
||||
assert job_data.done is False
|
||||
# Job ended, cache decreases
|
||||
assert len(data_coordinator.jobs.current_jobs) == 2
|
||||
|
||||
# REST API should not be used during this sequence
|
||||
jobs_info.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("all_setup_requests")
|
||||
async def test_job_manager_reload_on_supervisor_restart(
|
||||
hass: HomeAssistant, jobs_info: AsyncMock, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
"""Test job manager reloads cache on supervisor restart."""
|
||||
jobs_info.return_value = JobsInfo(
|
||||
ignore_conditions=[],
|
||||
jobs=[
|
||||
Job(
|
||||
name="test_job",
|
||||
reference="test",
|
||||
uuid=uuid4(),
|
||||
progress=0,
|
||||
stage=None,
|
||||
done=False,
|
||||
errors=[],
|
||||
created=datetime.now(),
|
||||
extra=None,
|
||||
child_jobs=[],
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = await async_setup_component(hass, "hassio", {})
|
||||
assert result
|
||||
jobs_info.assert_called_once()
|
||||
|
||||
data_coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
|
||||
assert len(data_coordinator.jobs.current_jobs) == 1
|
||||
assert data_coordinator.jobs.current_jobs[0].name == "test_job"
|
||||
|
||||
jobs_info.reset_mock()
|
||||
jobs_info.return_value = JobsInfo(ignore_conditions=[], jobs=[])
|
||||
client = await hass_ws_client(hass)
|
||||
|
||||
# Make an example listener
|
||||
job_data: Job | None = None
|
||||
|
||||
@callback
|
||||
def mock_subcription_callback(job: Job) -> None:
|
||||
nonlocal job_data
|
||||
job_data = job
|
||||
|
||||
subscription = JobSubscription(mock_subcription_callback, name="test_job")
|
||||
data_coordinator.jobs.subscribe(subscription)
|
||||
|
||||
# Send supervisor restart signal
|
||||
await client.send_json(
|
||||
{
|
||||
"id": 1,
|
||||
"type": "supervisor/event",
|
||||
"data": {
|
||||
"event": "supervisor_update",
|
||||
"update_key": "supervisor",
|
||||
"data": {"startup": "complete"},
|
||||
},
|
||||
}
|
||||
)
|
||||
msg = await client.receive_json()
|
||||
assert msg["success"]
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# Listener should be told job is done and cache cleared out
|
||||
jobs_info.assert_called_once()
|
||||
assert job_data.name == "test_job"
|
||||
assert job_data.reference == "test"
|
||||
assert job_data.done is True
|
||||
assert not data_coordinator.jobs.current_jobs
|
||||
@@ -125,6 +125,22 @@
|
||||
"filename": "logi_circle.markdown",
|
||||
"alert_url": "https://alerts.home-assistant.io/#logi_circle.markdown"
|
||||
},
|
||||
{
|
||||
"id": "neato",
|
||||
"title": "New Neato Botvacs Do Not Support Existing API",
|
||||
"created": "2021-12-20T13:27:00.000Z",
|
||||
"integrations": [
|
||||
{
|
||||
"package": "neato"
|
||||
}
|
||||
],
|
||||
"homeassistant": {
|
||||
"package": "homeassistant",
|
||||
"affected_from_version": "0.30"
|
||||
},
|
||||
"filename": "neato.markdown",
|
||||
"alert_url": "https://alerts.home-assistant.io/#neato.markdown"
|
||||
},
|
||||
{
|
||||
"id": "nest",
|
||||
"title": "Nest Desktop Auth Deprecation",
|
||||
|
||||
@@ -88,6 +88,22 @@
|
||||
"filename": "logi_circle.markdown",
|
||||
"alert_url": "https://alerts.home-assistant.io/#logi_circle.markdown"
|
||||
},
|
||||
{
|
||||
"id": "neato",
|
||||
"title": "New Neato Botvacs Do Not Support Existing API",
|
||||
"created": "2021-12-20T13:27:00.000Z",
|
||||
"integrations": [
|
||||
{
|
||||
"package": "neato"
|
||||
}
|
||||
],
|
||||
"homeassistant": {
|
||||
"package": "homeassistant",
|
||||
"affected_from_version": "0.30"
|
||||
},
|
||||
"filename": "neato.markdown",
|
||||
"alert_url": "https://alerts.home-assistant.io/#neato.markdown"
|
||||
},
|
||||
{
|
||||
"id": "nest",
|
||||
"title": "Nest Desktop Auth Deprecation",
|
||||
|
||||
@@ -55,6 +55,7 @@ async def setup_repairs(hass: HomeAssistant) -> None:
|
||||
("hive_us", "hive"),
|
||||
("homematicip_cloud", "homematicip_cloud"),
|
||||
("logi_circle", "logi_circle"),
|
||||
("neato", "neato"),
|
||||
("nest", "nest"),
|
||||
("senseme", "senseme"),
|
||||
("sochain", "sochain"),
|
||||
@@ -70,6 +71,7 @@ async def setup_repairs(hass: HomeAssistant) -> None:
|
||||
("hive_us", "hive"),
|
||||
("homematicip_cloud", "homematicip_cloud"),
|
||||
("logi_circle", "logi_circle"),
|
||||
("neato", "neato"),
|
||||
("nest", "nest"),
|
||||
("senseme", "senseme"),
|
||||
("sochain", "sochain"),
|
||||
@@ -85,6 +87,7 @@ async def setup_repairs(hass: HomeAssistant) -> None:
|
||||
("hikvision", "hikvisioncam"),
|
||||
("homematicip_cloud", "homematicip_cloud"),
|
||||
("logi_circle", "logi_circle"),
|
||||
("neato", "neato"),
|
||||
("nest", "nest"),
|
||||
("senseme", "senseme"),
|
||||
("sochain", "sochain"),
|
||||
@@ -118,6 +121,7 @@ async def test_alerts(
|
||||
"hive",
|
||||
"homematicip_cloud",
|
||||
"logi_circle",
|
||||
"neato",
|
||||
"nest",
|
||||
"senseme",
|
||||
"sochain",
|
||||
@@ -194,6 +198,7 @@ async def test_alerts(
|
||||
"hive",
|
||||
"homematicip_cloud",
|
||||
"logi_circle",
|
||||
"neato",
|
||||
"nest",
|
||||
"senseme",
|
||||
"sochain",
|
||||
@@ -211,6 +216,7 @@ async def test_alerts(
|
||||
("hive_us", "hive"),
|
||||
("homematicip_cloud", "homematicip_cloud"),
|
||||
("logi_circle", "logi_circle"),
|
||||
("neato", "neato"),
|
||||
("nest", "nest"),
|
||||
("senseme", "senseme"),
|
||||
("sochain", "sochain"),
|
||||
@@ -227,6 +233,7 @@ async def test_alerts(
|
||||
"hive",
|
||||
"homematicip_cloud",
|
||||
"logi_circle",
|
||||
"neato",
|
||||
"nest",
|
||||
"senseme",
|
||||
"sochain",
|
||||
@@ -241,6 +248,7 @@ async def test_alerts(
|
||||
("hive_us", "hive"),
|
||||
("homematicip_cloud", "homematicip_cloud"),
|
||||
("logi_circle", "logi_circle"),
|
||||
("neato", "neato"),
|
||||
("nest", "nest"),
|
||||
("senseme", "senseme"),
|
||||
("sochain", "sochain"),
|
||||
@@ -256,6 +264,7 @@ async def test_alerts(
|
||||
"hive",
|
||||
"homematicip_cloud",
|
||||
"logi_circle",
|
||||
"neato",
|
||||
"nest",
|
||||
"senseme",
|
||||
"sochain",
|
||||
@@ -271,6 +280,7 @@ async def test_alerts(
|
||||
("hikvision", "hikvisioncam"),
|
||||
("homematicip_cloud", "homematicip_cloud"),
|
||||
("logi_circle", "logi_circle"),
|
||||
("neato", "neato"),
|
||||
("nest", "nest"),
|
||||
("senseme", "senseme"),
|
||||
("sochain", "sochain"),
|
||||
@@ -517,6 +527,7 @@ async def test_no_alerts(
|
||||
("hive_us", "hive"),
|
||||
("homematicip_cloud", "homematicip_cloud"),
|
||||
("logi_circle", "logi_circle"),
|
||||
("neato", "neato"),
|
||||
("nest", "nest"),
|
||||
("senseme", "senseme"),
|
||||
("sochain", "sochain"),
|
||||
@@ -529,6 +540,7 @@ async def test_no_alerts(
|
||||
("hive_us", "hive"),
|
||||
("homematicip_cloud", "homematicip_cloud"),
|
||||
("logi_circle", "logi_circle"),
|
||||
("neato", "neato"),
|
||||
("nest", "nest"),
|
||||
("senseme", "senseme"),
|
||||
("sochain", "sochain"),
|
||||
@@ -544,6 +556,7 @@ async def test_no_alerts(
|
||||
("hive_us", "hive"),
|
||||
("homematicip_cloud", "homematicip_cloud"),
|
||||
("logi_circle", "logi_circle"),
|
||||
("neato", "neato"),
|
||||
("nest", "nest"),
|
||||
("senseme", "senseme"),
|
||||
("sochain", "sochain"),
|
||||
@@ -557,6 +570,7 @@ async def test_no_alerts(
|
||||
("hive_us", "hive"),
|
||||
("homematicip_cloud", "homematicip_cloud"),
|
||||
("logi_circle", "logi_circle"),
|
||||
("neato", "neato"),
|
||||
("nest", "nest"),
|
||||
("senseme", "senseme"),
|
||||
("sochain", "sochain"),
|
||||
@@ -592,6 +606,7 @@ async def test_alerts_change(
|
||||
"hive",
|
||||
"homematicip_cloud",
|
||||
"logi_circle",
|
||||
"neato",
|
||||
"nest",
|
||||
"senseme",
|
||||
"sochain",
|
||||
|
||||
@@ -1016,8 +1016,28 @@ async def test_climate_ui_load(knx: KNXTestKit) -> None:
|
||||
knx.assert_state(
|
||||
"climate.direct_indi_op_heat_cool",
|
||||
HVACMode.HEAT,
|
||||
current_temperature=20.0,
|
||||
command_value=None,
|
||||
min_temp=10.0,
|
||||
max_temp=24.0,
|
||||
target_temp_step=0.1,
|
||||
preset_modes=["comfort", "standby", "economy", "building_protection"],
|
||||
preset_mode="comfort",
|
||||
hvac_modes=["cool", "heat", "off"],
|
||||
hvac_action="heating",
|
||||
supported_features=401,
|
||||
)
|
||||
knx.assert_state(
|
||||
"climate.sps_op_mode_contr_mode",
|
||||
HVACMode.COOL,
|
||||
current_temperature=20.0,
|
||||
command_value=13,
|
||||
min_temp=14.0,
|
||||
max_temp=30.0,
|
||||
target_temp_step=0.5,
|
||||
preset_modes=["comfort", "standby", "economy", "building_protection"],
|
||||
preset_mode="comfort",
|
||||
hvac_modes=["auto", "cool", "dry", "fan_only", "heat", "off"],
|
||||
hvac_action="cooling",
|
||||
supported_features=953,
|
||||
)
|
||||
|
||||
@@ -634,8 +634,8 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) System Fan #1 Fan',
|
||||
'max_value': '-',
|
||||
'min_value': '-',
|
||||
'max_value': None,
|
||||
'min_value': None,
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -1458,8 +1458,8 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) System Fan #1 Fan',
|
||||
'max_value': '-',
|
||||
'min_value': '-',
|
||||
'max_value': None,
|
||||
'min_value': None,
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -1836,8 +1836,8 @@
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'MSI MAG B650M MORTAR WIFI (MS-7D76) System Fan #1 Fan',
|
||||
'max_value': '-',
|
||||
'min_value': '-',
|
||||
'max_value': None,
|
||||
'min_value': None,
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
|
||||
@@ -100,7 +100,7 @@ async def test_sensors_are_updated(
|
||||
|
||||
updated_data = dict(mock_lhm_client.get_data.return_value.sensor_data)
|
||||
updated_data["amdcpu-0-temperature-3"] = replace(
|
||||
updated_data["amdcpu-0-temperature-3"], value="42,1"
|
||||
updated_data["amdcpu-0-temperature-3"], value="42.1"
|
||||
)
|
||||
mock_lhm_client.get_data.return_value = replace(
|
||||
mock_lhm_client.get_data.return_value,
|
||||
|
||||
@@ -10,13 +10,15 @@ from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.miele.const import DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from tests.common import (
|
||||
MockConfigEntry,
|
||||
async_fire_time_changed,
|
||||
async_load_json_object_fixture,
|
||||
mock_restore_cache_with_extra_data,
|
||||
snapshot_platform,
|
||||
)
|
||||
|
||||
@@ -583,6 +585,7 @@ async def test_laundry_dry_scenario(
|
||||
check_sensor_state(hass, "sensor.tumble_dryer_elapsed_time", "20", step)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("restore_state", ["45", STATE_UNKNOWN, STATE_UNAVAILABLE])
|
||||
@pytest.mark.parametrize("load_device_file", ["laundry.json"])
|
||||
@pytest.mark.parametrize("platforms", [(SENSOR_DOMAIN,)])
|
||||
async def test_elapsed_time_sensor_restored(
|
||||
@@ -592,6 +595,7 @@ async def test_elapsed_time_sensor_restored(
|
||||
setup_platform: None,
|
||||
device_fixture: MieleDevices,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
restore_state,
|
||||
) -> None:
|
||||
"""Test that elapsed time returns the restored value when program ended."""
|
||||
|
||||
@@ -648,6 +652,26 @@ async def test_elapsed_time_sensor_restored(
|
||||
|
||||
assert hass.states.get(entity_id).state == "unavailable"
|
||||
|
||||
# simulate restore with state different from native value
|
||||
mock_restore_cache_with_extra_data(
|
||||
hass,
|
||||
[
|
||||
(
|
||||
State(
|
||||
entity_id,
|
||||
restore_state,
|
||||
{
|
||||
"unit_of_measurement": "min",
|
||||
},
|
||||
),
|
||||
{
|
||||
"native_value": "12",
|
||||
"native_unit_of_measurement": "min",
|
||||
},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
await hass.config_entries.async_reload(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
"""Tests for the Neato component."""
|
||||
@@ -0,0 +1,164 @@
|
||||
"""Test the Neato Botvac config flow."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from pybotvac.neato import Neato
|
||||
import pytest
|
||||
|
||||
from homeassistant import config_entries, setup
|
||||
from homeassistant.components.application_credentials import (
|
||||
ClientCredential,
|
||||
async_import_client_credential,
|
||||
)
|
||||
from homeassistant.components.neato.const import NEATO_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import config_entry_oauth2_flow
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
CLIENT_ID = "1234"
|
||||
CLIENT_SECRET = "5678"
|
||||
|
||||
VENDOR = Neato()
|
||||
OAUTH2_AUTHORIZE = VENDOR.auth_endpoint
|
||||
OAUTH2_TOKEN = VENDOR.token_endpoint
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
async def test_full_flow(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Check full flow."""
|
||||
assert await setup.async_setup_component(hass, "neato", {})
|
||||
await async_import_client_credential(
|
||||
hass, NEATO_DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET)
|
||||
)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"neato", context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": "https://example.com/auth/external/callback",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["url"] == (
|
||||
f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}"
|
||||
"&redirect_uri=https://example.com/auth/external/callback"
|
||||
f"&state={state}"
|
||||
f"&client_secret={CLIENT_SECRET}"
|
||||
"&scope=public_profile+control_robots+maps"
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
assert resp.headers["content-type"] == "text/html; charset=utf-8"
|
||||
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.neato.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
await hass.config_entries.flow.async_configure(result["flow_id"])
|
||||
|
||||
assert len(hass.config_entries.async_entries(NEATO_DOMAIN)) == 1
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_abort_if_already_setup(hass: HomeAssistant) -> None:
|
||||
"""Test we abort if Neato is already setup."""
|
||||
entry = MockConfigEntry(
|
||||
domain=NEATO_DOMAIN,
|
||||
data={"auth_implementation": "neato", "token": {"some": "data"}},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
# Should fail
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
"neato", context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.ABORT
|
||||
assert result["reason"] == "already_configured"
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("current_request_with_host")
|
||||
async def test_reauth(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
) -> None:
|
||||
"""Test initialization of the reauth flow."""
|
||||
assert await setup.async_setup_component(hass, "neato", {})
|
||||
await async_import_client_credential(
|
||||
hass, NEATO_DOMAIN, ClientCredential(CLIENT_ID, CLIENT_SECRET)
|
||||
)
|
||||
|
||||
entry = MockConfigEntry(
|
||||
entry_id="my_entry",
|
||||
domain=NEATO_DOMAIN,
|
||||
data={"username": "abcdef", "password": "123456", "vendor": "neato"},
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
# Should show form
|
||||
result = await entry.start_reauth_flow(hass)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
|
||||
# Confirm reauth flow
|
||||
result2 = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
|
||||
state = config_entry_oauth2_flow._encode_jwt(
|
||||
hass,
|
||||
{
|
||||
"flow_id": result["flow_id"],
|
||||
"redirect_uri": "https://example.com/auth/external/callback",
|
||||
},
|
||||
)
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
resp = await client.get(f"/auth/external/callback?code=abcd&state={state}")
|
||||
assert resp.status == 200
|
||||
|
||||
aioclient_mock.post(
|
||||
OAUTH2_TOKEN,
|
||||
json={
|
||||
"refresh_token": "mock-refresh-token",
|
||||
"access_token": "mock-access-token",
|
||||
"type": "Bearer",
|
||||
"expires_in": 60,
|
||||
},
|
||||
)
|
||||
|
||||
# Update entry
|
||||
with patch(
|
||||
"homeassistant.components.neato.async_setup_entry", return_value=True
|
||||
) as mock_setup:
|
||||
result3 = await hass.config_entries.flow.async_configure(result2["flow_id"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
new_entry = hass.config_entries.async_get_entry("my_entry")
|
||||
|
||||
assert result3["type"] is FlowResultType.ABORT
|
||||
assert result3["reason"] == "reauth_successful"
|
||||
assert new_entry.state is ConfigEntryState.LOADED
|
||||
assert len(hass.config_entries.async_entries(NEATO_DOMAIN)) == 1
|
||||
assert len(mock_setup.mock_calls) == 1
|
||||
@@ -37,7 +37,7 @@ def mock_opower_api() -> Generator[AsyncMock]:
|
||||
"homeassistant.components.opower.coordinator.Opower", autospec=True
|
||||
) as mock_api:
|
||||
api = mock_api.return_value
|
||||
api.utility = PGE
|
||||
api.utility = PGE()
|
||||
|
||||
api.async_get_accounts.return_value = [
|
||||
Account(
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
"""Tests for Shelly sensor platform."""
|
||||
|
||||
from copy import deepcopy
|
||||
from unittest.mock import Mock
|
||||
from unittest.mock import Mock, PropertyMock
|
||||
|
||||
from aioshelly.const import MODEL_BLU_GATEWAY_G3
|
||||
from aioshelly.exceptions import NotInitialized
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
@@ -52,6 +53,7 @@ from . import (
|
||||
register_device,
|
||||
register_entity,
|
||||
)
|
||||
from .conftest import MOCK_CONFIG, MOCK_SHELLY_RPC, MOCK_STATUS_RPC
|
||||
|
||||
from tests.common import (
|
||||
async_fire_time_changed,
|
||||
@@ -632,6 +634,9 @@ async def test_rpc_restored_sleeping_sensor(
|
||||
extra_data = {"native_value": "21.0", "native_unit_of_measurement": "°C"}
|
||||
|
||||
mock_restore_cache_with_extra_data(hass, ((State(entity_id, ""), extra_data),))
|
||||
type(mock_rpc_device).shelly = PropertyMock(side_effect=NotInitialized())
|
||||
type(mock_rpc_device).config = PropertyMock(side_effect=NotInitialized())
|
||||
type(mock_rpc_device).status = PropertyMock(side_effect=NotInitialized())
|
||||
monkeypatch.setattr(mock_rpc_device, "initialized", False)
|
||||
|
||||
await hass.config_entries.async_setup(entry.entry_id)
|
||||
@@ -641,6 +646,9 @@ async def test_rpc_restored_sleeping_sensor(
|
||||
assert state.state == "21.0"
|
||||
|
||||
# Make device online
|
||||
type(mock_rpc_device).shelly = PropertyMock(return_value=MOCK_SHELLY_RPC)
|
||||
type(mock_rpc_device).config = PropertyMock(return_value=MOCK_CONFIG)
|
||||
type(mock_rpc_device).status = PropertyMock(return_value=MOCK_STATUS_RPC)
|
||||
monkeypatch.setattr(mock_rpc_device, "initialized", True)
|
||||
mock_rpc_device.mock_online()
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
@@ -7,12 +7,15 @@ from homeassistant.components import notify
|
||||
from homeassistant.components.telegram import DOMAIN
|
||||
from homeassistant.const import SERVICE_RELOAD
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import get_fixture_path
|
||||
|
||||
|
||||
async def test_reload_notify(hass: HomeAssistant) -> None:
|
||||
async def test_reload_notify(
|
||||
hass: HomeAssistant, issue_registry: ir.IssueRegistry
|
||||
) -> None:
|
||||
"""Verify we can reload the notify service."""
|
||||
|
||||
with patch("homeassistant.components.telegram_bot.async_setup", return_value=True):
|
||||
@@ -45,3 +48,9 @@ async def test_reload_notify(hass: HomeAssistant) -> None:
|
||||
|
||||
assert not hass.services.has_service(notify.DOMAIN, DOMAIN)
|
||||
assert hass.services.has_service(notify.DOMAIN, "telegram_reloaded")
|
||||
|
||||
assert issue_registry.async_get_issue(
|
||||
domain=DOMAIN,
|
||||
issue_id="migrate_notify",
|
||||
)
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
@@ -37,4 +37,5 @@ async def test_send_message(
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("event.mock_title_update_event")
|
||||
assert state is not None
|
||||
assert state.attributes == snapshot(exclude=props("config_entry_id"))
|
||||
|
||||
@@ -167,39 +167,6 @@
|
||||
# ---
|
||||
# name: test_sensor_state[Air Purifier 200s][entities]
|
||||
list([
|
||||
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': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.air_purifier_200s_air_quality',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': 'Air quality',
|
||||
'platform': 'vesync',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': 'air_quality',
|
||||
'unique_id': 'asd_sdfKIHG7IJHGwJGJ7GJ_ag5h3G55-air-quality',
|
||||
'unit_of_measurement': None,
|
||||
}),
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -237,19 +204,6 @@
|
||||
}),
|
||||
])
|
||||
# ---
|
||||
# name: test_sensor_state[Air Purifier 200s][sensor.air_purifier_200s_air_quality]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Air Purifier 200s Air quality',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.air_purifier_200s_air_quality',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'None',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensor_state[Air Purifier 200s][sensor.air_purifier_200s_filter_lifetime]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
# serializer version: 1
|
||||
# name: test_device_tracker[ex30_2024][device_tracker.volvo_ex30_none-entry]
|
||||
# name: test_device_tracker[ex30_2024][device_tracker.volvo_ex30_location-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -12,7 +12,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'device_tracker',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'device_tracker.volvo_ex30_none',
|
||||
'entity_id': 'device_tracker.volvo_ex30_location',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -24,7 +24,7 @@
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'original_name': 'Location',
|
||||
'platform': 'volvo',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -34,24 +34,24 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_device_tracker[ex30_2024][device_tracker.volvo_ex30_none-state]
|
||||
# name: test_device_tracker[ex30_2024][device_tracker.volvo_ex30_location-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Volvo EX30 None',
|
||||
'friendly_name': 'Volvo EX30 Location',
|
||||
'gps_accuracy': 0,
|
||||
'latitude': 57.72537482589284,
|
||||
'longitude': 11.849843629550225,
|
||||
'source_type': <SourceType.GPS: 'gps'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'device_tracker.volvo_ex30_none',
|
||||
'entity_id': 'device_tracker.volvo_ex30_location',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'not_home',
|
||||
})
|
||||
# ---
|
||||
# name: test_device_tracker[s90_diesel_2018][device_tracker.volvo_s90_none-entry]
|
||||
# name: test_device_tracker[s90_diesel_2018][device_tracker.volvo_s90_location-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -64,7 +64,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'device_tracker',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'device_tracker.volvo_s90_none',
|
||||
'entity_id': 'device_tracker.volvo_s90_location',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -76,7 +76,7 @@
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'original_name': 'Location',
|
||||
'platform': 'volvo',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -86,24 +86,24 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_device_tracker[s90_diesel_2018][device_tracker.volvo_s90_none-state]
|
||||
# name: test_device_tracker[s90_diesel_2018][device_tracker.volvo_s90_location-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Volvo S90 None',
|
||||
'friendly_name': 'Volvo S90 Location',
|
||||
'gps_accuracy': 0,
|
||||
'latitude': 57.72537482589284,
|
||||
'longitude': 11.849843629550225,
|
||||
'source_type': <SourceType.GPS: 'gps'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'device_tracker.volvo_s90_none',
|
||||
'entity_id': 'device_tracker.volvo_s90_location',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'not_home',
|
||||
})
|
||||
# ---
|
||||
# name: test_device_tracker[xc40_electric_2024][device_tracker.volvo_xc40_none-entry]
|
||||
# name: test_device_tracker[xc40_electric_2024][device_tracker.volvo_xc40_location-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -116,7 +116,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'device_tracker',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'device_tracker.volvo_xc40_none',
|
||||
'entity_id': 'device_tracker.volvo_xc40_location',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -128,7 +128,7 @@
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'original_name': 'Location',
|
||||
'platform': 'volvo',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -138,24 +138,24 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_device_tracker[xc40_electric_2024][device_tracker.volvo_xc40_none-state]
|
||||
# name: test_device_tracker[xc40_electric_2024][device_tracker.volvo_xc40_location-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Volvo XC40 None',
|
||||
'friendly_name': 'Volvo XC40 Location',
|
||||
'gps_accuracy': 0,
|
||||
'latitude': 57.72537482589284,
|
||||
'longitude': 11.849843629550225,
|
||||
'source_type': <SourceType.GPS: 'gps'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'device_tracker.volvo_xc40_none',
|
||||
'entity_id': 'device_tracker.volvo_xc40_location',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'not_home',
|
||||
})
|
||||
# ---
|
||||
# name: test_device_tracker[xc90_petrol_2019][device_tracker.volvo_xc90_none-entry]
|
||||
# name: test_device_tracker[xc90_petrol_2019][device_tracker.volvo_xc90_location-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
@@ -168,7 +168,7 @@
|
||||
'disabled_by': None,
|
||||
'domain': 'device_tracker',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'device_tracker.volvo_xc90_none',
|
||||
'entity_id': 'device_tracker.volvo_xc90_location',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
@@ -180,7 +180,7 @@
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'original_name': 'Location',
|
||||
'platform': 'volvo',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
@@ -190,17 +190,17 @@
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_device_tracker[xc90_petrol_2019][device_tracker.volvo_xc90_none-state]
|
||||
# name: test_device_tracker[xc90_petrol_2019][device_tracker.volvo_xc90_location-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'friendly_name': 'Volvo XC90 None',
|
||||
'friendly_name': 'Volvo XC90 Location',
|
||||
'gps_accuracy': 0,
|
||||
'latitude': 57.72537482589284,
|
||||
'longitude': 11.849843629550225,
|
||||
'source_type': <SourceType.GPS: 'gps'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'device_tracker.volvo_xc90_none',
|
||||
'entity_id': 'device_tracker.volvo_xc90_location',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
|
||||
Reference in New Issue
Block a user