mirror of
https://github.com/home-assistant/core.git
synced 2025-11-05 00:49:37 +00:00
Compare commits
1 Commits
rc
...
default_vi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3913039490 |
@@ -361,7 +361,6 @@ homeassistant.components.myuplink.*
|
||||
homeassistant.components.nam.*
|
||||
homeassistant.components.nanoleaf.*
|
||||
homeassistant.components.nasweb.*
|
||||
homeassistant.components.neato.*
|
||||
homeassistant.components.nest.*
|
||||
homeassistant.components.netatmo.*
|
||||
homeassistant.components.network.*
|
||||
|
||||
2
Dockerfile
generated
2
Dockerfile
generated
@@ -31,7 +31,7 @@ RUN \
|
||||
&& go2rtc --version
|
||||
|
||||
# Install uv
|
||||
RUN pip3 install uv==0.9.6
|
||||
RUN pip3 install uv==0.9.5
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioamazondevices"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioamazondevices==6.5.6"]
|
||||
"requirements": ["aioamazondevices==6.5.5"]
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ SENSOR_DESCRIPTIONS = (
|
||||
translation_key="daily_rain",
|
||||
native_unit_of_measurement=UnitOfPrecipitationDepth.INCHES,
|
||||
device_class=SensorDeviceClass.PRECIPITATION,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
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_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
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_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
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_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
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_INCREASING,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
suggested_display_precision=2,
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/assist_satellite",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.4.0"]
|
||||
"requirements": ["hassil==3.3.0"]
|
||||
}
|
||||
|
||||
@@ -189,7 +189,7 @@ class BryantEvolutionClimate(ClimateEntity):
|
||||
return HVACAction.HEATING
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_parse_hvac_action",
|
||||
translation_key="failed_to_parse_hvac_mode",
|
||||
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}, {current_temperature}, {target_temperature_low}"
|
||||
"message": "Could not determine HVAC action: {mode_and_active}, {self.current_temperature}, {self.target_temperature_low}"
|
||||
},
|
||||
"failed_to_parse_hvac_mode": {
|
||||
"message": "Cannot parse response to HVACMode: {mode}"
|
||||
|
||||
@@ -13,6 +13,6 @@
|
||||
"integration_type": "system",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["acme", "hass_nabucasa", "snitun"],
|
||||
"requirements": ["hass-nabucasa==1.5.1"],
|
||||
"requirements": ["hass-nabucasa==1.4.0"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -768,16 +768,7 @@ class DefaultAgent(ConversationEntity):
|
||||
if lang_intents.fuzzy_matcher is None:
|
||||
return None
|
||||
|
||||
context_area: str | None = None
|
||||
satellite_area, _ = self._get_satellite_area_and_device(
|
||||
user_input.satellite_id, user_input.device_id
|
||||
)
|
||||
if satellite_area:
|
||||
context_area = satellite_area.name
|
||||
|
||||
fuzzy_result = lang_intents.fuzzy_matcher.match(
|
||||
user_input.text, context_area=context_area
|
||||
)
|
||||
fuzzy_result = lang_intents.fuzzy_matcher.match(user_input.text)
|
||||
if fuzzy_result is None:
|
||||
return None
|
||||
|
||||
@@ -1249,14 +1240,15 @@ class DefaultAgent(ConversationEntity):
|
||||
intent_slot_list_names=self._fuzzy_config.slot_list_names,
|
||||
slot_combinations={
|
||||
intent_name: {
|
||||
combo_key: SlotCombinationInfo(
|
||||
context_area=combo_info.context_area,
|
||||
name_domains=(
|
||||
set(combo_info.name_domains)
|
||||
if combo_info.name_domains
|
||||
else None
|
||||
),
|
||||
)
|
||||
combo_key: [
|
||||
SlotCombinationInfo(
|
||||
name_domains=(
|
||||
set(combo_info.name_domains)
|
||||
if combo_info.name_domains
|
||||
else None
|
||||
)
|
||||
)
|
||||
]
|
||||
for combo_key, combo_info in intent_combos.items()
|
||||
}
|
||||
for intent_name, intent_combos in self._fuzzy_config.slot_combinations.items()
|
||||
|
||||
@@ -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.3.0", "home-assistant-intents==2025.10.28"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pycync==0.4.3"]
|
||||
"requirements": ["pycync==0.4.2"]
|
||||
}
|
||||
|
||||
@@ -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.3.0"]
|
||||
"requirements": ["py-sucks==0.9.11", "deebot-client==16.1.0"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eheimdigital"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["eheimdigital==1.4.0"],
|
||||
"requirements": ["eheimdigital==1.3.0"],
|
||||
"zeroconf": [
|
||||
{ "name": "eheimdigital._http._tcp.local.", "type": "_http._tcp.local." }
|
||||
]
|
||||
|
||||
1
homeassistant/components/enmax/__init__.py
Normal file
1
homeassistant/components/enmax/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Virtual integration: Enmax Energy."""
|
||||
6
homeassistant/components/enmax/manifest.json
Normal file
6
homeassistant/components/enmax/manifest.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"domain": "enmax",
|
||||
"name": "Enmax Energy",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "opower"
|
||||
}
|
||||
@@ -40,9 +40,7 @@ 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=hass, verify_ssl=data[CONF_VERIFY_SSL]
|
||||
),
|
||||
session=async_get_clientsession(hass),
|
||||
)
|
||||
await client.get_about()
|
||||
except FireflyAuthenticationError:
|
||||
|
||||
@@ -263,6 +263,9 @@ class Panel:
|
||||
# Title to show in the sidebar
|
||||
sidebar_title: str | None = None
|
||||
|
||||
# If the panel should be visible by default in the sidebar
|
||||
sidebar_default_visible: bool = True
|
||||
|
||||
# Url to show the panel in the frontend
|
||||
frontend_url_path: str
|
||||
|
||||
@@ -280,6 +283,7 @@ class Panel:
|
||||
component_name: str,
|
||||
sidebar_title: str | None,
|
||||
sidebar_icon: str | None,
|
||||
sidebar_default_visible: bool,
|
||||
frontend_url_path: str | None,
|
||||
config: dict[str, Any] | None,
|
||||
require_admin: bool,
|
||||
@@ -293,6 +297,7 @@ class Panel:
|
||||
self.config = config
|
||||
self.require_admin = require_admin
|
||||
self.config_panel_domain = config_panel_domain
|
||||
self.sidebar_default_visible = sidebar_default_visible
|
||||
|
||||
@callback
|
||||
def to_response(self) -> PanelResponse:
|
||||
@@ -301,6 +306,7 @@ class Panel:
|
||||
"component_name": self.component_name,
|
||||
"icon": self.sidebar_icon,
|
||||
"title": self.sidebar_title,
|
||||
"default_visible": self.sidebar_default_visible,
|
||||
"config": self.config,
|
||||
"url_path": self.frontend_url_path,
|
||||
"require_admin": self.require_admin,
|
||||
@@ -315,6 +321,7 @@ def async_register_built_in_panel(
|
||||
component_name: str,
|
||||
sidebar_title: str | None = None,
|
||||
sidebar_icon: str | None = None,
|
||||
sidebar_default_visible: bool = True,
|
||||
frontend_url_path: str | None = None,
|
||||
config: dict[str, Any] | None = None,
|
||||
require_admin: bool = False,
|
||||
@@ -327,6 +334,7 @@ def async_register_built_in_panel(
|
||||
component_name,
|
||||
sidebar_title,
|
||||
sidebar_icon,
|
||||
sidebar_default_visible,
|
||||
frontend_url_path,
|
||||
config,
|
||||
require_admin,
|
||||
@@ -453,7 +461,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, "security")
|
||||
async_register_built_in_panel(hass, "safety")
|
||||
async_register_built_in_panel(hass, "climate")
|
||||
|
||||
async_register_built_in_panel(hass, "profile")
|
||||
@@ -879,6 +887,7 @@ class PanelResponse(TypedDict):
|
||||
component_name: str
|
||||
icon: str | None
|
||||
title: str | None
|
||||
default_visible: bool
|
||||
config: dict[str, Any] | None
|
||||
url_path: str
|
||||
require_admin: bool
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20251104.0"]
|
||||
"requirements": ["home-assistant-frontend==20251001.4"]
|
||||
}
|
||||
|
||||
@@ -620,11 +620,7 @@ 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)
|
||||
|
||||
# Unload coordinator
|
||||
coordinator: HassioDataUpdateCoordinator = hass.data[ADDONS_COORDINATOR]
|
||||
coordinator.unload()
|
||||
|
||||
# Pop coordinator
|
||||
# Pop add-on data
|
||||
hass.data.pop(ADDONS_COORDINATOR, None)
|
||||
|
||||
return unload_ok
|
||||
|
||||
@@ -563,8 +563,3 @@ 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,6 +44,7 @@ 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,
|
||||
@@ -86,6 +87,7 @@ 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,7 +3,6 @@
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import partial
|
||||
import logging
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
@@ -30,8 +29,6 @@ from .const import (
|
||||
)
|
||||
from .handler import get_supervisor_client
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass(slots=True, frozen=True)
|
||||
class JobSubscription:
|
||||
@@ -48,7 +45,7 @@ class JobSubscription:
|
||||
event_callback: Callable[[Job], Any]
|
||||
uuid: str | None = None
|
||||
name: str | None = None
|
||||
reference: str | None = None
|
||||
reference: str | None | type[Any] = Any
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate at least one filter option is present."""
|
||||
@@ -61,7 +58,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 (None, job.reference)
|
||||
return job.name == self.name and self.reference in (Any, job.reference)
|
||||
|
||||
|
||||
class SupervisorJobs:
|
||||
@@ -73,7 +70,6 @@ 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]:
|
||||
@@ -83,24 +79,20 @@ 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, runs the
|
||||
callback on them.
|
||||
If any jobs match the subscription at the time this is called, creates
|
||||
tasks to run their callback on it.
|
||||
"""
|
||||
self._subscriptions.add(subscription)
|
||||
|
||||
# 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,
|
||||
)
|
||||
# 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))
|
||||
|
||||
return partial(self._subscriptions.discard, subscription)
|
||||
|
||||
@@ -139,7 +131,7 @@ class SupervisorJobs:
|
||||
|
||||
# If this is the first update register to receive Supervisor events
|
||||
if first_update:
|
||||
self._dispatcher_disconnect = async_dispatcher_connect(
|
||||
async_dispatcher_connect(
|
||||
self._hass, EVENT_SUPERVISOR_EVENT, self._supervisor_events_to_jobs
|
||||
)
|
||||
|
||||
@@ -166,14 +158,3 @@ 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
|
||||
|
||||
@@ -75,7 +75,6 @@ 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
|
||||
@@ -113,6 +112,7 @@ class HomeAssistantConnectZBT2ConfigFlow(
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
ZIGBEE_BAUDRATE = 460800
|
||||
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
"""Initialize the config flow."""
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Base class for IOmeter entities."""
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
@@ -22,5 +21,4 @@ class IOmeterEntity(CoordinatorEntity[IOMeterCoordinator]):
|
||||
manufacturer="IOmeter GmbH",
|
||||
model="IOmeter",
|
||||
sw_version=coordinator.current_fw_version,
|
||||
configuration_url=f"http://{coordinator.config_entry.data[CONF_HOST]}/",
|
||||
)
|
||||
|
||||
@@ -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(CONF_TARGET_TEMPERATURE, ClimateConf.MIN_TEMP),
|
||||
max_temp=conf.get(CONF_TARGET_TEMPERATURE, ClimateConf.MAX_TEMP),
|
||||
min_temp=conf.get(ClimateConf.MIN_TEMP),
|
||||
max_temp=conf.get(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 = sorted(set(filter(None, ha_controller_modes)))
|
||||
hvac_modes = list(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.31.195356"
|
||||
"knx-frontend==2025.10.26.81530"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/libre_hardware_monitor",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["librehardwaremonitor-api==1.5.0"]
|
||||
"requirements": ["librehardwaremonitor-api==1.4.0"]
|
||||
}
|
||||
|
||||
@@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from librehardwaremonitor_api.model import LibreHardwareMonitorSensorData
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorStateClass
|
||||
@@ -53,10 +51,10 @@ class LibreHardwareMonitorSensor(
|
||||
super().__init__(coordinator)
|
||||
|
||||
self._attr_name: str = sensor_data.name
|
||||
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.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_unit_of_measurement = sensor_data.unit
|
||||
self._attr_unique_id: str = f"{entry_id}_{sensor_data.sensor_id}"
|
||||
@@ -74,12 +72,23 @@ 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._attr_native_value = sensor_data.value
|
||||
self.value = sensor_data.value
|
||||
self._attr_extra_state_attributes = {
|
||||
STATE_MIN_VALUE: sensor_data.min,
|
||||
STATE_MAX_VALUE: sensor_data.max,
|
||||
STATE_MIN_VALUE: self._format_number_value(sensor_data.min),
|
||||
STATE_MAX_VALUE: self._format_number_value(sensor_data.max),
|
||||
}
|
||||
else:
|
||||
self._attr_native_value = None
|
||||
self.value = None
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the formatted sensor value or None if no value is available."""
|
||||
if self.value is not None and self.value != "-":
|
||||
return self._format_number_value(self.value)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _format_number_value(number_str: str) -> str:
|
||||
return number_str.replace(",", ".")
|
||||
|
||||
@@ -19,6 +19,7 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
REVOLUTIONS_PER_MINUTE,
|
||||
STATE_UNKNOWN,
|
||||
EntityCategory,
|
||||
UnitOfEnergy,
|
||||
UnitOfTemperature,
|
||||
@@ -761,35 +762,40 @@ class MieleSensor(MieleEntity, SensorEntity):
|
||||
class MieleRestorableSensor(MieleSensor, RestoreSensor):
|
||||
"""Representation of a Sensor whose internal state can be restored."""
|
||||
|
||||
_attr_native_value: StateType
|
||||
_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
|
||||
|
||||
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_data = await self.async_get_last_sensor_data()
|
||||
if last_data:
|
||||
self._attr_native_value = last_data.native_value # type: ignore[assignment]
|
||||
last_value = await self.async_get_last_state()
|
||||
if last_value and last_value.state != STATE_UNKNOWN:
|
||||
self._last_value = last_value.state
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
"""Return the state of the sensor.
|
||||
"""Return the state of the sensor."""
|
||||
return self._last_value
|
||||
|
||||
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)
|
||||
def _update_last_value(self) -> None:
|
||||
"""Update the last value of the sensor."""
|
||||
self._last_value = self.entity_description.value_fn(self.device)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
self._update_native_value()
|
||||
self._update_last_value()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
|
||||
@@ -906,7 +912,7 @@ class MieleProgramIdSensor(MieleSensor):
|
||||
class MieleTimeSensor(MieleRestorableSensor):
|
||||
"""Representation of time sensors keeping state from cache."""
|
||||
|
||||
def _update_native_value(self) -> None:
|
||||
def _update_last_value(self) -> None:
|
||||
"""Update the last value of the sensor."""
|
||||
|
||||
current_value = self.entity_description.value_fn(self.device)
|
||||
@@ -917,9 +923,7 @@ class MieleTimeSensor(MieleRestorableSensor):
|
||||
current_status == StateStatus.PROGRAM_ENDED
|
||||
and self.entity_description.end_value_fn is not None
|
||||
):
|
||||
self._attr_native_value = self.entity_description.end_value_fn(
|
||||
self._attr_native_value
|
||||
)
|
||||
self._last_value = self.entity_description.end_value_fn(self._last_value)
|
||||
|
||||
# keep value when program ends if no function is specified
|
||||
elif current_status == StateStatus.PROGRAM_ENDED:
|
||||
@@ -927,11 +931,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._attr_native_value = None
|
||||
self._last_value = None
|
||||
|
||||
# otherwise, cache value and return it
|
||||
else:
|
||||
self._attr_native_value = current_value
|
||||
self._last_value = current_value
|
||||
|
||||
|
||||
class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
@@ -939,13 +943,13 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
|
||||
_is_reporting: bool = False
|
||||
|
||||
def _update_native_value(self) -> None:
|
||||
def _update_last_value(self) -> None:
|
||||
"""Update the last value of the sensor."""
|
||||
current_value = self.entity_description.value_fn(self.device)
|
||||
current_status = StateStatus(self.device.state_status)
|
||||
last_value = (
|
||||
float(cast(str, self._attr_native_value))
|
||||
if self._attr_native_value is not None
|
||||
float(cast(str, self._last_value))
|
||||
if self._last_value is not None and self._last_value != STATE_UNKNOWN
|
||||
else 0
|
||||
)
|
||||
|
||||
@@ -959,7 +963,7 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
StateStatus.SERVICE,
|
||||
):
|
||||
self._is_reporting = False
|
||||
self._attr_native_value = None
|
||||
self._last_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
|
||||
@@ -969,7 +973,7 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
and not self._is_reporting
|
||||
and last_value > 0
|
||||
):
|
||||
self._attr_native_value = current_value
|
||||
self._last_value = current_value
|
||||
self._is_reporting = True
|
||||
|
||||
elif (
|
||||
@@ -978,12 +982,12 @@ class MieleConsumptionSensor(MieleRestorableSensor):
|
||||
and current_value is not None
|
||||
and cast(int, current_value) > 0
|
||||
):
|
||||
self._attr_native_value = 0
|
||||
self._last_value = 0
|
||||
|
||||
# keep value when program ends
|
||||
elif current_status == StateStatus.PROGRAM_ENDED:
|
||||
pass
|
||||
|
||||
else:
|
||||
self._attr_native_value = current_value
|
||||
self._last_value = current_value
|
||||
self._is_reporting = True
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
"""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
|
||||
@@ -1,58 +0,0 @@
|
||||
"""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"
|
||||
@@ -1,28 +0,0 @@
|
||||
"""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,
|
||||
),
|
||||
)
|
||||
@@ -1,44 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,130 +0,0 @@
|
||||
"""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
|
||||
@@ -1,64 +0,0 @@
|
||||
"""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)
|
||||
@@ -1,150 +0,0 @@
|
||||
"""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",
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"""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,
|
||||
)
|
||||
@@ -1,50 +0,0 @@
|
||||
"""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
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"services": {
|
||||
"custom_cleaning": {
|
||||
"service": "mdi:broom"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
"""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
|
||||
@@ -1,32 +0,0 @@
|
||||
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:
|
||||
@@ -1,73 +0,0 @@
|
||||
{
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,118 +0,0 @@
|
||||
"""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
|
||||
)
|
||||
@@ -1,388 +0,0 @@
|
||||
"""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,7 +7,6 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from pynintendoparental import Authenticator
|
||||
from pynintendoparental.api import Api
|
||||
from pynintendoparental.exceptions import HttpException, InvalidSessionTokenException
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -15,7 +14,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_API_TOKEN
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import APP_SETUP_URL, CONF_SESSION_TOKEN, DOMAIN
|
||||
from .const import CONF_SESSION_TOKEN, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -38,9 +37,6 @@ class NintendoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
if user_input is not None:
|
||||
nintendo_api = Api(
|
||||
self.auth, self.hass.config.time_zone, self.hass.config.language
|
||||
)
|
||||
try:
|
||||
await self.auth.complete_login(
|
||||
self.auth, user_input[CONF_API_TOKEN], False
|
||||
@@ -52,24 +48,12 @@ class NintendoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
assert self.auth.account_id
|
||||
await self.async_set_unique_id(self.auth.account_id)
|
||||
self._abort_if_unique_id_configured()
|
||||
try:
|
||||
if "base" not in errors:
|
||||
await nintendo_api.async_get_account_devices()
|
||||
except HttpException as err:
|
||||
if err.status_code == 404:
|
||||
return self.async_abort(
|
||||
reason="no_devices_found",
|
||||
description_placeholders={"more_info_url": APP_SETUP_URL},
|
||||
)
|
||||
errors["base"] = "cannot_connect"
|
||||
else:
|
||||
if "base" not in errors:
|
||||
return self.async_create_entry(
|
||||
title=self.auth.account_id,
|
||||
data={
|
||||
CONF_SESSION_TOKEN: self.auth.get_session_token,
|
||||
},
|
||||
)
|
||||
return self.async_create_entry(
|
||||
title=self.auth.account_id,
|
||||
data={
|
||||
CONF_SESSION_TOKEN: self.auth.get_session_token,
|
||||
},
|
||||
)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
description_placeholders={"link": self.auth.login_url},
|
||||
|
||||
@@ -8,7 +8,4 @@ BEDTIME_ALARM_MIN = "16:00"
|
||||
BEDTIME_ALARM_MAX = "23:00"
|
||||
BEDTIME_ALARM_DISABLE = "00:00"
|
||||
|
||||
APP_SETUP_URL = (
|
||||
"https://www.nintendo.com/my/support/switch/parentalcontrols/app/setup.html"
|
||||
)
|
||||
ATTR_BONUS_TIME = "bonus_time"
|
||||
|
||||
@@ -6,10 +6,7 @@ from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from pynintendoparental import Authenticator, NintendoParental
|
||||
from pynintendoparental.exceptions import (
|
||||
InvalidOAuthConfigurationException,
|
||||
NoDevicesFoundException,
|
||||
)
|
||||
from pynintendoparental.exceptions import InvalidOAuthConfigurationException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -27,8 +24,6 @@ UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
class NintendoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
"""Nintendo data update coordinator."""
|
||||
|
||||
config_entry: NintendoParentalControlsConfigEntry
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
@@ -55,8 +50,3 @@ class NintendoUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
raise ConfigEntryError(
|
||||
err, translation_domain=DOMAIN, translation_key="invalid_auth"
|
||||
) from err
|
||||
except NoDevicesFoundException as err:
|
||||
raise ConfigEntryError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="no_devices_found",
|
||||
) from err
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynintendoparental"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pynintendoparental==1.1.3"]
|
||||
"requirements": ["pynintendoparental==1.1.2"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"no_devices_found": "There are no devices paired with this Nintendo account, go to [Nintendo Support]({more_info_url}) for further assistance.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
@@ -68,9 +67,6 @@
|
||||
},
|
||||
"device_not_found": {
|
||||
"message": "Device not found."
|
||||
},
|
||||
"no_devices_found": {
|
||||
"message": "No Nintendo devices found for this account."
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
|
||||
@@ -14,7 +14,7 @@ from onedrive_personal_sdk.exceptions import (
|
||||
NotFoundError,
|
||||
OneDriveException,
|
||||
)
|
||||
from onedrive_personal_sdk.models.items import ItemUpdate
|
||||
from onedrive_personal_sdk.models.items import Item, ItemUpdate
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -202,7 +202,9 @@ async def _get_onedrive_client(
|
||||
)
|
||||
|
||||
|
||||
async def _handle_item_operation[T](func: Callable[[], Awaitable[T]], folder: str) -> T:
|
||||
async def _handle_item_operation(
|
||||
func: Callable[[], Awaitable[Item]], folder: str
|
||||
) -> Item:
|
||||
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.15"]
|
||||
"requirements": ["onedrive-personal-sdk==0.0.14"]
|
||||
}
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["openai==2.2.0", "python-open-router==0.3.2"]
|
||||
"requirements": ["openai==2.2.0", "python-open-router==0.3.1"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.15.9"]
|
||||
"requirements": ["opower==0.15.8"]
|
||||
}
|
||||
|
||||
@@ -229,7 +229,7 @@ class OverkizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle the local authentication step via config flow."""
|
||||
errors = {}
|
||||
description_placeholders = {
|
||||
"somfy_developer_mode_docs": "https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started"
|
||||
"somfy-developer-mode-docs": "https://github.com/Somfy-Developer/Somfy-TaHoma-Developer-Mode#getting-started"
|
||||
}
|
||||
|
||||
if user_input:
|
||||
|
||||
@@ -41,7 +41,7 @@
|
||||
"token": "Token generated by the app used to control your device.",
|
||||
"verify_ssl": "Verify the SSL certificate. Select this only if you are connecting via the hostname."
|
||||
},
|
||||
"description": "By activating the [Developer Mode of your TaHoma box]({somfy_developer_mode_docs}), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\n1. Open the TaHoma By Somfy application on your device.\n2. Navigate to the Help & advanced features -> Advanced features menu in the application.\n3. Activate Developer Mode by tapping 7 times on the version number of your gateway (like 2025.1.4-11).\n4. Generate a token from the Developer Mode menu to authenticate your API calls.\n\n5. Enter the generated token below and update the host to include your Gateway PIN or the IP address of your gateway."
|
||||
"description": "By activating the [Developer Mode of your TaHoma box]({somfy-developer-mode-docs}), you can authorize third-party software (like Home Assistant) to connect to it via your local network.\n\n1. Open the TaHoma By Somfy application on your device.\n2. Navigate to the Help & advanced features -> Advanced features menu in the application.\n3. Activate Developer Mode by tapping 7 times on the version number of your gateway (like 2025.1.4-11).\n4. Generate a token from the Developer Mode menu to authenticate your API calls.\n\n5. Enter the generated token below and update the host to include your Gateway PIN or the IP address of your gateway."
|
||||
},
|
||||
"local_or_cloud": {
|
||||
"data": {
|
||||
|
||||
@@ -38,7 +38,9 @@ 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[CONF_VERIFY_SSL]),
|
||||
session=async_get_clientsession(
|
||||
hass=hass, verify_ssl=data.get(CONF_VERIFY_SSL, True)
|
||||
),
|
||||
)
|
||||
try:
|
||||
await client.get_endpoints()
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyportainer==1.0.12"]
|
||||
"requirements": ["pyportainer==1.0.11"]
|
||||
}
|
||||
|
||||
@@ -19,5 +19,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["reolink-aio==0.16.4"]
|
||||
"requirements": ["reolink-aio==0.16.2"]
|
||||
}
|
||||
|
||||
@@ -835,7 +835,6 @@
|
||||
"vehicle_type": {
|
||||
"name": "Vehicle type",
|
||||
"state": {
|
||||
"bus": "Bus",
|
||||
"motorcycle": "Motorcycle",
|
||||
"pickup_truck": "Pickup truck",
|
||||
"sedan": "Sedan",
|
||||
|
||||
@@ -417,7 +417,7 @@ def get_rpc_sub_device_name(
|
||||
"""Get name based on device and channel name."""
|
||||
if key in device.config and key != "em:0":
|
||||
# workaround for Pro 3EM, we don't want to get name for em:0
|
||||
if (zone_id := get_irrigation_zone_id(device, key)) is not None:
|
||||
if (zone_id := get_irrigation_zone_id(device.config, 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,13 +792,9 @@ async def get_rpc_scripts_event_types(
|
||||
return script_events
|
||||
|
||||
|
||||
def get_irrigation_zone_id(device: RpcDevice, key: str) -> int | None:
|
||||
def get_irrigation_zone_id(config: dict[str, Any], key: str) -> int | None:
|
||||
"""Return the zone id if the component is an irrigation zone."""
|
||||
if (
|
||||
device.initialized
|
||||
and key in device.config
|
||||
and (zone := get_rpc_role_by_key(device.config, key)).startswith("zone")
|
||||
):
|
||||
if key in config and (zone := get_rpc_role_by_key(config, key)).startswith("zone"):
|
||||
return int(zone[4:])
|
||||
return None
|
||||
|
||||
@@ -841,7 +837,7 @@ def get_rpc_device_info(
|
||||
if (
|
||||
(
|
||||
component not in (*All_LIGHT_TYPES, "cover", "em1", "switch")
|
||||
and get_irrigation_zone_id(device, key) is None
|
||||
and get_irrigation_zone_id(device.config, 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.45"]
|
||||
"requirements": ["python-smarttub==0.0.44"]
|
||||
}
|
||||
|
||||
@@ -4,13 +4,11 @@ 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
|
||||
|
||||
@@ -132,14 +130,7 @@ 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."""
|
||||
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
|
||||
device = await hass.async_add_executor_job(soundtouch_device, entry.data[CONF_HOST])
|
||||
|
||||
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = SoundTouchData(device)
|
||||
|
||||
|
||||
@@ -24,8 +24,7 @@ from homeassistant.components.telegram_bot import (
|
||||
)
|
||||
from homeassistant.const import ATTR_LOCATION
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import issue_registry as ir
|
||||
from homeassistant.helpers.reload import async_setup_reload_service
|
||||
from homeassistant.helpers.reload import setup_reload_service
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import DOMAIN, PLATFORMS
|
||||
@@ -46,25 +45,14 @@ PLATFORM_SCHEMA = NOTIFY_PLATFORM_SCHEMA.extend(
|
||||
)
|
||||
|
||||
|
||||
async def async_get_service(
|
||||
def get_service(
|
||||
hass: HomeAssistant,
|
||||
config: ConfigType,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> TelegramNotificationService:
|
||||
"""Get the Telegram notification service."""
|
||||
|
||||
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)
|
||||
setup_reload_service(hass, DOMAIN, PLATFORMS)
|
||||
chat_id = config.get(CONF_CHAT_ID)
|
||||
return TelegramNotificationService(hass, chat_id)
|
||||
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
{
|
||||
"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(self._bot), event_type, event_data)
|
||||
async_dispatcher_send(self.hass, SIGNAL_UPDATE_EVENT, 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(self.bot), EVENT_TELEGRAM_SENT, event_data
|
||||
self.hass, SIGNAL_UPDATE_EVENT, EVENT_TELEGRAM_SENT, event_data
|
||||
)
|
||||
except TelegramError as exc:
|
||||
if not suppress_error:
|
||||
|
||||
@@ -14,9 +14,9 @@ from .const import (
|
||||
EVENT_TELEGRAM_COMMAND,
|
||||
EVENT_TELEGRAM_SENT,
|
||||
EVENT_TELEGRAM_TEXT,
|
||||
SIGNAL_UPDATE_EVENT,
|
||||
)
|
||||
from .entity import TelegramBotEntity
|
||||
from .helpers import signal
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -55,7 +55,7 @@ class TelegramBotEventEntity(TelegramBotEntity, EventEntity):
|
||||
self.async_on_remove(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
signal(self.config_entry.runtime_data.bot),
|
||||
SIGNAL_UPDATE_EVENT,
|
||||
self._async_handle_event,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
"""Helper functions for Telegram bot integration."""
|
||||
|
||||
from telegram import Bot
|
||||
|
||||
from .const import SIGNAL_UPDATE_EVENT
|
||||
|
||||
|
||||
def signal(bot: Bot) -> str:
|
||||
"""Define signal name."""
|
||||
return f"{SIGNAL_UPDATE_EVENT}_{bot.id}"
|
||||
@@ -7,5 +7,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/tesla_fleet",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tesla-fleet-api"],
|
||||
"requirements": ["tesla-fleet-api==1.2.5"]
|
||||
"requirements": ["tesla-fleet-api==1.2.3"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/teslemetry",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tesla-fleet-api"],
|
||||
"requirements": ["tesla-fleet-api==1.2.5", "teslemetry-stream==0.7.10"]
|
||||
"requirements": ["tesla-fleet-api==1.2.3", "teslemetry-stream==0.7.10"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/tessie",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tessie", "tesla-fleet-api"],
|
||||
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.5"]
|
||||
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.3"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
import dataclasses
|
||||
import fnmatch
|
||||
import os
|
||||
|
||||
@@ -30,6 +29,15 @@ def usb_device_from_port(port: ListPortInfo) -> USBDevice:
|
||||
|
||||
def scan_serial_ports() -> Sequence[USBDevice]:
|
||||
"""Scan serial ports for USB devices."""
|
||||
return [
|
||||
usb_device_from_port(port)
|
||||
for port in comports()
|
||||
if port.vid is not None or port.pid is not None
|
||||
]
|
||||
|
||||
|
||||
def usb_device_from_path(device_path: str) -> USBDevice | None:
|
||||
"""Get USB device info from a device path."""
|
||||
|
||||
# Scan all symlinks first
|
||||
by_id = "/dev/serial/by-id"
|
||||
@@ -38,30 +46,23 @@ def scan_serial_ports() -> Sequence[USBDevice]:
|
||||
for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()):
|
||||
realpath_to_by_id[os.path.realpath(path)] = path
|
||||
|
||||
serial_ports = []
|
||||
|
||||
for port in comports():
|
||||
if port.vid is not None or port.pid is not None:
|
||||
usb_device = usb_device_from_port(port)
|
||||
device_path = realpath_to_by_id.get(port.device, port.device)
|
||||
|
||||
if device_path != port.device:
|
||||
# Prefer the unique /dev/serial/by-id/ path if it exists
|
||||
usb_device = dataclasses.replace(usb_device, device=device_path)
|
||||
|
||||
serial_ports.append(usb_device)
|
||||
|
||||
return serial_ports
|
||||
|
||||
|
||||
def usb_device_from_path(device_path: str) -> USBDevice | None:
|
||||
"""Get USB device info from a device path."""
|
||||
|
||||
# Then compare the actual path to each serial port's
|
||||
device_path_real = os.path.realpath(device_path)
|
||||
|
||||
for device in scan_serial_ports():
|
||||
if os.path.realpath(device.device) == device_path_real:
|
||||
return device
|
||||
normalized_path = realpath_to_by_id.get(device.device, device.device)
|
||||
if (
|
||||
normalized_path == device_path
|
||||
or os.path.realpath(device.device) == device_path_real
|
||||
):
|
||||
return USBDevice(
|
||||
device=normalized_path,
|
||||
vid=device.vid,
|
||||
pid=device.pid,
|
||||
serial_number=device.serial_number,
|
||||
manufacturer=device.manufacturer,
|
||||
description=device.description,
|
||||
)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
@@ -7,6 +7,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/vegehub",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["vegehub==0.1.26"],
|
||||
"requirements": ["vegehub==0.1.24"],
|
||||
"zeroconf": ["_vege._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -267,12 +267,8 @@ class VeSyncFanHA(VeSyncBaseEntity, FanEntity):
|
||||
await self.async_set_preset_mode(preset_mode)
|
||||
return
|
||||
if percentage is None:
|
||||
success = await self.device.turn_on()
|
||||
if not success:
|
||||
raise HomeAssistantError(self.device.last_response.message)
|
||||
self.schedule_update_ha_state()
|
||||
else:
|
||||
await self.async_set_percentage(percentage)
|
||||
percentage = 50
|
||||
await self.async_set_percentage(percentage)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off."""
|
||||
|
||||
@@ -37,7 +37,7 @@ _LOGGER = logging.getLogger(__name__)
|
||||
|
||||
VS_TO_HA_MODE_MAP = {
|
||||
VS_HUMIDIFIER_MODE_AUTO: MODE_AUTO,
|
||||
VS_HUMIDIFIER_MODE_HUMIDITY: VS_HUMIDIFIER_MODE_HUMIDITY,
|
||||
VS_HUMIDIFIER_MODE_HUMIDITY: MODE_AUTO,
|
||||
VS_HUMIDIFIER_MODE_MANUAL: MODE_NORMAL,
|
||||
VS_HUMIDIFIER_MODE_SLEEP: MODE_SLEEP,
|
||||
}
|
||||
@@ -93,8 +93,6 @@ class VeSyncHumidifierHA(VeSyncBaseEntity, HumidifierEntity):
|
||||
|
||||
_attr_supported_features = HumidifierEntityFeature.MODES
|
||||
|
||||
_attr_translation_key = "vesync"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
device: VeSyncBaseDevice,
|
||||
|
||||
@@ -13,5 +13,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/vesync",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyvesync"],
|
||||
"requirements": ["pyvesync==3.1.4"]
|
||||
"requirements": ["pyvesync==3.1.2"]
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ NUMBER_DESCRIPTIONS: list[VeSyncNumberEntityDescription] = [
|
||||
mode=NumberMode.SLIDER,
|
||||
exists_fn=is_humidifier,
|
||||
set_value_fn=lambda device, value: device.set_mist_level(value),
|
||||
value_fn=lambda device: device.state.mist_virtual_level,
|
||||
value_fn=lambda device: device.state.mist_level,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@@ -49,17 +49,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"humidifier": {
|
||||
"vesync": {
|
||||
"state_attributes": {
|
||||
"mode": {
|
||||
"state": {
|
||||
"humidity": "Humidity"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"mist_level": {
|
||||
"name": "Mist level"
|
||||
|
||||
@@ -283,14 +283,6 @@
|
||||
"default": "mdi:alarm-light"
|
||||
}
|
||||
},
|
||||
"device_tracker": {
|
||||
"location": {
|
||||
"default": "mdi:car",
|
||||
"state": {
|
||||
"not_home": "mdi:car-arrow-right"
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"availability": {
|
||||
"default": "mdi:car-connected"
|
||||
|
||||
@@ -210,11 +210,6 @@
|
||||
"name": "Honk & flash"
|
||||
}
|
||||
},
|
||||
"device_tracker": {
|
||||
"location": {
|
||||
"name": "Location"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"availability": {
|
||||
"name": "Car connection",
|
||||
|
||||
@@ -17,7 +17,6 @@ from homeassistant.components.homeassistant_hardware.helpers import (
|
||||
async_notify_firmware_info,
|
||||
async_register_firmware_info_provider,
|
||||
)
|
||||
from homeassistant.components.usb import usb_device_from_path
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_TYPE,
|
||||
@@ -135,21 +134,6 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
|
||||
Will automatically load components to support devices found on the network.
|
||||
"""
|
||||
|
||||
# Try to perform an in-place migration if we detect that the device path can be made
|
||||
# unique
|
||||
device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
|
||||
usb_device = await hass.async_add_executor_job(usb_device_from_path, device_path)
|
||||
|
||||
if usb_device is not None and device_path != usb_device.device:
|
||||
_LOGGER.info(
|
||||
"Migrating ZHA device path from %s to %s", device_path, usb_device.device
|
||||
)
|
||||
new_data = {**config_entry.data}
|
||||
new_data[CONF_DEVICE][CONF_DEVICE_PATH] = usb_device.device
|
||||
hass.config_entries.async_update_entry(config_entry, data=new_data)
|
||||
device_path = usb_device.device
|
||||
|
||||
ha_zha_data: HAZHAData = get_zha_data(hass)
|
||||
ha_zha_data.config_entry = config_entry
|
||||
zha_lib_data: ZHAData = create_zha_config(hass, ha_zha_data)
|
||||
@@ -179,6 +163,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
|
||||
_LOGGER.debug("Trigger cache: %s", zha_lib_data.device_trigger_cache)
|
||||
|
||||
# Check if firmware update is in progress for this device
|
||||
device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
|
||||
_raise_if_port_in_use(hass, device_path)
|
||||
|
||||
try:
|
||||
|
||||
@@ -7,9 +7,10 @@ import collections
|
||||
from contextlib import suppress
|
||||
from enum import StrEnum
|
||||
import json
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
import serial.tools.list_ports
|
||||
from serial.tools.list_ports_common import ListPortInfo
|
||||
import voluptuous as vol
|
||||
from zha.application.const import RadioType
|
||||
import zigpy.backups
|
||||
@@ -24,7 +25,6 @@ from homeassistant.components.homeassistant_hardware.firmware_config_flow import
|
||||
ZigbeeFlowStrategy,
|
||||
)
|
||||
from homeassistant.components.homeassistant_yellow import hardware as yellow_hardware
|
||||
from homeassistant.components.usb import USBDevice, scan_serial_ports
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_IGNORE,
|
||||
SOURCE_ZEROCONF,
|
||||
@@ -124,10 +124,10 @@ def _format_backup_choice(
|
||||
return f"{dt_util.as_local(backup.backup_time).strftime('%c')} ({identifier})"
|
||||
|
||||
|
||||
async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice]:
|
||||
async def list_serial_ports(hass: HomeAssistant) -> list[ListPortInfo]:
|
||||
"""List all serial ports, including the Yellow radio and the multi-PAN addon."""
|
||||
ports: list[USBDevice] = []
|
||||
ports.extend(await hass.async_add_executor_job(scan_serial_ports))
|
||||
ports: list[ListPortInfo] = []
|
||||
ports.extend(await hass.async_add_executor_job(serial.tools.list_ports.comports))
|
||||
|
||||
# Add useful info to the Yellow's serial port selection screen
|
||||
try:
|
||||
@@ -137,14 +137,9 @@ async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice]:
|
||||
else:
|
||||
# PySerial does not properly handle the Yellow's serial port with the CM5
|
||||
# so we manually include it
|
||||
port = USBDevice(
|
||||
device="/dev/ttyAMA1",
|
||||
vid="ffff", # This is technically not a USB device
|
||||
pid="ffff",
|
||||
serial_number=None,
|
||||
manufacturer="Nabu Casa",
|
||||
description="Yellow Zigbee module",
|
||||
)
|
||||
port = ListPortInfo(device="/dev/ttyAMA1", skip_link_detection=True)
|
||||
port.description = "Yellow Zigbee module"
|
||||
port.manufacturer = "Nabu Casa"
|
||||
|
||||
ports = [p for p in ports if not p.device.startswith("/dev/ttyAMA")]
|
||||
ports.insert(0, port)
|
||||
@@ -161,15 +156,13 @@ async def list_serial_ports(hass: HomeAssistant) -> list[USBDevice]:
|
||||
addon_info = None
|
||||
|
||||
if addon_info is not None and addon_info.state != AddonState.NOT_INSTALLED:
|
||||
addon_port = USBDevice(
|
||||
addon_port = ListPortInfo(
|
||||
device=silabs_multiprotocol_addon.get_zigbee_socket(),
|
||||
vid="ffff", # This is technically not a USB device
|
||||
pid="ffff",
|
||||
serial_number=None,
|
||||
manufacturer="Nabu Casa",
|
||||
description="Silicon Labs Multiprotocol add-on",
|
||||
skip_link_detection=True,
|
||||
)
|
||||
|
||||
addon_port.description = "Multiprotocol add-on"
|
||||
addon_port.manufacturer = "Nabu Casa"
|
||||
ports.append(addon_port)
|
||||
|
||||
return ports
|
||||
@@ -225,15 +218,8 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
) -> ConfigFlowResult:
|
||||
"""Choose a serial port."""
|
||||
ports = await list_serial_ports(self.hass)
|
||||
|
||||
# The full `/dev/serial/by-id/` path is too verbose to show
|
||||
resolved_paths = {
|
||||
p.device: await self.hass.async_add_executor_job(os.path.realpath, p.device)
|
||||
for p in ports
|
||||
}
|
||||
|
||||
list_of_ports = [
|
||||
f"{resolved_paths[p.device]} - {p.description}{', s/n: ' + p.serial_number if p.serial_number else ''}"
|
||||
f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}"
|
||||
+ (f" - {p.manufacturer}" if p.manufacturer else "")
|
||||
for p in ports
|
||||
]
|
||||
@@ -487,63 +473,10 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
temp_radio_mgr.device_settings = config_entry.data[CONF_DEVICE]
|
||||
temp_radio_mgr.radio_type = RadioType[config_entry.data[CONF_RADIO_TYPE]]
|
||||
|
||||
try:
|
||||
await temp_radio_mgr.async_reset_adapter()
|
||||
except HomeAssistantError:
|
||||
# Old adapter not found or cannot connect, show prompt to plug back in
|
||||
return await self.async_step_plug_in_old_radio()
|
||||
await temp_radio_mgr.async_reset_adapter()
|
||||
|
||||
return await self.async_step_maybe_confirm_ezsp_restore()
|
||||
|
||||
async def async_step_plug_in_old_radio(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Prompt user to plug in the old radio if connection fails."""
|
||||
config_entries = self.hass.config_entries.async_entries(
|
||||
DOMAIN, include_ignore=False
|
||||
)
|
||||
|
||||
# Unless the user removes the config entry whilst we try to reset the old radio
|
||||
# for a few seconds and then also unplugs it, we will basically never hit this
|
||||
if not config_entries:
|
||||
return await self.async_step_maybe_confirm_ezsp_restore()
|
||||
|
||||
config_entry = config_entries[0]
|
||||
old_device_path = config_entry.data[CONF_DEVICE][CONF_DEVICE_PATH]
|
||||
|
||||
return self.async_show_menu(
|
||||
step_id="plug_in_old_radio",
|
||||
menu_options=["retry_old_radio", "skip_reset_old_radio"],
|
||||
description_placeholders={"device_path": old_device_path},
|
||||
)
|
||||
|
||||
async def async_step_retry_old_radio(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Retry connecting to the old radio."""
|
||||
return await self.async_step_maybe_reset_old_radio()
|
||||
|
||||
async def async_step_skip_reset_old_radio(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Skip resetting the old radio and continue with migration."""
|
||||
return await self.async_step_maybe_confirm_ezsp_restore()
|
||||
|
||||
async def async_step_plug_in_new_radio(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Prompt user to plug in the new radio if connection fails."""
|
||||
if user_input is not None:
|
||||
# User confirmed, retry now
|
||||
return await self.async_step_maybe_confirm_ezsp_restore()
|
||||
|
||||
assert self._radio_mgr.device_path is not None
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="plug_in_new_radio",
|
||||
description_placeholders={"device_path": self._radio_mgr.device_path},
|
||||
)
|
||||
|
||||
async def async_step_migration_strategy_advanced(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -700,9 +633,6 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
# On confirmation, overwrite destructively
|
||||
try:
|
||||
await self._radio_mgr.restore_backup(overwrite_ieee=True)
|
||||
except HomeAssistantError:
|
||||
# User unplugged the new adapter, allow retry
|
||||
return await self.async_step_plug_in_new_radio()
|
||||
except CannotWriteNetworkSettings as exc:
|
||||
return self.async_abort(
|
||||
reason="cannot_restore_backup",
|
||||
@@ -720,9 +650,6 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
except DestructiveWriteNetworkSettings:
|
||||
# Restore cannot happen automatically, we need to ask for permission
|
||||
pass
|
||||
except HomeAssistantError:
|
||||
# User unplugged the new adapter, allow retry
|
||||
return await self.async_step_plug_in_new_radio()
|
||||
except CannotWriteNetworkSettings as exc:
|
||||
return self.async_abort(
|
||||
reason="cannot_restore_backup",
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
"zha",
|
||||
"universal_silabs_flasher"
|
||||
],
|
||||
"requirements": ["zha==0.0.77"],
|
||||
"requirements": ["zha==0.0.75"],
|
||||
"usb": [
|
||||
{
|
||||
"description": "*2652*",
|
||||
|
||||
@@ -111,22 +111,6 @@
|
||||
"description": "A backup was created earlier and your old adapter is being reset as part of the migration.",
|
||||
"title": "Resetting old adapter"
|
||||
},
|
||||
"plug_in_new_radio": {
|
||||
"description": "Your new adapter at `{device_path}` was not found.\nPlease plug it in and click Submit to continue.",
|
||||
"title": "New adapter not found"
|
||||
},
|
||||
"plug_in_old_radio": {
|
||||
"description": "Your old adapter at `{device_path}` was not found. You can retry after plugging it back in, or skip resetting the old adapter.\n\n**Warning:** If you skip resetting the old adapter, ensure it remains permanently disconnected. Plugging it back in later will cause network issues.",
|
||||
"menu_option_descriptions": {
|
||||
"retry_old_radio": "Retry connecting to the old adapter to reset it as part of the migration.",
|
||||
"skip_reset_old_radio": "Skip resetting the old adapter and continue with the migration."
|
||||
},
|
||||
"menu_options": {
|
||||
"retry_old_radio": "Retry",
|
||||
"skip_reset_old_radio": "Skip reset"
|
||||
},
|
||||
"title": "Old adapter not found"
|
||||
},
|
||||
"upload_manual_backup": {
|
||||
"data": {
|
||||
"uploaded_backup_file": "Upload a file"
|
||||
@@ -1961,22 +1945,6 @@
|
||||
"description": "[%key:component::zha::config::step::maybe_confirm_ezsp_restore::description%]",
|
||||
"title": "[%key:component::zha::config::step::maybe_confirm_ezsp_restore::title%]"
|
||||
},
|
||||
"plug_in_new_radio": {
|
||||
"description": "[%key:component::zha::config::step::plug_in_new_radio::description%]",
|
||||
"title": "[%key:component::zha::config::step::plug_in_new_radio::title%]"
|
||||
},
|
||||
"plug_in_old_radio": {
|
||||
"description": "[%key:component::zha::config::step::plug_in_old_radio::description%]",
|
||||
"menu_option_descriptions": {
|
||||
"retry_old_radio": "[%key:component::zha::config::step::plug_in_old_radio::menu_option_descriptions::retry_old_radio%]",
|
||||
"skip_reset_old_radio": "[%key:component::zha::config::step::plug_in_old_radio::menu_option_descriptions::skip_reset_old_radio%]"
|
||||
},
|
||||
"menu_options": {
|
||||
"retry_old_radio": "[%key:component::zha::config::step::plug_in_old_radio::menu_options::retry_old_radio%]",
|
||||
"skip_reset_old_radio": "[%key:component::zha::config::step::plug_in_old_radio::menu_options::skip_reset_old_radio%]"
|
||||
},
|
||||
"title": "[%key:component::zha::config::step::plug_in_old_radio::title%]"
|
||||
},
|
||||
"prompt_migrate_or_reconfigure": {
|
||||
"description": "Are you migrating to a new adapter or re-configuring the current adapter?",
|
||||
"menu_option_descriptions": {
|
||||
|
||||
@@ -24,7 +24,7 @@ if TYPE_CHECKING:
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2025
|
||||
MINOR_VERSION: Final = 11
|
||||
PATCH_VERSION: Final = "0b4"
|
||||
PATCH_VERSION: Final = "0.dev0"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 13, 2)
|
||||
|
||||
10
homeassistant/generated/amazon_polly.py
generated
10
homeassistant/generated/amazon_polly.py
generated
@@ -23,12 +23,9 @@ SUPPORTED_REGIONS: Final[set[str]] = {
|
||||
"ap-south-1",
|
||||
"ap-southeast-1",
|
||||
"ap-southeast-2",
|
||||
"ap-southeast-5",
|
||||
"ca-central-1",
|
||||
"eu-central-1",
|
||||
"eu-central-2",
|
||||
"eu-north-1",
|
||||
"eu-south-2",
|
||||
"eu-west-1",
|
||||
"eu-west-2",
|
||||
"eu-west-3",
|
||||
@@ -43,7 +40,6 @@ SUPPORTED_REGIONS: Final[set[str]] = {
|
||||
SUPPORTED_VOICES: Final[set[str]] = {
|
||||
"Aditi",
|
||||
"Adriano",
|
||||
"Alba",
|
||||
"Amy",
|
||||
"Andres",
|
||||
"Aria",
|
||||
@@ -84,9 +80,6 @@ SUPPORTED_VOICES: Final[set[str]] = {
|
||||
"Ivy",
|
||||
"Jacek",
|
||||
"Jan",
|
||||
"Jasmine",
|
||||
"Jihye",
|
||||
"Jitka",
|
||||
"Joanna",
|
||||
"Joey",
|
||||
"Justin",
|
||||
@@ -118,17 +111,14 @@ SUPPORTED_VOICES: Final[set[str]] = {
|
||||
"Nicole",
|
||||
"Ola",
|
||||
"Olivia",
|
||||
"Patrick",
|
||||
"Pedro",
|
||||
"Penelope",
|
||||
"Raul",
|
||||
"Raveena",
|
||||
"Remi",
|
||||
"Ricardo",
|
||||
"Ruben",
|
||||
"Russell",
|
||||
"Ruth",
|
||||
"Sabrina",
|
||||
"Salli",
|
||||
"Seoyeon",
|
||||
"Sergio",
|
||||
|
||||
@@ -27,7 +27,6 @@ APPLICATION_CREDENTIALS = [
|
||||
"miele",
|
||||
"monzo",
|
||||
"myuplink",
|
||||
"neato",
|
||||
"nest",
|
||||
"netatmo",
|
||||
"ondilo_ico",
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -428,7 +428,6 @@ FLOWS = {
|
||||
"nam",
|
||||
"nanoleaf",
|
||||
"nasweb",
|
||||
"neato",
|
||||
"nederlandse_spoorwegen",
|
||||
"nest",
|
||||
"netatmo",
|
||||
|
||||
@@ -1767,6 +1767,11 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "local_polling"
|
||||
},
|
||||
"enmax": {
|
||||
"name": "Enmax Energy",
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "opower"
|
||||
},
|
||||
"enocean": {
|
||||
"name": "EnOcean",
|
||||
"integration_type": "hub",
|
||||
@@ -4334,12 +4339,6 @@
|
||||
"integration_type": "virtual",
|
||||
"supported_by": "opower"
|
||||
},
|
||||
"neato": {
|
||||
"name": "Neato Botvac",
|
||||
"integration_type": "hub",
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"nederlandse_spoorwegen": {
|
||||
"name": "Nederlandse Spoorwegen (NS)",
|
||||
"integration_type": "service",
|
||||
|
||||
@@ -36,10 +36,10 @@ fnv-hash-fast==1.6.0
|
||||
go2rtc-client==0.2.1
|
||||
ha-ffmpeg==3.2.2
|
||||
habluetooth==5.7.0
|
||||
hass-nabucasa==1.5.1
|
||||
hassil==3.4.0
|
||||
hass-nabucasa==1.4.0
|
||||
hassil==3.3.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20251104.0
|
||||
home-assistant-frontend==20251001.4
|
||||
home-assistant-intents==2025.10.28
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
@@ -69,7 +69,7 @@ standard-telnetlib==3.13.0
|
||||
typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==1.5.2
|
||||
urllib3>=2.0
|
||||
uv==0.9.6
|
||||
uv==0.9.5
|
||||
voluptuous-openapi==0.1.0
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous==0.15.2
|
||||
|
||||
@@ -104,30 +104,20 @@ class Dialect:
|
||||
return (-1, 0)
|
||||
|
||||
is_exact_language = self.language == dialect.language
|
||||
is_exact_language_and_code = is_exact_language and (self.code == dialect.code)
|
||||
|
||||
if (self.region is None) and (dialect.region is None):
|
||||
# Weak match with no region constraint
|
||||
# Prefer exact language match
|
||||
if is_exact_language_and_code:
|
||||
return (3, 0)
|
||||
|
||||
if is_exact_language:
|
||||
return (2, 0)
|
||||
|
||||
return (1, 0)
|
||||
return (2 if is_exact_language else 1, 0)
|
||||
|
||||
if (self.region is not None) and (dialect.region is not None):
|
||||
if self.region == dialect.region:
|
||||
# Same language + region match
|
||||
# Prefer exact language match
|
||||
if is_exact_language_and_code:
|
||||
return (math.inf, 2)
|
||||
|
||||
if is_exact_language:
|
||||
return (math.inf, 1)
|
||||
|
||||
return (math.inf, 0)
|
||||
return (
|
||||
math.inf,
|
||||
1 if is_exact_language else 0,
|
||||
)
|
||||
|
||||
# Regions are both set, but don't match
|
||||
return (0, 0)
|
||||
@@ -149,8 +139,8 @@ class Dialect:
|
||||
region_idx = pref_regions.index(dialect.region)
|
||||
|
||||
# More preferred regions are at the front.
|
||||
# Add 2 to boost above a weak match where no regions are set.
|
||||
return (2 + (len(pref_regions) - region_idx), 0)
|
||||
# Add 1 to boost above a weak match where no regions are set.
|
||||
return (1 + (len(pref_regions) - region_idx), 0)
|
||||
except ValueError:
|
||||
# Region was not in preferred list
|
||||
pass
|
||||
|
||||
10
mypy.ini
generated
10
mypy.ini
generated
@@ -3366,16 +3366,6 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.neato.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.nest.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2025.11.0b4"
|
||||
version = "2025.11.0.dev0"
|
||||
license = "Apache-2.0"
|
||||
license-files = ["LICENSE*", "homeassistant/backports/LICENSE*"]
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
@@ -48,7 +48,7 @@ dependencies = [
|
||||
"fnv-hash-fast==1.6.0",
|
||||
# hass-nabucasa is imported by helpers which don't depend on the cloud
|
||||
# integration
|
||||
"hass-nabucasa==1.5.1",
|
||||
"hass-nabucasa==1.4.0",
|
||||
# When bumping httpx, please check the version pins of
|
||||
# httpcore, anyio, and h11 in gen_requirements_all
|
||||
"httpx==0.28.1",
|
||||
@@ -75,7 +75,7 @@ dependencies = [
|
||||
"typing-extensions>=4.15.0,<5.0",
|
||||
"ulid-transform==1.5.2",
|
||||
"urllib3>=2.0",
|
||||
"uv==0.9.6",
|
||||
"uv==0.9.5",
|
||||
"voluptuous==0.15.2",
|
||||
"voluptuous-serialize==2.7.0",
|
||||
"voluptuous-openapi==0.1.0",
|
||||
|
||||
4
requirements.txt
generated
4
requirements.txt
generated
@@ -22,7 +22,7 @@ certifi>=2021.5.30
|
||||
ciso8601==2.3.3
|
||||
cronsim==2.6
|
||||
fnv-hash-fast==1.6.0
|
||||
hass-nabucasa==1.5.1
|
||||
hass-nabucasa==1.4.0
|
||||
httpx==0.28.1
|
||||
home-assistant-bluetooth==1.13.1
|
||||
ifaddr==0.2.0
|
||||
@@ -46,7 +46,7 @@ standard-telnetlib==3.13.0
|
||||
typing-extensions>=4.15.0,<5.0
|
||||
ulid-transform==1.5.2
|
||||
urllib3>=2.0
|
||||
uv==0.9.6
|
||||
uv==0.9.5
|
||||
voluptuous==0.15.2
|
||||
voluptuous-serialize==2.7.0
|
||||
voluptuous-openapi==0.1.0
|
||||
|
||||
43
requirements_all.txt
generated
43
requirements_all.txt
generated
@@ -194,7 +194,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.2
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==6.5.6
|
||||
aioamazondevices==6.5.5
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -791,7 +791,7 @@ decora-wifi==1.4
|
||||
# decora==0.6
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==16.3.0
|
||||
deebot-client==16.1.0
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@@ -859,7 +859,7 @@ ebusdpy==0.0.17
|
||||
ecoaliface==0.4.0
|
||||
|
||||
# homeassistant.components.eheimdigital
|
||||
eheimdigital==1.4.0
|
||||
eheimdigital==1.3.0
|
||||
|
||||
# homeassistant.components.ekeybionyx
|
||||
ekey-bionyxpy==1.0.0
|
||||
@@ -1160,14 +1160,14 @@ habiticalib==0.4.6
|
||||
habluetooth==5.7.0
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.5.1
|
||||
hass-nabucasa==1.4.0
|
||||
|
||||
# homeassistant.components.splunk
|
||||
hass-splunk==0.1.1
|
||||
|
||||
# homeassistant.components.assist_satellite
|
||||
# homeassistant.components.conversation
|
||||
hassil==3.4.0
|
||||
hassil==3.3.0
|
||||
|
||||
# homeassistant.components.jewish_calendar
|
||||
hdate[astral]==1.1.2
|
||||
@@ -1201,7 +1201,7 @@ hole==0.9.0
|
||||
holidays==0.83
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20251104.0
|
||||
home-assistant-frontend==20251001.4
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.10.28
|
||||
@@ -1334,7 +1334,7 @@ kiwiki-client==0.1.1
|
||||
knocki==0.4.2
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2025.10.31.195356
|
||||
knx-frontend==2025.10.26.81530
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
@@ -1376,7 +1376,7 @@ libpyfoscamcgi==0.0.8
|
||||
libpyvivotek==0.6.1
|
||||
|
||||
# homeassistant.components.libre_hardware_monitor
|
||||
librehardwaremonitor-api==1.5.0
|
||||
librehardwaremonitor-api==1.4.0
|
||||
|
||||
# homeassistant.components.mikrotik
|
||||
librouteros==3.2.0
|
||||
@@ -1630,7 +1630,7 @@ omnilogic==0.4.5
|
||||
ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
onedrive-personal-sdk==0.0.15
|
||||
onedrive-personal-sdk==0.0.14
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==4.0.4
|
||||
@@ -1670,7 +1670,7 @@ openwrt-luci-rpc==1.1.17
|
||||
openwrt-ubus-rpc==0.0.2
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.15.9
|
||||
opower==0.15.8
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==0.17.6
|
||||
@@ -1911,9 +1911,6 @@ pyblackbird==0.6
|
||||
# homeassistant.components.bluesound
|
||||
pyblu==2.0.5
|
||||
|
||||
# homeassistant.components.neato
|
||||
pybotvac==0.0.28
|
||||
|
||||
# homeassistant.components.braviatv
|
||||
pybravia==0.3.4
|
||||
|
||||
@@ -1951,7 +1948,7 @@ pycsspeechtts==1.0.8
|
||||
# pycups==2.0.4
|
||||
|
||||
# homeassistant.components.cync
|
||||
pycync==0.4.3
|
||||
pycync==0.4.2
|
||||
|
||||
# homeassistant.components.daikin
|
||||
pydaikin==2.17.1
|
||||
@@ -2228,7 +2225,7 @@ pynetio==0.1.9.1
|
||||
pynina==0.3.6
|
||||
|
||||
# homeassistant.components.nintendo_parental_controls
|
||||
pynintendoparental==1.1.3
|
||||
pynintendoparental==1.1.2
|
||||
|
||||
# homeassistant.components.nobo_hub
|
||||
pynobo==1.8.1
|
||||
@@ -2308,7 +2305,7 @@ pyplaato==0.0.19
|
||||
pypoint==3.0.0
|
||||
|
||||
# homeassistant.components.portainer
|
||||
pyportainer==1.0.12
|
||||
pyportainer==1.0.11
|
||||
|
||||
# homeassistant.components.probe_plus
|
||||
pyprobeplus==1.1.2
|
||||
@@ -2537,7 +2534,7 @@ python-mpd2==3.1.1
|
||||
python-mystrom==2.5.0
|
||||
|
||||
# homeassistant.components.open_router
|
||||
python-open-router==0.3.2
|
||||
python-open-router==0.3.1
|
||||
|
||||
# homeassistant.components.swiss_public_transport
|
||||
python-opendata-transport==0.5.0
|
||||
@@ -2568,7 +2565,7 @@ python-ripple-api==0.0.3
|
||||
python-roborock==3.7.0
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.45
|
||||
python-smarttub==0.0.44
|
||||
|
||||
# homeassistant.components.snoo
|
||||
python-snoo==0.8.3
|
||||
@@ -2635,7 +2632,7 @@ pyvera==0.3.16
|
||||
pyversasense==0.0.6
|
||||
|
||||
# homeassistant.components.vesync
|
||||
pyvesync==3.1.4
|
||||
pyvesync==3.1.2
|
||||
|
||||
# homeassistant.components.vizio
|
||||
pyvizio==0.1.61
|
||||
@@ -2725,7 +2722,7 @@ renault-api==0.5.0
|
||||
renson-endura-delta==1.7.2
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.16.4
|
||||
reolink-aio==0.16.2
|
||||
|
||||
# homeassistant.components.idteck_prox
|
||||
rfk101py==0.0.1
|
||||
@@ -2976,7 +2973,7 @@ temperusb==1.6.1
|
||||
# homeassistant.components.tesla_fleet
|
||||
# homeassistant.components.teslemetry
|
||||
# homeassistant.components.tessie
|
||||
tesla-fleet-api==1.2.5
|
||||
tesla-fleet-api==1.2.3
|
||||
|
||||
# homeassistant.components.powerwall
|
||||
tesla-powerwall==0.5.2
|
||||
@@ -3107,7 +3104,7 @@ vacuum-map-parser-roborock==0.1.4
|
||||
vallox-websocket-api==5.3.0
|
||||
|
||||
# homeassistant.components.vegehub
|
||||
vegehub==0.1.26
|
||||
vegehub==0.1.24
|
||||
|
||||
# homeassistant.components.rdw
|
||||
vehicle==2.2.2
|
||||
@@ -3262,7 +3259,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.77
|
||||
zha==0.0.75
|
||||
|
||||
# homeassistant.components.zhong_hong
|
||||
zhong-hong-hvac==1.0.13
|
||||
|
||||
43
requirements_test_all.txt
generated
43
requirements_test_all.txt
generated
@@ -182,7 +182,7 @@ aioairzone-cloud==0.7.2
|
||||
aioairzone==1.0.2
|
||||
|
||||
# homeassistant.components.alexa_devices
|
||||
aioamazondevices==6.5.6
|
||||
aioamazondevices==6.5.5
|
||||
|
||||
# homeassistant.components.ambient_network
|
||||
# homeassistant.components.ambient_station
|
||||
@@ -691,7 +691,7 @@ debugpy==1.8.16
|
||||
# decora==0.6
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==16.3.0
|
||||
deebot-client==16.1.0
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@@ -750,7 +750,7 @@ eagle100==0.1.1
|
||||
easyenergy==2.1.2
|
||||
|
||||
# homeassistant.components.eheimdigital
|
||||
eheimdigital==1.4.0
|
||||
eheimdigital==1.3.0
|
||||
|
||||
# homeassistant.components.ekeybionyx
|
||||
ekey-bionyxpy==1.0.0
|
||||
@@ -1021,11 +1021,11 @@ habiticalib==0.4.6
|
||||
habluetooth==5.7.0
|
||||
|
||||
# homeassistant.components.cloud
|
||||
hass-nabucasa==1.5.1
|
||||
hass-nabucasa==1.4.0
|
||||
|
||||
# homeassistant.components.assist_satellite
|
||||
# homeassistant.components.conversation
|
||||
hassil==3.4.0
|
||||
hassil==3.3.0
|
||||
|
||||
# homeassistant.components.jewish_calendar
|
||||
hdate[astral]==1.1.2
|
||||
@@ -1050,7 +1050,7 @@ hole==0.9.0
|
||||
holidays==0.83
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20251104.0
|
||||
home-assistant-frontend==20251001.4
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2025.10.28
|
||||
@@ -1159,7 +1159,7 @@ kegtron-ble==1.0.2
|
||||
knocki==0.4.2
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2025.10.31.195356
|
||||
knx-frontend==2025.10.26.81530
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
@@ -1195,7 +1195,7 @@ letpot==0.6.3
|
||||
libpyfoscamcgi==0.0.8
|
||||
|
||||
# homeassistant.components.libre_hardware_monitor
|
||||
librehardwaremonitor-api==1.5.0
|
||||
librehardwaremonitor-api==1.4.0
|
||||
|
||||
# homeassistant.components.mikrotik
|
||||
librouteros==3.2.0
|
||||
@@ -1401,7 +1401,7 @@ omnilogic==0.4.5
|
||||
ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
onedrive-personal-sdk==0.0.15
|
||||
onedrive-personal-sdk==0.0.14
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==4.0.4
|
||||
@@ -1429,7 +1429,7 @@ openrgb-python==0.3.6
|
||||
openwebifpy==4.3.1
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.15.9
|
||||
opower==0.15.8
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==0.17.6
|
||||
@@ -1616,9 +1616,6 @@ pyblackbird==0.6
|
||||
# homeassistant.components.bluesound
|
||||
pyblu==2.0.5
|
||||
|
||||
# homeassistant.components.neato
|
||||
pybotvac==0.0.28
|
||||
|
||||
# homeassistant.components.braviatv
|
||||
pybravia==0.3.4
|
||||
|
||||
@@ -1644,7 +1641,7 @@ pycsspeechtts==1.0.8
|
||||
# pycups==2.0.4
|
||||
|
||||
# homeassistant.components.cync
|
||||
pycync==0.4.3
|
||||
pycync==0.4.2
|
||||
|
||||
# homeassistant.components.daikin
|
||||
pydaikin==2.17.1
|
||||
@@ -1864,7 +1861,7 @@ pynetgear==0.10.10
|
||||
pynina==0.3.6
|
||||
|
||||
# homeassistant.components.nintendo_parental_controls
|
||||
pynintendoparental==1.1.3
|
||||
pynintendoparental==1.1.2
|
||||
|
||||
# homeassistant.components.nobo_hub
|
||||
pynobo==1.8.1
|
||||
@@ -1935,7 +1932,7 @@ pyplaato==0.0.19
|
||||
pypoint==3.0.0
|
||||
|
||||
# homeassistant.components.portainer
|
||||
pyportainer==1.0.12
|
||||
pyportainer==1.0.11
|
||||
|
||||
# homeassistant.components.probe_plus
|
||||
pyprobeplus==1.1.2
|
||||
@@ -2110,7 +2107,7 @@ python-mpd2==3.1.1
|
||||
python-mystrom==2.5.0
|
||||
|
||||
# homeassistant.components.open_router
|
||||
python-open-router==0.3.2
|
||||
python-open-router==0.3.1
|
||||
|
||||
# homeassistant.components.swiss_public_transport
|
||||
python-opendata-transport==0.5.0
|
||||
@@ -2138,7 +2135,7 @@ python-rabbitair==0.0.8
|
||||
python-roborock==3.7.0
|
||||
|
||||
# homeassistant.components.smarttub
|
||||
python-smarttub==0.0.45
|
||||
python-smarttub==0.0.44
|
||||
|
||||
# homeassistant.components.snoo
|
||||
python-snoo==0.8.3
|
||||
@@ -2193,7 +2190,7 @@ pyuptimerobot==22.2.0
|
||||
pyvera==0.3.16
|
||||
|
||||
# homeassistant.components.vesync
|
||||
pyvesync==3.1.4
|
||||
pyvesync==3.1.2
|
||||
|
||||
# homeassistant.components.vizio
|
||||
pyvizio==0.1.61
|
||||
@@ -2271,7 +2268,7 @@ renault-api==0.5.0
|
||||
renson-endura-delta==1.7.2
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.16.4
|
||||
reolink-aio==0.16.2
|
||||
|
||||
# homeassistant.components.rflink
|
||||
rflink==0.0.67
|
||||
@@ -2465,7 +2462,7 @@ temperusb==1.6.1
|
||||
# homeassistant.components.tesla_fleet
|
||||
# homeassistant.components.teslemetry
|
||||
# homeassistant.components.tessie
|
||||
tesla-fleet-api==1.2.5
|
||||
tesla-fleet-api==1.2.3
|
||||
|
||||
# homeassistant.components.powerwall
|
||||
tesla-powerwall==0.5.2
|
||||
@@ -2578,7 +2575,7 @@ vacuum-map-parser-roborock==0.1.4
|
||||
vallox-websocket-api==5.3.0
|
||||
|
||||
# homeassistant.components.vegehub
|
||||
vegehub==0.1.26
|
||||
vegehub==0.1.24
|
||||
|
||||
# homeassistant.components.rdw
|
||||
vehicle==2.2.2
|
||||
@@ -2709,7 +2706,7 @@ zeroconf==0.148.0
|
||||
zeversolar==0.3.2
|
||||
|
||||
# homeassistant.components.zha
|
||||
zha==0.0.77
|
||||
zha==0.0.75
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.67.1
|
||||
|
||||
4
script/hassfest/docker/Dockerfile
generated
4
script/hassfest/docker/Dockerfile
generated
@@ -14,7 +14,7 @@ WORKDIR "/github/workspace"
|
||||
COPY . /usr/src/homeassistant
|
||||
|
||||
# Uv is only needed during build
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.6,source=/uv,target=/bin/uv \
|
||||
RUN --mount=from=ghcr.io/astral-sh/uv:0.9.5,source=/uv,target=/bin/uv \
|
||||
# Uv creates a lock file in /tmp
|
||||
--mount=type=tmpfs,target=/tmp \
|
||||
# Required for PyTurboJPEG
|
||||
@@ -31,7 +31,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.9.6,source=/uv,target=/bin/uv \
|
||||
PyTurboJPEG==1.8.0 \
|
||||
go2rtc-client==0.2.1 \
|
||||
ha-ffmpeg==3.2.2 \
|
||||
hassil==3.4.0 \
|
||||
hassil==3.3.0 \
|
||||
home-assistant-intents==2025.10.28 \
|
||||
mutagen==1.47.0 \
|
||||
pymicro-vad==1.0.1 \
|
||||
|
||||
@@ -673,7 +673,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
|
||||
"namecheapdns",
|
||||
"nanoleaf",
|
||||
"nasweb",
|
||||
"neato",
|
||||
"nederlandse_spoorwegen",
|
||||
"ness_alarm",
|
||||
"netatmo",
|
||||
@@ -1706,7 +1705,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
|
||||
"namecheapdns",
|
||||
"nanoleaf",
|
||||
"nasweb",
|
||||
"neato",
|
||||
"nederlandse_spoorwegen",
|
||||
"nest",
|
||||
"ness_alarm",
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from functools import partial
|
||||
import json
|
||||
import re
|
||||
import string
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
@@ -132,12 +131,10 @@ def translation_value_validator(value: Any) -> str:
|
||||
|
||||
- prevents string with HTML
|
||||
- prevents strings with single quoted placeholders
|
||||
- prevents strings with placeholders using invalid identifiers
|
||||
- prevents combined translations
|
||||
"""
|
||||
string_value = cv.string_with_no_html(value)
|
||||
string_value = string_no_single_quoted_placeholders(string_value)
|
||||
string_value = validate_placeholders(string_value)
|
||||
if RE_COMBINED_REFERENCE.search(string_value):
|
||||
raise vol.Invalid("the string should not contain combined translations")
|
||||
if string_value != string_value.strip():
|
||||
@@ -154,19 +151,6 @@ def string_no_single_quoted_placeholders(value: str) -> str:
|
||||
return value
|
||||
|
||||
|
||||
def validate_placeholders(value: str) -> str:
|
||||
"""Validate that placeholders in translations use valid identifiers."""
|
||||
formatter = string.Formatter()
|
||||
|
||||
for _, field_name, _, _ in formatter.parse(value):
|
||||
if field_name: # skip literal text segments
|
||||
if not field_name.isidentifier():
|
||||
raise vol.Invalid(
|
||||
"placeholders must be valid identifiers ([a-zA-Z_][a-zA-Z0-9_]*)"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def gen_data_entry_schema(
|
||||
*,
|
||||
config: Config,
|
||||
|
||||
@@ -3,11 +3,12 @@
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import sys
|
||||
|
||||
from . import download, upload
|
||||
from .const import INTEGRATIONS_DIR
|
||||
from .util import flatten_translations, get_base_arg_parser, substitute_references
|
||||
from .util import flatten_translations, get_base_arg_parser
|
||||
|
||||
|
||||
def valid_integration(integration):
|
||||
@@ -30,6 +31,42 @@ def get_arguments() -> argparse.Namespace:
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def substitute_translation_references(integration_strings, flattened_translations):
|
||||
"""Recursively processes all translation strings for the integration."""
|
||||
result = {}
|
||||
for key, value in integration_strings.items():
|
||||
if isinstance(value, dict):
|
||||
sub_dict = substitute_translation_references(value, flattened_translations)
|
||||
result[key] = sub_dict
|
||||
elif isinstance(value, str):
|
||||
result[key] = substitute_reference(value, flattened_translations)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def substitute_reference(value, flattened_translations):
|
||||
"""Substitute localization key references in a translation string."""
|
||||
matches = re.findall(r"\[\%key:([a-z0-9_]+(?:::(?:[a-z0-9-_])+)+)\%\]", value)
|
||||
if not matches:
|
||||
return value
|
||||
|
||||
new = value
|
||||
for key in matches:
|
||||
if key in flattened_translations:
|
||||
new = new.replace(
|
||||
f"[%key:{key}%]",
|
||||
# New value can also be a substitution reference
|
||||
substitute_reference(
|
||||
flattened_translations[key], flattened_translations
|
||||
),
|
||||
)
|
||||
else:
|
||||
print(f"Invalid substitution key '{key}' found in string '{value}'")
|
||||
sys.exit(1)
|
||||
|
||||
return new
|
||||
|
||||
|
||||
def run_single(translations, flattened_translations, integration):
|
||||
"""Run the script for a single integration."""
|
||||
print(f"Generating translations for {integration}")
|
||||
@@ -40,8 +77,8 @@ def run_single(translations, flattened_translations, integration):
|
||||
|
||||
integration_strings = translations["component"][integration]
|
||||
|
||||
translations["component"][integration] = substitute_references(
|
||||
integration_strings, flattened_translations, fail_on_missing=True
|
||||
translations["component"][integration] = substitute_translation_references(
|
||||
integration_strings, flattened_translations
|
||||
)
|
||||
|
||||
if download.DOWNLOAD_DIR.is_dir():
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user