mirror of
https://github.com/home-assistant/core.git
synced 2025-11-10 11:29:46 +00:00
Compare commits
85 Commits
cursor/add
...
2025.11.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f3ddffb5ff | ||
|
|
9bdfa77fa0 | ||
|
|
c65003009f | ||
|
|
0f722109b7 | ||
|
|
f7d86dec3c | ||
|
|
6b49c8a70c | ||
|
|
ab9a8f3e53 | ||
|
|
4e12628266 | ||
|
|
e6d8d4de42 | ||
|
|
6620b90eb4 | ||
|
|
6fd3af8891 | ||
|
|
46979b8418 | ||
|
|
1718a11de2 | ||
|
|
2016b1d8c7 | ||
|
|
4b72e45fc2 | ||
|
|
ead5ce905b | ||
|
|
0b91a92554 | ||
|
|
f233f2da3f | ||
|
|
7855df92c8 | ||
|
|
11309f89f0 | ||
|
|
396a987035 | ||
|
|
b7696bfb20 | ||
|
|
5cfbe2cf71 | ||
|
|
4e255286af | ||
|
|
53a96af844 | ||
|
|
accb705d8b | ||
|
|
1793abce4f | ||
|
|
8bfed0b60c | ||
|
|
016c1de2ef | ||
|
|
c270f31365 | ||
|
|
f9e06acfc7 | ||
|
|
901558b293 | ||
|
|
c09cf36345 | ||
|
|
926627b49c | ||
|
|
a8eeba9c5f | ||
|
|
e4591c27c0 | ||
|
|
40dedec602 | ||
|
|
3a65b5ca70 | ||
|
|
dbeb82861f | ||
|
|
e43c35ab2d | ||
|
|
b4c4fdefe3 | ||
|
|
965dd7c557 | ||
|
|
9a921f2c8e | ||
|
|
aaae3244a8 | ||
|
|
40ff100900 | ||
|
|
1b62b2309f | ||
|
|
9d57251aea | ||
|
|
f877614e7f | ||
|
|
170e1e87c7 | ||
|
|
e1feba5c86 | ||
|
|
9bf52b7966 | ||
|
|
3bc61a3564 | ||
|
|
d2ba94e1bf | ||
|
|
9a4ed82399 | ||
|
|
b5136d01aa | ||
|
|
d3e05090ea | ||
|
|
7e75ca7af9 | ||
|
|
6616b5775f | ||
|
|
69b82d4c59 | ||
|
|
6b9709677a | ||
|
|
a4e9c82c84 | ||
|
|
de86bedb80 | ||
|
|
9111c6df90 | ||
|
|
751f6bddb1 | ||
|
|
c9a61de0a1 | ||
|
|
01fb46d903 | ||
|
|
d26f61c9fe | ||
|
|
a47a144312 | ||
|
|
69cf4f99d1 | ||
|
|
e6c757c187 | ||
|
|
a36b0e2f3f | ||
|
|
1a7c6cd96c | ||
|
|
ba3e538402 | ||
|
|
b2cd08aa65 | ||
|
|
06dcd25a16 | ||
|
|
fd36782bae | ||
|
|
ed4573db57 | ||
|
|
78373a6483 | ||
|
|
8455c35bec | ||
|
|
00887a2f3f | ||
|
|
f1ca7543fa | ||
|
|
bb72b24ba9 | ||
|
|
322a27d992 | ||
|
|
a3b516110b | ||
|
|
95ac5c0183 |
@@ -361,6 +361,7 @@ homeassistant.components.myuplink.*
|
||||
homeassistant.components.nam.*
|
||||
homeassistant.components.nanoleaf.*
|
||||
homeassistant.components.nasweb.*
|
||||
homeassistant.components.neato.*
|
||||
homeassistant.components.nest.*
|
||||
homeassistant.components.netatmo.*
|
||||
homeassistant.components.network.*
|
||||
|
||||
4
CODEOWNERS
generated
4
CODEOWNERS
generated
@@ -1543,8 +1543,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/suez_water/ @ooii @jb101010-2
|
||||
/homeassistant/components/sun/ @home-assistant/core
|
||||
/tests/components/sun/ @home-assistant/core
|
||||
/homeassistant/components/sunricher_dali_center/ @niracler
|
||||
/tests/components/sunricher_dali_center/ @niracler
|
||||
/homeassistant/components/sunricher_dali/ @niracler
|
||||
/tests/components/sunricher_dali/ @niracler
|
||||
/homeassistant/components/supla/ @mwegrzynek
|
||||
/homeassistant/components/surepetcare/ @benleb @danielhiversen
|
||||
/tests/components/surepetcare/ @benleb @danielhiversen
|
||||
|
||||
2
Dockerfile
generated
2
Dockerfile
generated
@@ -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
|
||||
|
||||
|
||||
@@ -179,12 +179,18 @@ class Data:
|
||||
user_hash = base64.b64decode(found["password"])
|
||||
|
||||
# bcrypt.checkpw is timing-safe
|
||||
if not bcrypt.checkpw(password.encode(), user_hash):
|
||||
# With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
|
||||
# Previously the password was silently truncated.
|
||||
# https://github.com/pyca/bcrypt/pull/1000
|
||||
if not bcrypt.checkpw(password.encode()[:72], user_hash):
|
||||
raise InvalidAuth
|
||||
|
||||
def hash_password(self, password: str, for_storage: bool = False) -> bytes:
|
||||
"""Encode a password."""
|
||||
hashed: bytes = bcrypt.hashpw(password.encode(), bcrypt.gensalt(rounds=12))
|
||||
# With bcrypt 5.0 passing a password longer than 72 bytes raises a ValueError.
|
||||
# Previously the password was silently truncated.
|
||||
# https://github.com/pyca/bcrypt/pull/1000
|
||||
hashed: bytes = bcrypt.hashpw(password.encode()[:72], bcrypt.gensalt(rounds=12))
|
||||
|
||||
if for_storage:
|
||||
hashed = base64.b64encode(hashed)
|
||||
|
||||
@@ -23,7 +23,7 @@ from homeassistant.components.bluetooth import (
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_ADDRESS
|
||||
|
||||
from .const import DOMAIN, MFCT_ID
|
||||
from .const import DEVICE_MODEL, DOMAIN, MFCT_ID
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -128,15 +128,15 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm discovery."""
|
||||
assert self._discovered_device is not None
|
||||
|
||||
if user_input is not None:
|
||||
if (
|
||||
self._discovered_device is not None
|
||||
and self._discovered_device.device.firmware.need_firmware_upgrade
|
||||
):
|
||||
if self._discovered_device.device.firmware.need_firmware_upgrade:
|
||||
return self.async_abort(reason="firmware_upgrade_required")
|
||||
|
||||
return self.async_create_entry(
|
||||
title=self.context["title_placeholders"]["name"], data={}
|
||||
title=self.context["title_placeholders"]["name"],
|
||||
data={DEVICE_MODEL: self._discovered_device.device.model.value},
|
||||
)
|
||||
|
||||
self._set_confirm_only()
|
||||
@@ -164,7 +164,10 @@ class AirthingsConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
self._discovered_device = discovery
|
||||
|
||||
return self.async_create_entry(title=discovery.name, data={})
|
||||
return self.async_create_entry(
|
||||
title=discovery.name,
|
||||
data={DEVICE_MODEL: discovery.device.model.value},
|
||||
)
|
||||
|
||||
current_addresses = self._async_current_ids(include_ignore=False)
|
||||
devices: list[BluetoothServiceInfoBleak] = []
|
||||
|
||||
@@ -1,11 +1,16 @@
|
||||
"""Constants for Airthings BLE."""
|
||||
|
||||
from airthings_ble import AirthingsDeviceType
|
||||
|
||||
DOMAIN = "airthings_ble"
|
||||
MFCT_ID = 820
|
||||
|
||||
VOLUME_BECQUEREL = "Bq/m³"
|
||||
VOLUME_PICOCURIE = "pCi/L"
|
||||
|
||||
DEVICE_MODEL = "device_model"
|
||||
|
||||
DEFAULT_SCAN_INTERVAL = 300
|
||||
DEVICE_SPECIFIC_SCAN_INTERVAL = {AirthingsDeviceType.CORENTIUM_HOME_2.value: 1800}
|
||||
|
||||
MAX_RETRIES_AFTER_STARTUP = 5
|
||||
|
||||
@@ -16,7 +16,12 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util.unit_system import METRIC_SYSTEM
|
||||
|
||||
from .const import DEFAULT_SCAN_INTERVAL, DOMAIN
|
||||
from .const import (
|
||||
DEFAULT_SCAN_INTERVAL,
|
||||
DEVICE_MODEL,
|
||||
DEVICE_SPECIFIC_SCAN_INTERVAL,
|
||||
DOMAIN,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -34,12 +39,18 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
|
||||
self.airthings = AirthingsBluetoothDeviceData(
|
||||
_LOGGER, hass.config.units is METRIC_SYSTEM
|
||||
)
|
||||
|
||||
device_model = entry.data.get(DEVICE_MODEL)
|
||||
interval = DEVICE_SPECIFIC_SCAN_INTERVAL.get(
|
||||
device_model, DEFAULT_SCAN_INTERVAL
|
||||
)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=entry,
|
||||
name=DOMAIN,
|
||||
update_interval=timedelta(seconds=DEFAULT_SCAN_INTERVAL),
|
||||
update_interval=timedelta(seconds=interval),
|
||||
)
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
@@ -58,11 +69,29 @@ class AirthingsBLEDataUpdateCoordinator(DataUpdateCoordinator[AirthingsDevice]):
|
||||
)
|
||||
self.ble_device = ble_device
|
||||
|
||||
if DEVICE_MODEL not in self.config_entry.data:
|
||||
_LOGGER.debug("Fetching device info for migration")
|
||||
try:
|
||||
data = await self.airthings.update_device(self.ble_device)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(
|
||||
f"Unable to fetch data for migration: {err}"
|
||||
) from err
|
||||
|
||||
self.hass.config_entries.async_update_entry(
|
||||
self.config_entry,
|
||||
data={**self.config_entry.data, DEVICE_MODEL: data.model.value},
|
||||
)
|
||||
self.update_interval = timedelta(
|
||||
seconds=DEVICE_SPECIFIC_SCAN_INTERVAL.get(
|
||||
data.model.value, DEFAULT_SCAN_INTERVAL
|
||||
)
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> AirthingsDevice:
|
||||
"""Get data from Airthings BLE."""
|
||||
try:
|
||||
data = await self.airthings.update_device(self.ble_device)
|
||||
except Exception as err:
|
||||
raise UpdateFailed(f"Unable to fetch data: {err}") from err
|
||||
|
||||
return data
|
||||
|
||||
@@ -6,8 +6,8 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Final
|
||||
|
||||
from aioamazondevices.api import AmazonDevice
|
||||
from aioamazondevices.const import SENSOR_STATE_OFF
|
||||
from aioamazondevices.const.metadata import SENSOR_STATE_OFF
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
DOMAIN as BINARY_SENSOR_DOMAIN,
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
||||
from aioamazondevices.api import AmazonEchoApi
|
||||
from aioamazondevices.exceptions import (
|
||||
CannotAuthenticate,
|
||||
CannotConnect,
|
||||
CannotRetrieveData,
|
||||
)
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
from aiohttp import ClientSession
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from aioamazondevices.api import AmazonDevice
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Defines a base Alexa Devices entity."""
|
||||
|
||||
from aioamazondevices.api import AmazonDevice
|
||||
from aioamazondevices.const import SPEAKER_GROUP_MODEL
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_MODEL
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==6.5.5"]
|
||||
"requirements": ["aioamazondevices==8.0.1"]
|
||||
}
|
||||
|
||||
@@ -6,8 +6,9 @@ from collections.abc import Awaitable, Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Final
|
||||
|
||||
from aioamazondevices.api import AmazonDevice, AmazonEchoApi
|
||||
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
|
||||
from aioamazondevices.api import AmazonEchoApi
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
|
||||
from homeassistant.components.notify import NotifyEntity, NotifyEntityDescription
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -7,12 +7,12 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Final
|
||||
|
||||
from aioamazondevices.api import AmazonDevice
|
||||
from aioamazondevices.const import (
|
||||
from aioamazondevices.const.schedules import (
|
||||
NOTIFICATION_ALARM,
|
||||
NOTIFICATION_REMINDER,
|
||||
NOTIFICATION_TIMER,
|
||||
)
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
SensorDeviceClass,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Support for services."""
|
||||
|
||||
from aioamazondevices.sounds import SOUNDS_LIST
|
||||
from aioamazondevices.const.sounds import SOUNDS_LIST
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
|
||||
@@ -6,7 +6,7 @@ from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
|
||||
from aioamazondevices.api import AmazonDevice
|
||||
from aioamazondevices.structures import AmazonDevice
|
||||
|
||||
from homeassistant.components.switch import (
|
||||
DOMAIN as SWITCH_DOMAIN,
|
||||
|
||||
@@ -4,7 +4,7 @@ from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from aioamazondevices.const import SPEAKER_GROUP_FAMILY
|
||||
from aioamazondevices.const.devices import SPEAKER_GROUP_FAMILY
|
||||
from aioamazondevices.exceptions import CannotConnect, CannotRetrieveData
|
||||
|
||||
from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN
|
||||
|
||||
@@ -106,7 +106,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
translation_key="daily_rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=2,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
@@ -150,7 +150,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
key=TYPE_LIGHTNING_PER_DAY,
|
||||
translation_key="lightning_strikes_per_day",
|
||||
native_unit_of_measurement="strikes",
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
@@ -182,7 +182,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
translation_key="monthly_rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
@@ -229,7 +229,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
translation_key="weekly_rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
@@ -262,7 +262,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
translation_key="yearly_rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.10.28"]
|
||||
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.11.7"]
|
||||
}
|
||||
|
||||
@@ -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." }
|
||||
]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
"""Virtual integration: Enmax Energy."""
|
||||
@@ -1,6 +0,0 @@
|
||||
{
|
||||
"domain": "enmax",
|
||||
"name": "Enmax Energy",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "opower"
|
||||
}
|
||||
@@ -75,10 +75,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> b
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> bool:
|
||||
"""Unload an esphome config entry."""
|
||||
entry_data = await cleanup_instance(entry)
|
||||
return await hass.config_entries.async_unload_platforms(
|
||||
entry, entry_data.loaded_platforms
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
entry, entry.runtime_data.loaded_platforms
|
||||
)
|
||||
if unload_ok:
|
||||
await cleanup_instance(entry)
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def async_remove_entry(hass: HomeAssistant, entry: ESPHomeConfigEntry) -> None:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -37,6 +37,7 @@ class FoscamDeviceInfo:
|
||||
supports_speak_volume_adjustment: bool
|
||||
supports_pet_adjustment: bool
|
||||
supports_car_adjustment: bool
|
||||
supports_human_adjustment: bool
|
||||
supports_wdr_adjustment: bool
|
||||
supports_hdr_adjustment: bool
|
||||
|
||||
@@ -144,24 +145,32 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
||||
if ret_sw == 0
|
||||
else False
|
||||
)
|
||||
ret_md, mothion_config_val = self.session.get_motion_detect_config()
|
||||
human_adjustment_val = (
|
||||
bool(int(software_capabilities.get("swCapabilities2")) & 128)
|
||||
if ret_sw == 0
|
||||
else False
|
||||
)
|
||||
ret_md, motion_config_val = self.session.get_motion_detect_config()
|
||||
if pet_adjustment_val:
|
||||
is_pet_detection_on_val = (
|
||||
mothion_config_val["petEnable"] == "1" if ret_md == 0 else False
|
||||
motion_config_val.get("petEnable") == "1" if ret_md == 0 else False
|
||||
)
|
||||
else:
|
||||
is_pet_detection_on_val = False
|
||||
|
||||
if car_adjustment_val:
|
||||
is_car_detection_on_val = (
|
||||
mothion_config_val["carEnable"] == "1" if ret_md == 0 else False
|
||||
motion_config_val.get("carEnable") == "1" if ret_md == 0 else False
|
||||
)
|
||||
else:
|
||||
is_car_detection_on_val = False
|
||||
|
||||
is_human_detection_on_val = (
|
||||
mothion_config_val["humanEnable"] == "1" if ret_md == 0 else False
|
||||
)
|
||||
if human_adjustment_val:
|
||||
is_human_detection_on_val = (
|
||||
motion_config_val.get("humanEnable") == "1" if ret_md == 0 else False
|
||||
)
|
||||
else:
|
||||
is_human_detection_on_val = False
|
||||
|
||||
return FoscamDeviceInfo(
|
||||
dev_info=dev_info,
|
||||
@@ -179,6 +188,7 @@ class FoscamCoordinator(DataUpdateCoordinator[FoscamDeviceInfo]):
|
||||
supports_speak_volume_adjustment=supports_speak_volume_adjustment_val,
|
||||
supports_pet_adjustment=pet_adjustment_val,
|
||||
supports_car_adjustment=car_adjustment_val,
|
||||
supports_human_adjustment=human_adjustment_val,
|
||||
supports_hdr_adjustment=supports_hdr_adjustment_val,
|
||||
supports_wdr_adjustment=supports_wdr_adjustment_val,
|
||||
is_open_wdr=is_open_wdr,
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/foscam",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["libpyfoscamcgi"],
|
||||
"requirements": ["libpyfoscamcgi==0.0.8"]
|
||||
"requirements": ["libpyfoscamcgi==0.0.9"]
|
||||
}
|
||||
|
||||
@@ -143,6 +143,7 @@ SWITCH_DESCRIPTIONS: list[FoscamSwitchEntityDescription] = [
|
||||
native_value_fn=lambda data: data.is_human_detection_on,
|
||||
turn_off_fn=lambda session: set_motion_detection(session, "humanEnable", False),
|
||||
turn_on_fn=lambda session: set_motion_detection(session, "humanEnable", True),
|
||||
exists_fn=lambda coordinator: coordinator.data.supports_human_adjustment,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@@ -453,7 +453,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
hass.http.app.router.register_resource(IndexView(repo_path, hass))
|
||||
|
||||
async_register_built_in_panel(hass, "light")
|
||||
async_register_built_in_panel(hass, "safety")
|
||||
async_register_built_in_panel(hass, "security")
|
||||
async_register_built_in_panel(hass, "climate")
|
||||
|
||||
async_register_built_in_panel(hass, "profile")
|
||||
|
||||
@@ -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==20251105.0"]
|
||||
}
|
||||
|
||||
@@ -136,6 +136,21 @@ async def async_setup_entry(
|
||||
new_data[CONF_URL] = url
|
||||
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
||||
|
||||
# Migrate legacy config entries without auth_type field
|
||||
if CONF_AUTH_TYPE not in config:
|
||||
new_data = dict(config_entry.data)
|
||||
# Detect auth type based on which fields are present
|
||||
if CONF_TOKEN in config:
|
||||
new_data[CONF_AUTH_TYPE] = AUTH_API_TOKEN
|
||||
elif CONF_USERNAME in config:
|
||||
new_data[CONF_AUTH_TYPE] = AUTH_PASSWORD
|
||||
else:
|
||||
raise ConfigEntryError(
|
||||
"Unable to determine authentication type from config entry."
|
||||
)
|
||||
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
||||
config = config_entry.data
|
||||
|
||||
# Determine API version
|
||||
if config.get(CONF_AUTH_TYPE) == AUTH_API_TOKEN:
|
||||
api_version = "v1"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -44,7 +44,6 @@ from .const import (
|
||||
EVENT_SUPPORTED_CHANGED,
|
||||
EXTRA_PLACEHOLDERS,
|
||||
ISSUE_KEY_ADDON_BOOT_FAIL,
|
||||
ISSUE_KEY_ADDON_DEPRECATED,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING,
|
||||
ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
@@ -87,7 +86,6 @@ ISSUE_KEYS_FOR_REPAIRS = {
|
||||
"issue_system_disk_lifetime",
|
||||
ISSUE_KEY_SYSTEM_FREE_SPACE,
|
||||
ISSUE_KEY_ADDON_PWNED,
|
||||
ISSUE_KEY_ADDON_DEPRECATED,
|
||||
}
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/holiday",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["holidays==0.83", "babel==2.15.0"]
|
||||
"requirements": ["holidays==0.84", "babel==2.15.0"]
|
||||
}
|
||||
|
||||
@@ -39,6 +39,8 @@ from .const import (
|
||||
NABU_CASA_FIRMWARE_RELEASES_URL,
|
||||
PID,
|
||||
PRODUCT,
|
||||
RADIO_TX_POWER_DBM_BY_COUNTRY,
|
||||
RADIO_TX_POWER_DBM_DEFAULT,
|
||||
SERIAL_NUMBER,
|
||||
VID,
|
||||
)
|
||||
@@ -75,6 +77,7 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
|
||||
context: ConfigFlowContext
|
||||
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR]
|
||||
ZIGBEE_BAUDRATE = 460800
|
||||
|
||||
async def async_step_install_zigbee_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
@@ -102,6 +105,21 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
|
||||
next_step_id="finish_thread_installation",
|
||||
)
|
||||
|
||||
def _extra_zha_hardware_options(self) -> dict[str, Any]:
|
||||
"""Return extra ZHA hardware options."""
|
||||
country = self.hass.config.country
|
||||
|
||||
if country is None:
|
||||
tx_power = RADIO_TX_POWER_DBM_DEFAULT
|
||||
else:
|
||||
tx_power = RADIO_TX_POWER_DBM_BY_COUNTRY.get(
|
||||
country, RADIO_TX_POWER_DBM_DEFAULT
|
||||
)
|
||||
|
||||
return {
|
||||
"tx_power": tx_power,
|
||||
}
|
||||
|
||||
|
||||
class HomeAssistantConnectZBT2ConfigFlow(
|
||||
ZBT2FirmwareMixin,
|
||||
@@ -112,7 +130,6 @@ class HomeAssistantConnectZBT2ConfigFlow(
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
ZIGBEE_BAUDRATE = 460800
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Initialize the config flow."""
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Constants for the Home Assistant Connect ZBT-2 integration."""
|
||||
|
||||
from homeassistant.generated.countries import COUNTRIES
|
||||
|
||||
DOMAIN = "homeassistant_connect_zbt2"
|
||||
|
||||
NABU_CASA_FIRMWARE_RELEASES_URL = (
|
||||
@@ -17,3 +19,59 @@ VID = "vid"
|
||||
DEVICE = "device"
|
||||
|
||||
HARDWARE_NAME = "Home Assistant Connect ZBT-2"
|
||||
|
||||
RADIO_TX_POWER_DBM_DEFAULT = 8
|
||||
RADIO_TX_POWER_DBM_BY_COUNTRY = {
|
||||
# EU Member States
|
||||
"AT": 10,
|
||||
"BE": 10,
|
||||
"BG": 10,
|
||||
"HR": 10,
|
||||
"CY": 10,
|
||||
"CZ": 10,
|
||||
"DK": 10,
|
||||
"EE": 10,
|
||||
"FI": 10,
|
||||
"FR": 10,
|
||||
"DE": 10,
|
||||
"GR": 10,
|
||||
"HU": 10,
|
||||
"IE": 10,
|
||||
"IT": 10,
|
||||
"LV": 10,
|
||||
"LT": 10,
|
||||
"LU": 10,
|
||||
"MT": 10,
|
||||
"NL": 10,
|
||||
"PL": 10,
|
||||
"PT": 10,
|
||||
"RO": 10,
|
||||
"SK": 10,
|
||||
"SI": 10,
|
||||
"ES": 10,
|
||||
"SE": 10,
|
||||
# EEA Members
|
||||
"IS": 10,
|
||||
"LI": 10,
|
||||
"NO": 10,
|
||||
# Standards harmonized with RED or ETSI
|
||||
"CH": 10,
|
||||
"GB": 10,
|
||||
"TR": 10,
|
||||
"AL": 10,
|
||||
"BA": 10,
|
||||
"GE": 10,
|
||||
"MD": 10,
|
||||
"ME": 10,
|
||||
"MK": 10,
|
||||
"RS": 10,
|
||||
"UA": 10,
|
||||
# Other CEPT nations
|
||||
"AD": 10,
|
||||
"AZ": 10,
|
||||
"MC": 10,
|
||||
"SM": 10,
|
||||
"VA": 10,
|
||||
}
|
||||
|
||||
assert set(RADIO_TX_POWER_DBM_BY_COUNTRY) <= COUNTRIES
|
||||
|
||||
@@ -28,7 +28,7 @@ from homeassistant.config_entries import (
|
||||
OptionsFlow,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import AbortFlow, progress_step
|
||||
from homeassistant.data_entry_flow import AbortFlow
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
@@ -97,6 +97,12 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
self.addon_uninstall_task: asyncio.Task | None = None
|
||||
self.firmware_install_task: asyncio.Task[None] | None = None
|
||||
self.installing_firmware_name: str | None = None
|
||||
self._install_otbr_addon_task: asyncio.Task[None] | None = None
|
||||
self._start_otbr_addon_task: asyncio.Task[None] | None = None
|
||||
|
||||
# Progress flow steps cannot abort so we need to store the abort reason and then
|
||||
# re-raise it in a dedicated step
|
||||
self._progress_error: AbortFlow | None = None
|
||||
|
||||
def _get_translation_placeholders(self) -> dict[str, str]:
|
||||
"""Shared translation placeholders."""
|
||||
@@ -106,6 +112,11 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
if self._probed_firmware_info is not None
|
||||
else "unknown"
|
||||
),
|
||||
"firmware_name": (
|
||||
self.installing_firmware_name
|
||||
if self.installing_firmware_name is not None
|
||||
else "unknown"
|
||||
),
|
||||
"model": self._hardware_name,
|
||||
}
|
||||
|
||||
@@ -182,22 +193,22 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
return self.async_show_progress(
|
||||
step_id=step_id,
|
||||
progress_action="install_firmware",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": firmware_name,
|
||||
},
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
progress_task=self.firmware_install_task,
|
||||
)
|
||||
|
||||
try:
|
||||
await self.firmware_install_task
|
||||
except AbortFlow as err:
|
||||
return self.async_show_progress_done(
|
||||
next_step_id=err.reason,
|
||||
)
|
||||
self._progress_error = err
|
||||
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||
except HomeAssistantError:
|
||||
_LOGGER.exception("Failed to flash firmware")
|
||||
return self.async_show_progress_done(next_step_id="firmware_install_failed")
|
||||
self._progress_error = AbortFlow(
|
||||
reason="fw_install_failed",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||
finally:
|
||||
self.firmware_install_task = None
|
||||
|
||||
@@ -241,7 +252,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
_LOGGER.debug("Skipping firmware upgrade due to index download failure")
|
||||
return
|
||||
|
||||
raise AbortFlow(reason="firmware_download_failed") from err
|
||||
raise AbortFlow(
|
||||
reason="fw_download_failed",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
) from err
|
||||
|
||||
if not firmware_install_required:
|
||||
assert self._probed_firmware_info is not None
|
||||
@@ -270,7 +284,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
return
|
||||
|
||||
# Otherwise, fail
|
||||
raise AbortFlow(reason="firmware_download_failed") from err
|
||||
raise AbortFlow(
|
||||
reason="fw_download_failed",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
) from err
|
||||
|
||||
self._probed_firmware_info = await async_flash_silabs_firmware(
|
||||
hass=self.hass,
|
||||
@@ -313,41 +330,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
|
||||
await otbr_manager.async_start_addon_waiting()
|
||||
|
||||
async def async_step_firmware_download_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when firmware download failed."""
|
||||
assert self.installing_firmware_name is not None
|
||||
return self.async_abort(
|
||||
reason="fw_download_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": self.installing_firmware_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_firmware_install_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when firmware install failed."""
|
||||
assert self.installing_firmware_name is not None
|
||||
return self.async_abort(
|
||||
reason="fw_install_failed",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"firmware_name": self.installing_firmware_name,
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_unsupported_firmware(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Abort when unsupported firmware is detected."""
|
||||
return self.async_abort(
|
||||
reason="unsupported_firmware",
|
||||
description_placeholders=self._get_translation_placeholders(),
|
||||
)
|
||||
|
||||
async def async_step_zigbee_installation_type(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -456,6 +438,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
# This step is necessary to prevent `user_input` from being passed through
|
||||
return await self.async_step_continue_zigbee()
|
||||
|
||||
def _extra_zha_hardware_options(self) -> dict[str, Any]:
|
||||
"""Return extra ZHA hardware options."""
|
||||
return {}
|
||||
|
||||
async def async_step_continue_zigbee(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -478,6 +464,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
},
|
||||
"radio_type": "ezsp",
|
||||
"flow_strategy": self._zigbee_flow_strategy,
|
||||
**self._extra_zha_hardware_options(),
|
||||
},
|
||||
)
|
||||
return self._continue_zha_flow(result)
|
||||
@@ -506,16 +493,15 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
"""Install Thread firmware."""
|
||||
raise NotImplementedError
|
||||
|
||||
@progress_step(
|
||||
description_placeholders=lambda self: {
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||
}
|
||||
)
|
||||
async def async_step_install_otbr_addon(
|
||||
async def async_step_progress_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Show progress dialog for installing the OTBR addon."""
|
||||
"""Abort when progress step failed."""
|
||||
assert self._progress_error is not None
|
||||
raise self._progress_error
|
||||
|
||||
async def _async_install_otbr_addon(self) -> None:
|
||||
"""Do the work of installing the OTBR addon."""
|
||||
addon_manager = get_otbr_addon_manager(self.hass)
|
||||
addon_info = await self._async_get_addon_info(addon_manager)
|
||||
|
||||
@@ -533,18 +519,39 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
},
|
||||
) from err
|
||||
|
||||
return await self.async_step_finish_thread_installation()
|
||||
|
||||
@progress_step(
|
||||
description_placeholders=lambda self: {
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||
}
|
||||
)
|
||||
async def async_step_start_otbr_addon(
|
||||
async def async_step_install_otbr_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Configure OTBR to point to the SkyConnect and run the addon."""
|
||||
"""Show progress dialog for installing the OTBR addon."""
|
||||
if self._install_otbr_addon_task is None:
|
||||
self._install_otbr_addon_task = self.hass.async_create_task(
|
||||
self._async_install_otbr_addon(),
|
||||
"Install OTBR addon",
|
||||
)
|
||||
|
||||
if not self._install_otbr_addon_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="install_otbr_addon",
|
||||
progress_action="install_otbr_addon",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||
},
|
||||
progress_task=self._install_otbr_addon_task,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._install_otbr_addon_task
|
||||
except AbortFlow as err:
|
||||
self._progress_error = err
|
||||
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||
finally:
|
||||
self._install_otbr_addon_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="finish_thread_installation")
|
||||
|
||||
async def _async_start_otbr_addon(self) -> None:
|
||||
"""Do the work of starting the OTBR addon."""
|
||||
try:
|
||||
await self._configure_and_start_otbr_addon()
|
||||
except AddonError as err:
|
||||
@@ -557,7 +564,36 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
|
||||
},
|
||||
) from err
|
||||
|
||||
return await self.async_step_pre_confirm_otbr()
|
||||
async def async_step_start_otbr_addon(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Configure OTBR to point to the SkyConnect and run the addon."""
|
||||
if self._start_otbr_addon_task is None:
|
||||
self._start_otbr_addon_task = self.hass.async_create_task(
|
||||
self._async_start_otbr_addon(),
|
||||
"Start OTBR addon",
|
||||
)
|
||||
|
||||
if not self._start_otbr_addon_task.done():
|
||||
return self.async_show_progress(
|
||||
step_id="start_otbr_addon",
|
||||
progress_action="start_otbr_addon",
|
||||
description_placeholders={
|
||||
**self._get_translation_placeholders(),
|
||||
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
|
||||
},
|
||||
progress_task=self._start_otbr_addon_task,
|
||||
)
|
||||
|
||||
try:
|
||||
await self._start_otbr_addon_task
|
||||
except AbortFlow as err:
|
||||
self._progress_error = err
|
||||
return self.async_show_progress_done(next_step_id="progress_failed")
|
||||
finally:
|
||||
self._start_otbr_addon_task = None
|
||||
|
||||
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
|
||||
|
||||
async def async_step_pre_confirm_otbr(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
||||
@@ -38,6 +38,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import Event, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage
|
||||
from homeassistant.helpers.hassio import is_hassio
|
||||
from homeassistant.helpers.http import (
|
||||
KEY_ALLOW_CONFIGURED_CORS,
|
||||
KEY_AUTHENTICATED, # noqa: F401
|
||||
@@ -109,7 +110,7 @@ HTTP_SCHEMA: Final = vol.All(
|
||||
cv.deprecated(CONF_BASE_URL),
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Optional(CONF_SERVER_HOST, default=_DEFAULT_BIND): vol.All(
|
||||
vol.Optional(CONF_SERVER_HOST): vol.All(
|
||||
cv.ensure_list, vol.Length(min=1), [cv.string]
|
||||
),
|
||||
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port,
|
||||
@@ -207,7 +208,17 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
if conf is None:
|
||||
conf = cast(ConfData, HTTP_SCHEMA({}))
|
||||
|
||||
server_host = conf[CONF_SERVER_HOST]
|
||||
if CONF_SERVER_HOST in conf and is_hassio(hass):
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"server_host_may_break_hassio",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.ERROR,
|
||||
translation_key="server_host_may_break_hassio",
|
||||
)
|
||||
|
||||
server_host = conf.get(CONF_SERVER_HOST, _DEFAULT_BIND)
|
||||
server_port = conf[CONF_SERVER_PORT]
|
||||
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
|
||||
ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
{
|
||||
"issues": {
|
||||
"server_host_may_break_hassio": {
|
||||
"description": "The `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and Supervisor, and will be removed in a future release.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.",
|
||||
"title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication"
|
||||
},
|
||||
"ssl_configured_without_configured_urls": {
|
||||
"description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.",
|
||||
"title": "SSL is configured without an external URL or internal URL"
|
||||
|
||||
@@ -299,8 +299,8 @@ def _create_climate_ui(xknx: XKNX, conf: ConfigExtractor, name: str) -> XknxClim
|
||||
group_address_active_state=conf.get_state_and_passive(CONF_GA_ACTIVE),
|
||||
group_address_command_value_state=conf.get_state_and_passive(CONF_GA_VALVE),
|
||||
sync_state=sync_state,
|
||||
min_temp=conf.get(ClimateConf.MIN_TEMP),
|
||||
max_temp=conf.get(ClimateConf.MAX_TEMP),
|
||||
min_temp=conf.get(CONF_TARGET_TEMPERATURE, ClimateConf.MIN_TEMP),
|
||||
max_temp=conf.get(CONF_TARGET_TEMPERATURE, ClimateConf.MAX_TEMP),
|
||||
mode=climate_mode,
|
||||
group_address_fan_speed=conf.get_write(CONF_GA_FAN_SPEED),
|
||||
group_address_fan_speed_state=conf.get_state_and_passive(CONF_GA_FAN_SPEED),
|
||||
@@ -486,7 +486,7 @@ class _KnxClimate(ClimateEntity, _KnxEntityBase):
|
||||
ha_controller_modes.append(self._last_hvac_mode)
|
||||
ha_controller_modes.append(HVACMode.OFF)
|
||||
|
||||
hvac_modes = list(set(filter(None, ha_controller_modes)))
|
||||
hvac_modes = sorted(set(filter(None, ha_controller_modes)))
|
||||
return (
|
||||
hvac_modes
|
||||
if hvac_modes
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.10.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2025.10.26.81530"
|
||||
"knx-frontend==2025.10.31.195356"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -359,7 +359,7 @@ CLIMATE_KNX_SCHEMA = vol.Schema(
|
||||
write=False, state_required=True, valid_dpt="9.001"
|
||||
),
|
||||
vol.Optional(CONF_GA_HUMIDITY_CURRENT): GASelector(
|
||||
write=False, valid_dpt="9.002"
|
||||
write=False, valid_dpt="9.007"
|
||||
),
|
||||
vol.Required(CONF_TARGET_TEMPERATURE): GroupSelect(
|
||||
GroupSelectOption(
|
||||
|
||||
@@ -622,6 +622,7 @@ ENERGY_USAGE_SENSORS: tuple[ThinQEnergySensorEntityDescription, ...] = (
|
||||
usage_period=USAGE_MONTHLY,
|
||||
start_date_fn=lambda today: today,
|
||||
end_date_fn=lambda today: today,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
),
|
||||
ThinQEnergySensorEntityDescription(
|
||||
key="last_month",
|
||||
|
||||
@@ -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(",", ".")
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pylitterbot"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pylitterbot==2024.2.7"]
|
||||
"requirements": ["pylitterbot==2025.0.0"]
|
||||
}
|
||||
|
||||
@@ -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,17 +939,23 @@ 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)
|
||||
# Guard for corrupt restored value
|
||||
restored_value = (
|
||||
self._attr_native_value
|
||||
if isinstance(self._attr_native_value, (int, float))
|
||||
else 0
|
||||
)
|
||||
last_value = (
|
||||
float(cast(str, self._last_value))
|
||||
if self._last_value is not None and self._last_value != STATE_UNKNOWN
|
||||
float(cast(str, restored_value))
|
||||
if self._attr_native_value is not None
|
||||
else 0
|
||||
)
|
||||
|
||||
# force unknown when appliance is not able to report consumption
|
||||
# Force unknown when appliance is not able to report consumption
|
||||
if current_status in (
|
||||
StateStatus.ON,
|
||||
StateStatus.OFF,
|
||||
@@ -963,7 +965,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 +975,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 +984,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
|
||||
|
||||
76
homeassistant/components/neato/__init__.py
Normal file
76
homeassistant/components/neato/__init__.py
Normal file
@@ -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
homeassistant/components/neato/api.py
Normal file
58
homeassistant/components/neato/api.py
Normal 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"
|
||||
28
homeassistant/components/neato/application_credentials.py
Normal file
28
homeassistant/components/neato/application_credentials.py
Normal file
@@ -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
homeassistant/components/neato/button.py
Normal file
44
homeassistant/components/neato/button.py
Normal 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
homeassistant/components/neato/camera.py
Normal file
130
homeassistant/components/neato/camera.py
Normal 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
|
||||
64
homeassistant/components/neato/config_flow.py
Normal file
64
homeassistant/components/neato/config_flow.py
Normal file
@@ -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
homeassistant/components/neato/const.py
Normal file
150
homeassistant/components/neato/const.py
Normal 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
homeassistant/components/neato/entity.py
Normal file
24
homeassistant/components/neato/entity.py
Normal 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
homeassistant/components/neato/hub.py
Normal file
50
homeassistant/components/neato/hub.py
Normal 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
|
||||
7
homeassistant/components/neato/icons.json
Normal file
7
homeassistant/components/neato/icons.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"services": {
|
||||
"custom_cleaning": {
|
||||
"service": "mdi:broom"
|
||||
}
|
||||
}
|
||||
}
|
||||
11
homeassistant/components/neato/manifest.json
Normal file
11
homeassistant/components/neato/manifest.json
Normal file
@@ -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
homeassistant/components/neato/sensor.py
Normal file
81
homeassistant/components/neato/sensor.py
Normal 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
|
||||
32
homeassistant/components/neato/services.yaml
Normal file
32
homeassistant/components/neato/services.yaml
Normal file
@@ -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:
|
||||
73
homeassistant/components/neato/strings.json
Normal file
73
homeassistant/components/neato/strings.json
Normal file
@@ -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
homeassistant/components/neato/switch.py
Normal file
118
homeassistant/components/neato/switch.py
Normal 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
homeassistant/components/neato/vacuum.py
Normal file
388
homeassistant/components/neato/vacuum.py
Normal 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.16"]
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.climate import (
|
||||
@@ -13,18 +14,44 @@ from homeassistant.components.climate import (
|
||||
HVACAction,
|
||||
HVACMode,
|
||||
)
|
||||
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
|
||||
from homeassistant.const import ATTR_TEMPERATURE, STATE_OFF, STATE_ON, UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import ExtraStoredData, RestoreEntity
|
||||
|
||||
from .const import DOMAIN, MASTER_THERMOSTATS
|
||||
from .coordinator import PlugwiseConfigEntry, PlugwiseDataUpdateCoordinator
|
||||
from .entity import PlugwiseEntity
|
||||
from .util import plugwise_command
|
||||
|
||||
ERROR_NO_SCHEDULE = "set_schedule_first"
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class PlugwiseClimateExtraStoredData(ExtraStoredData):
|
||||
"""Object to hold extra stored data."""
|
||||
|
||||
last_active_schedule: str | None
|
||||
previous_action_mode: str | None
|
||||
|
||||
def as_dict(self) -> dict[str, Any]:
|
||||
"""Return a dict representation of the text data."""
|
||||
return {
|
||||
"last_active_schedule": self.last_active_schedule,
|
||||
"previous_action_mode": self.previous_action_mode,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, restored: dict[str, Any]) -> PlugwiseClimateExtraStoredData:
|
||||
"""Initialize a stored data object from a dict."""
|
||||
return cls(
|
||||
last_active_schedule=restored.get("last_active_schedule"),
|
||||
previous_action_mode=restored.get("previous_action_mode"),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: PlugwiseConfigEntry,
|
||||
@@ -56,14 +83,26 @@ async def async_setup_entry(
|
||||
entry.async_on_unload(coordinator.async_add_listener(_add_entities))
|
||||
|
||||
|
||||
class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
|
||||
class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity, RestoreEntity):
|
||||
"""Representation of a Plugwise thermostat."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_temperature_unit = UnitOfTemperature.CELSIUS
|
||||
_attr_translation_key = DOMAIN
|
||||
|
||||
_previous_mode: str = "heating"
|
||||
_last_active_schedule: str | None = None
|
||||
_previous_action_mode: str | None = HVACAction.HEATING.value
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Run when entity about to be added."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
if extra_data := await self.async_get_last_extra_data():
|
||||
plugwise_extra_data = PlugwiseClimateExtraStoredData.from_dict(
|
||||
extra_data.as_dict()
|
||||
)
|
||||
self._last_active_schedule = plugwise_extra_data.last_active_schedule
|
||||
self._previous_action_mode = plugwise_extra_data.previous_action_mode
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -76,7 +115,6 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
|
||||
|
||||
gateway_id: str = coordinator.api.gateway_id
|
||||
self._gateway_data = coordinator.data[gateway_id]
|
||||
|
||||
self._location = device_id
|
||||
if (location := self.device.get("location")) is not None:
|
||||
self._location = location
|
||||
@@ -105,25 +143,19 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
|
||||
self.device["thermostat"]["resolution"], 0.1
|
||||
)
|
||||
|
||||
def _previous_action_mode(self, coordinator: PlugwiseDataUpdateCoordinator) -> None:
|
||||
"""Return the previous action-mode when the regulation-mode is not heating or cooling.
|
||||
|
||||
Helper for set_hvac_mode().
|
||||
"""
|
||||
# When no cooling available, _previous_mode is always heating
|
||||
if (
|
||||
"regulation_modes" in self._gateway_data
|
||||
and "cooling" in self._gateway_data["regulation_modes"]
|
||||
):
|
||||
mode = self._gateway_data["select_regulation_mode"]
|
||||
if mode in ("cooling", "heating"):
|
||||
self._previous_mode = mode
|
||||
|
||||
@property
|
||||
def current_temperature(self) -> float:
|
||||
"""Return the current temperature."""
|
||||
return self.device["sensors"]["temperature"]
|
||||
|
||||
@property
|
||||
def extra_restore_state_data(self) -> PlugwiseClimateExtraStoredData:
|
||||
"""Return text specific state data to be restored."""
|
||||
return PlugwiseClimateExtraStoredData(
|
||||
last_active_schedule=self._last_active_schedule,
|
||||
previous_action_mode=self._previous_action_mode,
|
||||
)
|
||||
|
||||
@property
|
||||
def target_temperature(self) -> float:
|
||||
"""Return the temperature we try to reach.
|
||||
@@ -170,9 +202,10 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
|
||||
|
||||
if self.coordinator.api.cooling_present:
|
||||
if "regulation_modes" in self._gateway_data:
|
||||
if self._gateway_data["select_regulation_mode"] == "cooling":
|
||||
selected = self._gateway_data.get("select_regulation_mode")
|
||||
if selected == HVACAction.COOLING.value:
|
||||
hvac_modes.append(HVACMode.COOL)
|
||||
if self._gateway_data["select_regulation_mode"] == "heating":
|
||||
if selected == HVACAction.HEATING.value:
|
||||
hvac_modes.append(HVACMode.HEAT)
|
||||
else:
|
||||
hvac_modes.append(HVACMode.HEAT_COOL)
|
||||
@@ -184,8 +217,16 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
|
||||
@property
|
||||
def hvac_action(self) -> HVACAction:
|
||||
"""Return the current running hvac operation if supported."""
|
||||
# Keep track of the previous action-mode
|
||||
self._previous_action_mode(self.coordinator)
|
||||
# Keep track of the previous hvac_action mode.
|
||||
# When no cooling available, _previous_action_mode is always heating
|
||||
if (
|
||||
"regulation_modes" in self._gateway_data
|
||||
and HVACAction.COOLING.value in self._gateway_data["regulation_modes"]
|
||||
):
|
||||
mode = self._gateway_data["select_regulation_mode"]
|
||||
if mode in (HVACAction.COOLING.value, HVACAction.HEATING.value):
|
||||
self._previous_action_mode = mode
|
||||
|
||||
if (action := self.device.get("control_state")) is not None:
|
||||
return HVACAction(action)
|
||||
|
||||
@@ -219,14 +260,33 @@ class PlugwiseClimateEntity(PlugwiseEntity, ClimateEntity):
|
||||
return
|
||||
|
||||
if hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.api.set_regulation_mode(hvac_mode)
|
||||
await self.coordinator.api.set_regulation_mode(hvac_mode.value)
|
||||
else:
|
||||
current = self.device.get("select_schedule")
|
||||
desired = current
|
||||
|
||||
# Capture the last valid schedule
|
||||
if desired and desired != "off":
|
||||
self._last_active_schedule = desired
|
||||
elif desired == "off":
|
||||
desired = self._last_active_schedule
|
||||
|
||||
# Enabling HVACMode.AUTO requires a previously set schedule for saving and restoring
|
||||
if hvac_mode == HVACMode.AUTO and not desired:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key=ERROR_NO_SCHEDULE,
|
||||
)
|
||||
|
||||
await self.coordinator.api.set_schedule_state(
|
||||
self._location,
|
||||
"on" if hvac_mode == HVACMode.AUTO else "off",
|
||||
STATE_ON if hvac_mode == HVACMode.AUTO else STATE_OFF,
|
||||
desired,
|
||||
)
|
||||
if self.hvac_mode == HVACMode.OFF:
|
||||
await self.coordinator.api.set_regulation_mode(self._previous_mode)
|
||||
if self.hvac_mode == HVACMode.OFF and self._previous_action_mode:
|
||||
await self.coordinator.api.set_regulation_mode(
|
||||
self._previous_action_mode
|
||||
)
|
||||
|
||||
@plugwise_command
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["plugwise"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["plugwise==1.8.2"],
|
||||
"requirements": ["plugwise==1.8.3"],
|
||||
"zeroconf": ["_plugwise._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -314,6 +314,9 @@
|
||||
"invalid_xml_data": {
|
||||
"message": "[%key:component::plugwise::config::error::response_error%]"
|
||||
},
|
||||
"set_schedule_first": {
|
||||
"message": "Failed setting HVACMode, set a schedule first."
|
||||
},
|
||||
"unsupported_firmware": {
|
||||
"message": "[%key:component::plugwise::config::error::unsupported%]"
|
||||
}
|
||||
|
||||
@@ -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.4"]
|
||||
}
|
||||
|
||||
@@ -835,6 +835,7 @@
|
||||
"vehicle_type": {
|
||||
"name": "Vehicle type",
|
||||
"state": {
|
||||
"bus": "Bus",
|
||||
"motorcycle": "Motorcycle",
|
||||
"pickup_truck": "Pickup truck",
|
||||
"sedan": "Sedan",
|
||||
|
||||
@@ -16,5 +16,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ruuvitag_ble",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["ruuvitag-ble==0.2.1"]
|
||||
"requirements": ["ruuvitag-ble==0.3.0"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/smarttub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["smarttub"],
|
||||
"requirements": ["python-smarttub==0.0.44"]
|
||||
"requirements": ["python-smarttub==0.0.45"]
|
||||
}
|
||||
|
||||
@@ -57,4 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: SolarEdgeConfigEntry) ->
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: SolarEdgeConfigEntry) -> bool:
|
||||
"""Unload SolarEdge config entry."""
|
||||
if DATA_API_CLIENT not in entry.runtime_data:
|
||||
return True # Nothing to unload
|
||||
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
@@ -133,8 +133,11 @@ class SolarEdgeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if api_key_ok and web_login_ok:
|
||||
data = {CONF_SITE_ID: site_id}
|
||||
data.update(api_auth)
|
||||
data.update(web_auth)
|
||||
if api_key:
|
||||
data[CONF_API_KEY] = api_key
|
||||
if username:
|
||||
data[CONF_USERNAME] = username
|
||||
data[CONF_PASSWORD] = web_auth[CONF_PASSWORD]
|
||||
|
||||
if self.source == SOURCE_RECONFIGURE:
|
||||
if TYPE_CHECKING:
|
||||
|
||||
@@ -4,11 +4,13 @@ import logging
|
||||
|
||||
from libsoundtouch import soundtouch_device
|
||||
from libsoundtouch.device import SoundTouchDevice
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_HOST, Platform
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
@@ -130,7 +132,14 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Bose SoundTouch from a config entry."""
|
||||
device = await hass.async_add_executor_job(soundtouch_device, entry.data[CONF_HOST])
|
||||
try:
|
||||
device = await hass.async_add_executor_job(
|
||||
soundtouch_device, entry.data[CONF_HOST]
|
||||
)
|
||||
except requests.exceptions.ConnectionError as err:
|
||||
raise ConfigEntryNotReady(
|
||||
f"Unable to connect to SoundTouch device at {entry.data[CONF_HOST]}"
|
||||
) from err
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SoundTouchData(device)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""The DALI Center integration."""
|
||||
"""The Sunricher DALI integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -28,7 +28,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool:
|
||||
"""Set up DALI Center from a config entry."""
|
||||
"""Set up Sunricher DALI from a config entry."""
|
||||
|
||||
gateway = DaliGateway(
|
||||
entry.data[CONF_SERIAL_NUMBER],
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Config flow for the DALI Center integration."""
|
||||
"""Config flow for the Sunricher DALI integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DaliCenterConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for DALI Center."""
|
||||
"""Handle a config flow for Sunricher DALI."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
5
homeassistant/components/sunricher_dali/const.py
Normal file
5
homeassistant/components/sunricher_dali/const.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Constants for the Sunricher DALI integration."""
|
||||
|
||||
DOMAIN = "sunricher_dali"
|
||||
MANUFACTURER = "Sunricher"
|
||||
CONF_SERIAL_NUMBER = "serial_number"
|
||||
@@ -38,7 +38,7 @@ async def async_setup_entry(
|
||||
entry: DaliCenterConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up DALI Center light entities from config entry."""
|
||||
"""Set up Sunricher DALI light entities from config entry."""
|
||||
runtime_data = entry.runtime_data
|
||||
gateway = runtime_data.gateway
|
||||
devices = runtime_data.devices
|
||||
@@ -57,7 +57,7 @@ async def async_setup_entry(
|
||||
|
||||
|
||||
class DaliCenterLight(LightEntity):
|
||||
"""Representation of a DALI Center Light."""
|
||||
"""Representation of a Sunricher DALI Light."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"domain": "sunricher_dali_center",
|
||||
"name": "DALI Center",
|
||||
"domain": "sunricher_dali",
|
||||
"name": "Sunricher DALI",
|
||||
"codeowners": ["@niracler"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali_center",
|
||||
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["PySrDaliGateway==0.13.1"]
|
||||
@@ -5,8 +5,8 @@
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"discovery_failed": "Failed to discover DALI gateways on the network",
|
||||
"no_devices_found": "No DALI gateways found on the network",
|
||||
"discovery_failed": "Failed to discover Sunricher DALI gateways on the network",
|
||||
"no_devices_found": "No Sunricher DALI gateways found on the network",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
@@ -17,12 +17,10 @@
|
||||
"data_description": {
|
||||
"selected_gateway": "Each option shows the gateway name, serial number, and IP address."
|
||||
},
|
||||
"description": "Select the gateway to configure.",
|
||||
"title": "Select DALI gateway"
|
||||
"description": "Select the gateway to configure."
|
||||
},
|
||||
"user": {
|
||||
"description": "**Three-step process:**\n\n1. Ensure the gateway is powered and on the same network.\n2. Select **Submit** to start discovery (searches for up to 3 minutes)\n3. While discovery is in progress, press the **Reset** button on your DALI gateway device **once**.\n\nThe gateway will respond immediately after the button press.",
|
||||
"title": "Set up DALI Center gateway"
|
||||
"description": "**Three-step process:**\n\n1. Ensure the gateway is powered and on the same network.\n2. Select **Submit** to start discovery (searches for up to 3 minutes)\n3. While discovery is in progress, press the **Reset** button on your Sunricher DALI gateway device **once**.\n\nThe gateway will respond immediately after the button press."
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Type definitions for the DALI Center integration."""
|
||||
"""Type definitions for the Sunricher DALI integration."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
@@ -9,7 +9,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
|
||||
@dataclass
|
||||
class DaliCenterData:
|
||||
"""Runtime data for the DALI Center integration."""
|
||||
"""Runtime data for the Sunricher DALI integration."""
|
||||
|
||||
gateway: DaliGateway
|
||||
devices: list[Device]
|
||||
@@ -1,5 +0,0 @@
|
||||
"""Constants for the DALI Center integration."""
|
||||
|
||||
DOMAIN = "sunricher_dali_center"
|
||||
MANUFACTURER = "Sunricher"
|
||||
CONF_SERIAL_NUMBER = "serial_number"
|
||||
@@ -24,7 +24,8 @@ from homeassistant.components.telegram_bot import (
|
||||
)
|
||||
from homeassistant.const import ATTR_LOCATION
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.reload import setup_reload_service
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.reload import async_setup_reload_service
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN, PLATFORMS
|
||||
@@ -45,14 +46,25 @@ PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
def get_service(
|
||||
async def async_get_service(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> TelegramNotificationService:
|
||||
"""Get the Telegram notification service."""
|
||||
|
||||
setup_reload_service(hass, DOMAIN, PLATFORMS)
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
"migrate_notify",
|
||||
breaks_in_ha_version="2026.5.0",
|
||||
is_fixable=False,
|
||||
translation_key="migrate_notify",
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/telegram_bot#notifiers",
|
||||
)
|
||||
|
||||
await async_setup_reload_service(hass, DOMAIN, PLATFORMS)
|
||||
chat_id = config.get(CONF_CHAT_ID)
|
||||
return TelegramNotificationService(hass, chat_id)
|
||||
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"issues": {
|
||||
"migrate_notify": {
|
||||
"description": "The Telegram `notify` service has been migrated. A new `notify` entity per chat ID is available now.\n\nUpdate all affected automations to use the new `notify.send_message` action exposed by these new entities and then restart Home Assistant.",
|
||||
"title": "Migration of Telegram notify service"
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"reload": {
|
||||
"description": "Reloads telegram notify services.",
|
||||
|
||||
@@ -108,8 +108,8 @@ from .const import (
|
||||
SERVICE_SEND_STICKER,
|
||||
SERVICE_SEND_VIDEO,
|
||||
SERVICE_SEND_VOICE,
|
||||
SIGNAL_UPDATE_EVENT,
|
||||
)
|
||||
from .helpers import signal
|
||||
|
||||
_FILE_TYPES = ("animation", "document", "photo", "sticker", "video", "voice")
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -169,7 +169,7 @@ class BaseTelegramBot:
|
||||
|
||||
_LOGGER.debug("Firing event %s: %s", event_type, event_data)
|
||||
self.hass.bus.async_fire(event_type, event_data, context=event_context)
|
||||
async_dispatcher_send(self.hass, SIGNAL_UPDATE_EVENT, event_type, event_data)
|
||||
async_dispatcher_send(self.hass, signal(self._bot), event_type, event_data)
|
||||
return True
|
||||
|
||||
@staticmethod
|
||||
@@ -551,7 +551,7 @@ class TelegramNotificationService:
|
||||
EVENT_TELEGRAM_SENT, event_data, context=context
|
||||
)
|
||||
async_dispatcher_send(
|
||||
self.hass, SIGNAL_UPDATE_EVENT, EVENT_TELEGRAM_SENT, event_data
|
||||
self.hass, signal(self.bot), EVENT_TELEGRAM_SENT, event_data
|
||||
)
|
||||
except TelegramError as exc:
|
||||
if not suppress_error:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user