Compare commits

...

33 Commits

Author SHA1 Message Date
Bram Kragten d2ba94e1bf Bump version to 2025.11.0b2 2025-11-03 08:04:32 +01:00
Joost Lekkerkerker 9a4ed82399 Bump python-open-router to 0.3.2 (#155700) 2025-11-03 08:04:12 +01:00
cdnninja b5136d01aa fix vesync mist level value (#155697) 2025-11-03 08:04:11 +01:00
starkillerOG d3e05090ea Bump reolink_aio to 0.16.3 (#155692) 2025-11-03 08:04:10 +01:00
Michael 7e75ca7af9 Revert "Remove neato integration (#154902)" (#155685) 2025-11-03 08:04:10 +01:00
Matthias Alphart 6616b5775f Fix KNX climate loading min/max temp from UI config (#155682) 2025-11-03 08:04:09 +01:00
Robert Resch 69b82d4c59 Bump deebot-client to 16.3.0 (#155681) 2025-11-03 08:04:08 +01:00
Bram Kragten 6b9709677a Fix device tracker name & icon for Volvo integration (#155667) 2025-11-03 08:03:00 +01:00
Robert Resch a4e9c82c84 Bump deebot-client to 16.2.0 (#155642) 2025-11-03 07:57:45 +01:00
cdnninja de86bedb80 vesync don't assume fan speed target (#155617) 2025-11-03 07:57:44 +01:00
Matthias Alphart 9111c6df90 Update knx-frontend to 2025.10.31.195356 (#155569) 2025-11-03 07:57:43 +01:00
Jordan Harvey 751f6bddb1 Update pynintendoparental to version 1.1.3 (#155568) 2025-11-03 07:57:42 +01:00
Josef Zweck c9a61de0a1 Bump onedrive-personal-sdk to 0.0.15 (#155540) 2025-11-03 07:57:41 +01:00
Sid 01fb46d903 Bump eheimdigital to 1.4.0 (#155539) 2025-11-03 07:57:41 +01:00
cdnninja d26f61c9fe Bump pyvesync to 3.1.4 (#155533) 2025-11-03 07:57:39 +01:00
Robert Resch a47a144312 Bump uv to 0.9.6 (#155521) 2025-11-03 07:57:38 +01:00
Erwin Douna 69cf4f99d1 Portainer refactor CONF_VERIFY_SSL (#155520) 2025-11-03 07:57:37 +01:00
Shay Levy e6c757c187 Fix Shelly irrigation zone ID retrieval with Sleepy devices (#155514) 2025-11-03 07:57:36 +01:00
hanwg a36b0e2f3f Fix event entity state update for Telegram bot (#155510) 2025-11-03 07:57:35 +01:00
Jakob Schlyter 1a7c6cd96c Update regions and voices used by Amazon Polly (#155501) 2025-11-03 07:57:34 +01:00
tronikos ba3e538402 Bump opower to 0.15.9 (#155473) 2025-11-03 07:57:33 +01:00
Mike Degatano b2cd08aa65 Addon progress reporting follow-up from feedback (#155464) 2025-11-03 07:57:33 +01:00
karwosts 06dcd25a16 Hassfest check for invalid localization placeholders (#155216) 2025-11-03 07:57:32 +01:00
Bram Kragten fd36782bae Bump version to 2025.11.0b1 2025-10-30 20:12:15 +01:00
Bram Kragten ed4573db57 Update frontend to 20251029.1 (#155513) 2025-10-30 20:11:55 +01:00
Erwin Douna 78373a6483 Firefly fix config flow (#155503) 2025-10-30 20:11:54 +01:00
Sab44 8455c35bec Bump librehardwaremonitor-api to 1.5.0 (#155492) 2025-10-30 20:11:53 +01:00
Kinachi249 00887a2f3f Bump PyCync to 0.4.3 (#155477) 2025-10-30 20:11:52 +01:00
Erwin Douna f1ca7543fa Bump pyportainer 1.0.12 (#155468) 2025-10-30 20:11:51 +01:00
Abílio Costa bb72b24ba9 Mock async_setup_entry in BMW Connected Drive config flow test (#155446) 2025-10-30 20:11:50 +01:00
Andrea Turri 322a27d992 Miele RestoreSensor: restore native value rather than stringified state (#152750)
Co-authored-by: Erik Montnemery <erik@montnemery.com>
Co-authored-by: Marc Mueller <30130371+cdce8p@users.noreply.github.com>
2025-10-30 20:11:49 +01:00
hanwg a3b516110b Deprecate legacy Telegram notify service (#150720)
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
Co-authored-by: abmantis <amfcalt@gmail.com>
2025-10-30 20:11:48 +01:00
Bram Kragten 95ac5c0183 Bump version to 2025.11.0b0 2025-10-29 18:53:20 +01:00
88 changed files with 2267 additions and 230 deletions
+1
View File
@@ -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
View File
@@ -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}"
+1 -1
View File
@@ -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"]
}
+5 -1
View File
@@ -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()
+33 -14
View File
@@ -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
+3 -3
View File
@@ -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
+1 -1
View File
@@ -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(",", ".")
+28 -32
View File
@@ -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
+58
View File
@@ -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,
),
)
+44
View File
@@ -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)
+130
View File
@@ -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)
+150
View File
@@ -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",
}
+24
View File
@@ -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,
)
+50
View File
@@ -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"]
}
+81
View File
@@ -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"
}
}
}
+118
View File
@@ -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
)
+388
View File
@@ -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",
+8 -4
View File
@@ -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
+15 -3
View File
@@ -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.",
+3 -3
View File
@@ -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}"
+6 -2
View File
@@ -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"]
}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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)
+10
View File
@@ -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",
+1
View File
@@ -27,6 +27,7 @@ APPLICATION_CREDENTIALS = [
"miele",
"monzo",
"myuplink",
"neato",
"nest",
"netatmo",
"ondilo_ico",
+1
View File
@@ -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",
+2 -2
View File
@@ -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
Generated
+10
View File
@@ -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
View File
@@ -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",
+1 -1
View File
@@ -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
+16 -13
View File
@@ -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
+16 -13
View File
@@ -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
+1 -1
View File
@@ -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
+2
View File
@@ -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",
+16
View File
@@ -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)
+11 -2
View File
@@ -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,
),
}
)
)
+345
View File
@@ -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",
+20
View File
@@ -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,
+25 -1
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Tests for the Neato component."""
+164
View File
@@ -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
+1 -1
View File
@@ -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(
+9 -1
View File
@@ -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)
+10 -1
View File
@@ -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>,