Compare commits

..

3 Commits

Author SHA1 Message Date
Cursor Agent
b2fe77b7f5 Refactor blueprint update entity and manager
This commit refactors the blueprint update entity and manager by removing unnecessary listeners and callbacks. It also renames `source_url` to `release_url` for clarity.

Co-authored-by: paulus.schoutsen <paulus.schoutsen@nabucasa.com>
2025-10-31 14:30:45 +00:00
Cursor Agent
d984e4398e Add daily refresh for blueprint updates
Co-authored-by: paulus.schoutsen <paulus.schoutsen@nabucasa.com>
2025-10-31 13:03:24 +00:00
Cursor Agent
75bd1a0310 feat: Add blueprint update entities
This commit introduces update entities for blueprints, allowing users to track and install updates for blueprints used in automations and scripts. It also adds a new event for script reloads.

Co-authored-by: paulus.schoutsen <paulus.schoutsen@nabucasa.com>
2025-10-31 11:55:03 +00:00
202 changed files with 1954 additions and 15576 deletions

View File

@@ -362,7 +362,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.*

4
CODEOWNERS generated
View File

@@ -1817,8 +1817,8 @@ build.json @home-assistant/supervisor
/tests/components/ws66i/ @ssaenger
/homeassistant/components/wyoming/ @synesthesiam
/tests/components/wyoming/ @synesthesiam
/homeassistant/components/xbox/ @hunterjm @tr4nt0r
/tests/components/xbox/ @hunterjm @tr4nt0r
/homeassistant/components/xbox/ @hunterjm
/tests/components/xbox/ @hunterjm
/homeassistant/components/xiaomi_aqara/ @danielhiversen @syssi
/tests/components/xiaomi_aqara/ @danielhiversen @syssi
/homeassistant/components/xiaomi_ble/ @Jc2k @Ernst79

View File

@@ -1,5 +1,11 @@
{
"domain": "yale",
"name": "Yale (non-US/Canada)",
"integrations": ["yale", "yalexs_ble", "yale_smart_alarm"]
"name": "Yale",
"integrations": [
"august",
"yale_smart_alarm",
"yalexs_ble",
"yale_home",
"yale"
]
}

View File

@@ -1,5 +0,0 @@
{
"domain": "yale_august",
"name": "Yale August (US/Canada)",
"integrations": ["august", "august_ble"]
}

View File

@@ -8,6 +8,6 @@
"integration_type": "service",
"iot_class": "calculated",
"quality_scale": "internal",
"requirements": ["cronsim==2.7", "securetar==2025.2.1"],
"requirements": ["cronsim==2.6", "securetar==2025.2.1"],
"single_config_entry": true
}

View File

@@ -72,7 +72,7 @@ class BlueMaestroConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@@ -1,7 +1,9 @@
"""The blueprint integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
from . import websocket_api
@@ -28,4 +30,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the blueprint integration."""
websocket_api.async_setup(hass)
hass.async_create_task(
async_load_platform(hass, Platform.UPDATE, DOMAIN, None, config)
)
return True

View File

@@ -204,8 +204,8 @@ class DomainBlueprints:
self.hass = hass
self.domain = domain
self.logger = logger
self._blueprint_in_use = blueprint_in_use
self._reload_blueprint_consumers = reload_blueprint_consumers
self.blueprint_in_use = blueprint_in_use
self.reload_blueprint_consumers = reload_blueprint_consumers
self._blueprints: dict[str, Blueprint | None] = {}
self._load_lock = asyncio.Lock()
self._blueprint_schema = blueprint_schema
@@ -325,7 +325,7 @@ class DomainBlueprints:
async def async_remove_blueprint(self, blueprint_path: str) -> None:
"""Remove a blueprint file."""
if self._blueprint_in_use(self.hass, blueprint_path):
if self.blueprint_in_use(self.hass, blueprint_path):
raise BlueprintInUse(self.domain, blueprint_path)
path = self.blueprint_folder / blueprint_path
await self.hass.async_add_executor_job(path.unlink)
@@ -362,7 +362,7 @@ class DomainBlueprints:
self._blueprints[blueprint_path] = blueprint
if overrides_existing:
await self._reload_blueprint_consumers(self.hass, blueprint_path)
await self.reload_blueprint_consumers(self.hass, blueprint_path)
return overrides_existing

View File

@@ -0,0 +1,293 @@
"""Update entities for blueprints."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import logging
from datetime import timedelta
from typing import Any, Final
from homeassistant.components import automation, script
from . import importer, models
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
from homeassistant.const import CONF_SOURCE_URL
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import event as event_helper
from homeassistant.helpers.entity import EntityCategory
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN as BLUEPRINT_DOMAIN
from .errors import BlueprintException
_LOGGER = logging.getLogger(__name__)
_LATEST_VERSION_PLACEHOLDER: Final = "remote"
DATA_UPDATE_MANAGER: Final = "update_manager"
REFRESH_INTERVAL: Final = timedelta(days=1)
@dataclass(slots=True)
class BlueprintUsage:
"""Details about a blueprint currently in use."""
domain: str
path: str
domain_blueprints: models.DomainBlueprints
blueprint: models.Blueprint
entities: list[str]
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the blueprint update platform."""
data = hass.data.setdefault(BLUEPRINT_DOMAIN, {})
if (manager := data.get(DATA_UPDATE_MANAGER)) is None:
manager = BlueprintUpdateManager(hass, async_add_entities)
data[DATA_UPDATE_MANAGER] = manager
await manager.async_start()
return
manager.replace_add_entities(async_add_entities)
await manager.async_recreate_entities()
class BlueprintUpdateManager:
"""Manage blueprint update entities based on blueprint usage."""
def __init__(
self, hass: HomeAssistant, async_add_entities: AddEntitiesCallback
) -> None:
"""Initialize the manager."""
self.hass = hass
self._async_add_entities = async_add_entities
self._entities: dict[tuple[str, str], BlueprintUpdateEntity] = {}
self._lock = asyncio.Lock()
self._refresh_cancel: CALLBACK_TYPE | None = None
self._started = False
self._interval_unsub: CALLBACK_TYPE | None = None
async def async_start(self) -> None:
"""Start tracking blueprint usage."""
if self._started:
return
self._started = True
self._interval_unsub = event_helper.async_track_time_interval(
self.hass, self._handle_time_interval, REFRESH_INTERVAL
)
await self.async_refresh_entities()
def replace_add_entities(self, async_add_entities: AddEntitiesCallback) -> None:
"""Update the callback used to register entities."""
self._async_add_entities = async_add_entities
async def async_recreate_entities(self) -> None:
"""Recreate entities after the platform has been reloaded."""
async with self._lock:
entities = list(self._entities.values())
self._entities.clear()
for entity in entities:
await entity.async_remove()
await self.async_refresh_entities()
async def async_refresh_entities(self) -> None:
"""Refresh update entities based on current blueprint usage."""
async with self._lock:
usage_map = await self._async_collect_in_use_blueprints()
current_keys = set(self._entities)
new_keys = set(usage_map)
for key in current_keys - new_keys:
entity = self._entities.pop(key)
await entity.async_remove()
new_entities: list[BlueprintUpdateEntity] = []
for key in new_keys - current_keys:
usage = usage_map[key]
entity = BlueprintUpdateEntity(self, usage)
self._entities[key] = entity
new_entities.append(entity)
for key in new_keys & current_keys:
self._entities[key].update_usage(usage_map[key])
self._entities[key].async_write_ha_state()
if new_entities:
self._async_add_entities(new_entities)
def async_schedule_refresh(self) -> None:
"""Schedule an asynchronous refresh."""
if self._refresh_cancel is not None:
return
self._refresh_cancel = event_helper.async_call_later(
self.hass, 0, self._handle_scheduled_refresh
)
@callback
def _handle_scheduled_refresh(self, _now: Any) -> None:
"""Run a scheduled refresh task."""
self._refresh_cancel = None
self.hass.async_create_task(self.async_refresh_entities())
@callback
def _handle_time_interval(self, _now: Any) -> None:
"""Handle scheduled interval refresh."""
self.async_schedule_refresh()
async def _async_collect_in_use_blueprints(self) -> dict[tuple[str, str], BlueprintUsage]:
"""Collect blueprint usage information for automations and scripts."""
usage_keys: set[tuple[str, str]] = set()
if automation.DATA_COMPONENT in self.hass.data:
component = self.hass.data[automation.DATA_COMPONENT]
for automation_entity in list(component.entities):
if (path := getattr(automation_entity, "referenced_blueprint", None)):
usage_keys.add((automation.DOMAIN, path))
if script.DOMAIN in self.hass.data:
component = self.hass.data[script.DOMAIN]
for script_entity in list(component.entities):
if (path := getattr(script_entity, "referenced_blueprint", None)):
usage_keys.add((script.DOMAIN, path))
domain_blueprints_map = self.hass.data.get(BLUEPRINT_DOMAIN, {})
usage_map: dict[tuple[str, str], BlueprintUsage] = {}
for domain, path in usage_keys:
domain_blueprints: models.DomainBlueprints | None = domain_blueprints_map.get(
domain
)
if domain_blueprints is None:
continue
if not domain_blueprints.blueprint_in_use(self.hass, path):
continue
try:
blueprint = await domain_blueprints.async_get_blueprint(path)
except BlueprintException:
continue
source_url = blueprint.metadata.get(CONF_SOURCE_URL)
if not source_url:
continue
if domain == automation.DOMAIN:
entities = automation.automations_with_blueprint(self.hass, path)
elif domain == script.DOMAIN:
entities = script.scripts_with_blueprint(self.hass, path)
else:
entities = []
usage_map[(domain, path)] = BlueprintUsage(
domain=domain,
path=path,
domain_blueprints=domain_blueprints,
blueprint=blueprint,
entities=entities,
)
return usage_map
class BlueprintUpdateEntity(UpdateEntity):
"""Define a blueprint update entity."""
_attr_entity_category = EntityCategory.CONFIG
_attr_has_entity_name = True
_attr_should_poll = False
_attr_supported_features = UpdateEntityFeature.INSTALL
def __init__(self, manager: BlueprintUpdateManager, usage: BlueprintUsage) -> None:
"""Initialize the update entity."""
self._manager = manager
self._domain = usage.domain
self._path = usage.path
self._domain_blueprints = usage.domain_blueprints
self._blueprint = usage.blueprint
self._entities_in_use = usage.entities
self._source_url = usage.blueprint.metadata.get(CONF_SOURCE_URL)
self._attr_unique_id = f"{self._domain}:{self._path}"
self._attr_in_progress = False
self.update_usage(usage)
@callback
def update_usage(self, usage: BlueprintUsage) -> None:
"""Update the entity with latest usage information."""
self._domain_blueprints = usage.domain_blueprints
self._blueprint = usage.blueprint
self._entities_in_use = usage.entities
self._source_url = usage.blueprint.metadata.get(CONF_SOURCE_URL)
self._attr_name = usage.blueprint.name
self._attr_release_summary = usage.blueprint.metadata.get("description")
self._attr_installed_version = usage.blueprint.metadata.get("version")
self._attr_release_url = self._source_url
self._attr_available = self._source_url is not None
self._attr_latest_version = (
_LATEST_VERSION_PLACEHOLDER
if self._source_url is not None
else self._attr_installed_version
)
async def async_install(self, version: str | None, backup: bool) -> None:
"""Install (refresh) the blueprint from its source."""
if self._source_url is None:
raise HomeAssistantError("Blueprint does not define a source URL")
self._attr_in_progress = True
self.async_write_ha_state()
usage: BlueprintUsage | None = None
try:
imported = await importer.fetch_blueprint_from_url(
self.hass, self._source_url
)
blueprint = imported.blueprint
if blueprint.domain != self._domain:
raise HomeAssistantError(
"Downloaded blueprint domain does not match the existing blueprint"
)
await self._domain_blueprints.async_add_blueprint(
blueprint, self._path, allow_override=True
)
usage = BlueprintUsage(
domain=self._domain,
path=self._path,
domain_blueprints=self._domain_blueprints,
blueprint=blueprint,
entities=self._entities_in_use,
)
except HomeAssistantError:
raise
except Exception as err: # noqa: BLE001 - Provide context for unexpected errors
raise HomeAssistantError("Failed to update blueprint from source") from err
finally:
self._attr_in_progress = False
if usage is not None:
self.update_usage(usage)
self.async_write_ha_state()
self._manager.async_schedule_refresh()

View File

@@ -27,9 +27,6 @@ from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import BrotherConfigEntry, BrotherDataUpdateCoordinator
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
ATTR_COUNTER = "counter"
ATTR_REMAINING_PAGES = "remaining_pages"

View File

@@ -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),

View File

@@ -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}"

View File

@@ -81,9 +81,6 @@
"active_map": {
"default": "mdi:floor-plan"
},
"auto_empty": {
"default": "mdi:delete-empty"
},
"water_amount": {
"default": "mdi:water"
},
@@ -92,6 +89,9 @@
}
},
"sensor": {
"auto_empty": {
"default": "mdi:delete-empty"
},
"error": {
"default": "mdi:alert-circle"
},

View File

@@ -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"]
}

View File

@@ -5,9 +5,8 @@ from dataclasses import dataclass
from typing import TYPE_CHECKING, Any
from deebot_client.capabilities import CapabilityMap, CapabilitySet, CapabilitySetTypes
from deebot_client.command import CommandWithMessageHandling
from deebot_client.device import Device
from deebot_client.events import WorkModeEvent, auto_empty
from deebot_client.events import WorkModeEvent
from deebot_client.events.base import Event
from deebot_client.events.map import CachedMapInfoEvent, MajorMapEvent
from deebot_client.events.water_info import WaterAmountEvent
@@ -35,9 +34,6 @@ class EcovacsSelectEntityDescription[EventT: Event](
current_option_fn: Callable[[EventT], str | None]
options_fn: Callable[[CapabilitySetTypes], list[str]]
set_option_fn: Callable[[CapabilitySetTypes, str], CommandWithMessageHandling] = (
lambda cap, option: cap.set(option)
)
ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = (
@@ -62,14 +58,6 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSelectEntityDescription, ...] = (
entity_registry_enabled_default=False,
entity_category=EntityCategory.CONFIG,
),
EcovacsSelectEntityDescription[auto_empty.AutoEmptyEvent](
capability_fn=lambda caps: caps.station.auto_empty if caps.station else None,
current_option_fn=lambda e: get_name_key(e.frequency) if e.frequency else None,
options_fn=lambda cap: [get_name_key(freq) for freq in cap.types],
set_option_fn=lambda cap, option: cap.set(None, option),
key="auto_empty",
translation_key="auto_empty",
),
)
@@ -118,17 +106,14 @@ class EcovacsSelectEntity[EventT: Event](
await super().async_added_to_hass()
async def on_event(event: EventT) -> None:
if (option := self.entity_description.current_option_fn(event)) is not None:
self._attr_current_option = option
self.async_write_ha_state()
self._attr_current_option = self.entity_description.current_option_fn(event)
self.async_write_ha_state()
self._subscribe(self._capability.event, on_event)
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self._device.execute_command(
self.entity_description.set_option_fn(self._capability, option)
)
await self._device.execute_command(self._capability.set(option))
class EcovacsActiveMapSelectEntity(

View File

@@ -17,6 +17,7 @@ from deebot_client.events import (
NetworkInfoEvent,
StatsEvent,
TotalStatsEvent,
auto_empty,
station,
)
from sucks import VacBot
@@ -158,6 +159,14 @@ ENTITY_DESCRIPTIONS: tuple[EcovacsSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=get_options(station.State),
),
EcovacsSensorEntityDescription[auto_empty.AutoEmptyEvent](
capability_fn=lambda caps: caps.station.auto_empty if caps.station else None,
value_fn=lambda e: get_name_key(e.frequency) if e.frequency else None,
key="auto_empty",
translation_key="auto_empty",
device_class=SensorDeviceClass.ENUM,
options=get_options(auto_empty.Frequency),
),
)

View File

@@ -129,16 +129,6 @@
"active_map": {
"name": "Active map"
},
"auto_empty": {
"name": "Auto-empty frequency",
"state": {
"auto": "Auto",
"min_10": "10 minutes",
"min_15": "15 minutes",
"min_25": "25 minutes",
"smart": "Smart"
}
},
"water_amount": {
"name": "[%key:component::ecovacs::entity::number::water_amount::name%]",
"state": {
@@ -159,6 +149,13 @@
}
},
"sensor": {
"auto_empty": {
"name": "Auto-empty frequency",
"state": {
"auto": "Auto",
"smart": "Smart"
}
},
"error": {
"name": "Error",
"state_attributes": {

View File

@@ -296,7 +296,7 @@ class Elkm1ConfigFlow(ConfigFlow, domain=DOMAIN):
return await self.async_step_discovered_connection()
return await self.async_step_manual_connection()
current_unique_ids = self._async_current_ids(include_ignore=False)
current_unique_ids = self._async_current_ids()
current_hosts = {
hostname_from_url(entry.data[CONF_HOST])
for entry in self._async_current_entries(include_ignore=False)

View File

@@ -77,7 +77,7 @@ class EufyLifeConfigFlow(ConfigFlow, domain=DOMAIN):
data={CONF_MODEL: model},
)
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if (

View File

@@ -129,51 +129,6 @@ class FireflyConfigFlow(ConfigFlow, domain=DOMAIN):
errors=errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration of the integration."""
errors: dict[str, str] = {}
reconf_entry = self._get_reconfigure_entry()
if user_input:
try:
await _validate_input(
self.hass,
data={
**reconf_entry.data,
**user_input,
},
)
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
except FireflyClientTimeout:
errors["base"] = "timeout_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
self._async_abort_entries_match({CONF_URL: user_input[CONF_URL]})
return self.async_update_reload_and_abort(
reconf_entry,
data_updates={
CONF_URL: user_input[CONF_URL],
CONF_API_KEY: user_input[CONF_API_KEY],
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
},
)
return self.async_show_form(
step_id="reconfigure",
data_schema=self.add_suggested_values_to_schema(
data_schema=STEP_USER_DATA_SCHEMA,
suggested_values=user_input or reconf_entry.data.copy(),
),
errors=errors,
)
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""

View File

@@ -2,8 +2,7 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -21,20 +20,6 @@
},
"description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Profile** and select the **OAuth** tab. Create a new personal access token and copy it (it will only display once)."
},
"reconfigure": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"api_key": "[%key:component::firefly_iii::config::step::user::data_description::api_key%]",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:component::firefly_iii::config::step::user::data_description::verify_ssl%]"
},
"description": "Use the following form to reconfigure your Firefly III instance.",
"title": "Reconfigure Firefly III Integration"
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",

View File

@@ -43,9 +43,6 @@ from .coordinator import GiosConfigEntry, GiosDataUpdateCoordinator
_LOGGER = logging.getLogger(__name__)
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class GiosSensorEntityDescription(SensorEntityDescription):

View File

@@ -14,10 +14,6 @@
"name": "[%key:common::config_flow::data::name%]",
"station_id": "Measuring station"
},
"data_description": {
"name": "Config entry name, by default, this is the name of your Home Assistant instance.",
"station_id": "The name of the measuring station where the environmental data is collected."
},
"title": "GIO\u015a (Polish Chief Inspectorate Of Environmental Protection)"
}
}

View File

@@ -38,7 +38,6 @@ from homeassistant.const import (
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv, issue_registry as ir, storage
from homeassistant.helpers.hassio import is_hassio
from homeassistant.helpers.http import (
KEY_ALLOW_CONFIGURED_CORS,
KEY_AUTHENTICATED, # noqa: F401
@@ -108,10 +107,9 @@ _DEFAULT_BIND = ["0.0.0.0", "::"] if _HAS_IPV6 else ["0.0.0.0"]
HTTP_SCHEMA: Final = vol.All(
cv.deprecated(CONF_BASE_URL),
cv.deprecated(CONF_SERVER_HOST), # Deprecated in HA Core 2025.11
vol.Schema(
{
vol.Optional(CONF_SERVER_HOST): vol.All(
vol.Optional(CONF_SERVER_HOST, default=_DEFAULT_BIND): vol.All(
cv.ensure_list, vol.Length(min=1), [cv.string]
),
vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port,
@@ -209,24 +207,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if conf is None:
conf = cast(ConfData, HTTP_SCHEMA({}))
if CONF_SERVER_HOST in conf:
if is_hassio(hass):
issue_id = "server_host_deprecated_hassio"
severity = ir.IssueSeverity.ERROR
else:
issue_id = "server_host_deprecated"
severity = ir.IssueSeverity.WARNING
ir.async_create_issue(
hass,
DOMAIN,
issue_id,
breaks_in_ha_version="2026.5.0",
is_fixable=False,
severity=severity,
translation_key=issue_id,
)
server_host = conf.get(CONF_SERVER_HOST, _DEFAULT_BIND)
server_host = conf[CONF_SERVER_HOST]
server_port = conf[CONF_SERVER_PORT]
ssl_certificate = conf.get(CONF_SSL_CERTIFICATE)
ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE)

View File

@@ -1,13 +1,5 @@
{
"issues": {
"server_host_deprecated": {
"description": "The `server_host` configuration option in the HTTP integration is deprecated and will be removed in a future release.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.",
"title": "The `server_host` HTTP configuration option is deprecated"
},
"server_host_deprecated_hassio": {
"description": "The deprecated `server_host` configuration option in the HTTP integration is prone to break the communication between Home Assistant Core and supervisor, and will be removed in a future release.\n\nIf you are using this option to bind Home Assistant to specific network interfaces, please remove it from your configuration. Home Assistant will automatically bind to all available interfaces by default.\n\nIf you have specific networking requirements, consider using firewall rules or other network configuration to control access to Home Assistant.",
"title": "The `server_host` HTTP configuration may break Home Assistant Core - Supervisor communication"
},
"ssl_configured_without_configured_urls": {
"description": "Home Assistant detected that SSL has been set up on your instance, however, no custom external internet URL has been set.\n\nThis may result in unexpected behavior. Text-to-speech may fail, and integrations may not be able to connect back to your instance correctly.\n\nTo address this issue, go to Settings > System > Network; under the \"Home Assistant URL\" section, configure your new \"Internet\" and \"Local network\" addresses that match your new SSL configuration.",
"title": "SSL is configured without an external URL or internal URL"

View File

@@ -72,7 +72,7 @@ class KegtronConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@@ -85,7 +85,7 @@ class MicroBotConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovered_adv:
self._discovered_advs[discovery.address] = discovery
else:
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass):
self._ble_device = discovery_info.device
address = discovery_info.address

View File

@@ -299,8 +299,8 @@ def _create_climate_ui(xknx: XKNX, conf: ConfigExtractor, name: str) -> XknxClim
group_address_active_state=conf.get_state_and_passive(CONF_GA_ACTIVE),
group_address_command_value_state=conf.get_state_and_passive(CONF_GA_VALVE),
sync_state=sync_state,
min_temp=conf.get(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

View File

@@ -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
}

View File

@@ -106,7 +106,7 @@ class KulerskyConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery in async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses

View File

@@ -79,7 +79,7 @@ class Ld2410BleConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery in async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses

View File

@@ -35,7 +35,7 @@ class LeaoneConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@@ -85,7 +85,7 @@ class LedBleConfigFlow(ConfigFlow, domain=DOMAIN):
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery in async_discovered_service_info(self.hass):
if (
discovery.address in current_addresses

View File

@@ -93,7 +93,7 @@ class InspectorBLEConfigFlow(ConfigFlow, domain=DOMAIN):
self._discovery_info = self._discovered_devices[address]
return await self.async_step_check_connection()
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@@ -49,15 +49,6 @@ ATA_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
value_fn=lambda x: x.device.total_energy_consumed,
enabled=lambda x: x.device.has_energy_consumed_meter,
),
MelcloudSensorEntityDescription(
key="outside_temperature",
translation_key="outside_temperature",
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
device_class=SensorDeviceClass.TEMPERATURE,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda x: x.device.outdoor_temperature,
enabled=lambda x: x.device.has_outdoor_temperature,
),
)
ATW_SENSORS: tuple[MelcloudSensorEntityDescription, ...] = (
MelcloudSensorEntityDescription(

View File

@@ -75,7 +75,7 @@ class MelnorConfigFlow(ConfigFlow, domain=DOMAIN):
return self._create_entry(address)
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(
self.hass, connectable=True
):

View File

@@ -9,7 +9,7 @@
"iot_class": "cloud_push",
"loggers": ["pymiele"],
"quality_scale": "platinum",
"requirements": ["pymiele==0.6.0"],
"requirements": ["pymiele==0.5.6"],
"single_config_entry": true,
"zeroconf": ["_mieleathome._tcp.local."]
}

View File

@@ -72,7 +72,7 @@ class MoatConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@@ -19,11 +19,7 @@ from .const import DOMAIN, MANUFACTURER, SUPPORT_EMAIL
from .coordinator import NASwebCoordinator
from .nasweb_data import NASwebData
PLATFORMS: list[Platform] = [
Platform.ALARM_CONTROL_PANEL,
Platform.SENSOR,
Platform.SWITCH,
]
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
NASWEB_CONFIG_URL = "https://{host}/page"

View File

@@ -1,154 +0,0 @@
"""Platform for NASweb alarms."""
from __future__ import annotations
import logging
import time
from webio_api import Zone as NASwebZone
from webio_api.const import STATE_ZONE_ALARM, STATE_ZONE_ARMED, STATE_ZONE_DISARMED
from homeassistant.components.alarm_control_panel import (
DOMAIN as DOMAIN_ALARM_CONTROL_PANEL,
AlarmControlPanelEntity,
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
CodeFormat,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
import homeassistant.helpers.entity_registry as er
from homeassistant.helpers.typing import DiscoveryInfoType
from homeassistant.helpers.update_coordinator import (
BaseCoordinatorEntity,
BaseDataUpdateCoordinatorProtocol,
)
from . import NASwebConfigEntry
from .const import DOMAIN, STATUS_UPDATE_MAX_TIME_INTERVAL
_LOGGER = logging.getLogger(__name__)
ALARM_CONTROL_PANEL_TRANSLATION_KEY = "zone"
NASWEB_STATE_TO_HA_STATE = {
STATE_ZONE_ALARM: AlarmControlPanelState.TRIGGERED,
STATE_ZONE_ARMED: AlarmControlPanelState.ARMED_AWAY,
STATE_ZONE_DISARMED: AlarmControlPanelState.DISARMED,
}
async def async_setup_entry(
hass: HomeAssistant,
config: NASwebConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up alarm control panel platform."""
coordinator = config.runtime_data
current_zones: set[int] = set()
@callback
def _check_entities() -> None:
received_zones: dict[int, NASwebZone] = {
entry.index: entry for entry in coordinator.webio_api.zones
}
added = {i for i in received_zones if i not in current_zones}
removed = {i for i in current_zones if i not in received_zones}
entities_to_add: list[ZoneEntity] = []
for index in added:
webio_zone = received_zones[index]
if not isinstance(webio_zone, NASwebZone):
_LOGGER.error("Cannot create ZoneEntity without NASwebZone")
continue
new_zone = ZoneEntity(coordinator, webio_zone)
entities_to_add.append(new_zone)
current_zones.add(index)
async_add_entities(entities_to_add)
entity_registry = er.async_get(hass)
for index in removed:
unique_id = f"{DOMAIN}.{config.unique_id}.zone.{index}"
if entity_id := entity_registry.async_get_entity_id(
DOMAIN_ALARM_CONTROL_PANEL, DOMAIN, unique_id
):
entity_registry.async_remove(entity_id)
current_zones.remove(index)
else:
_LOGGER.warning("Failed to remove old zone: no entity_id")
coordinator.async_add_listener(_check_entities)
_check_entities()
class ZoneEntity(AlarmControlPanelEntity, BaseCoordinatorEntity):
"""Entity representing NASweb zone."""
_attr_has_entity_name = True
_attr_should_poll = False
_attr_translation_key = ALARM_CONTROL_PANEL_TRANSLATION_KEY
def __init__(
self, coordinator: BaseDataUpdateCoordinatorProtocol, nasweb_zone: NASwebZone
) -> None:
"""Initialize zone entity."""
super().__init__(coordinator)
self._zone = nasweb_zone
self._attr_name = nasweb_zone.name
self._attr_translation_placeholders = {"index": f"{nasweb_zone.index:2d}"}
self._attr_unique_id = (
f"{DOMAIN}.{self._zone.webio_serial}.zone.{self._zone.index}"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._zone.webio_serial)},
)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self._handle_coordinator_update()
def _set_attr_available(
self, entity_last_update: float, available: bool | None
) -> None:
if (
self.coordinator.last_update is None
or time.time() - entity_last_update >= STATUS_UPDATE_MAX_TIME_INTERVAL
):
self._attr_available = False
else:
self._attr_available = available if available is not None else False
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._attr_alarm_state = NASWEB_STATE_TO_HA_STATE[self._zone.state]
if self._zone.pass_type == 0:
self._attr_code_format = CodeFormat.TEXT
elif self._zone.pass_type == 1:
self._attr_code_format = CodeFormat.NUMBER
else:
self._attr_code_format = None
self._attr_code_arm_required = self._attr_code_format is not None
self._set_attr_available(self._zone.last_update, self._zone.available)
self.async_write_ha_state()
async def async_update(self) -> None:
"""Update the entity.
Only used by the generic entity update service.
Scheduling updates is not necessary, the coordinator takes care of updates via push notifications.
"""
@property
def supported_features(self) -> AlarmControlPanelEntityFeature:
"""Return the list of supported features."""
return AlarmControlPanelEntityFeature.ARM_AWAY
async def async_alarm_arm_away(self, code: str | None = None) -> None:
"""Arm away ZoneEntity."""
await self._zone.arm(code)
async def async_alarm_disarm(self, code: str | None = None) -> None:
"""Disarm ZoneEntity."""
await self._zone.disarm(code)

View File

@@ -23,7 +23,6 @@ _LOGGER = logging.getLogger(__name__)
KEY_INPUTS = "inputs"
KEY_OUTPUTS = "outputs"
KEY_ZONES = "zones"
class NotificationCoordinator:
@@ -104,7 +103,6 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol):
KEY_OUTPUTS: self.webio_api.outputs,
KEY_INPUTS: self.webio_api.inputs,
KEY_TEMP_SENSOR: self.webio_api.temp_sensor,
KEY_ZONES: self.webio_api.zones,
}
self.async_set_updated_data(data)
@@ -199,6 +197,5 @@ class NASwebCoordinator(BaseDataUpdateCoordinatorProtocol):
KEY_OUTPUTS: self.webio_api.outputs,
KEY_INPUTS: self.webio_api.inputs,
KEY_TEMP_SENSOR: self.webio_api.temp_sensor,
KEY_ZONES: self.webio_api.zones,
}
self.async_set_updated_data(new_data)

View File

@@ -24,11 +24,6 @@
}
},
"entity": {
"alarm_control_panel": {
"zone": {
"name": "Zone {index}"
}
},
"sensor": {
"sensor_input": {
"name": "Input {index}",

View File

@@ -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

View File

@@ -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"

View File

@@ -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,
),
)

View File

@@ -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)

View File

@@ -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

View File

@@ -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)

View File

@@ -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",
}

View File

@@ -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,
)

View File

@@ -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

View File

@@ -1,7 +0,0 @@
{
"services": {
"custom_cleaning": {
"service": "mdi:broom"
}
}
}

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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:

View File

@@ -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"
}
}
}

View File

@@ -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
)

View File

@@ -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
)

View File

@@ -7,5 +7,5 @@
"iot_class": "cloud_polling",
"loggers": ["pynintendoparental"],
"quality_scale": "bronze",
"requirements": ["pynintendoparental==1.1.3"]
"requirements": ["pynintendoparental==1.1.2"]
}

View File

@@ -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:

View File

@@ -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"]
}

View File

@@ -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"]
}

View File

@@ -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:

View File

@@ -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": {

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
from abc import abstractmethod
from dataclasses import dataclass
from datetime import timedelta
import json
import logging
from typing import TYPE_CHECKING, Any
@@ -174,6 +175,10 @@ class PlaystationNetworkGroupsUpdateCoordinator(
}
)
except PSNAWPForbiddenError as e:
try:
error = json.loads(e.args[0])
except json.JSONDecodeError as err:
raise PSNAWPServerError from err
ir.async_create_issue(
self.hass,
DOMAIN,
@@ -184,7 +189,7 @@ class PlaystationNetworkGroupsUpdateCoordinator(
translation_key="group_chat_forbidden",
translation_placeholders={
CONF_NAME: self.config_entry.title,
"error_message": e.message or "",
"error_message": error["error"]["message"],
},
)
await self.async_shutdown()

View File

@@ -81,5 +81,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["PSNAWP==3.0.1", "pyrate-limiter==3.9.0"]
"requirements": ["PSNAWP==3.0.0", "pyrate-limiter==3.9.0"]
}

View File

@@ -4,6 +4,9 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from pyportainer.models.docker import DockerContainer
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -15,7 +18,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PortainerConfigEntry
from .coordinator import PortainerContainerData, PortainerCoordinator
from .coordinator import PortainerCoordinator
from .entity import (
PortainerContainerEntity,
PortainerCoordinatorData,
@@ -24,31 +27,24 @@ from .entity import (
@dataclass(frozen=True, kw_only=True)
class PortainerContainerBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Class to hold Portainer container binary sensor description."""
class PortainerBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Class to hold Portainer binary sensor description."""
state_fn: Callable[[PortainerContainerData], bool | None]
state_fn: Callable[[Any], bool]
@dataclass(frozen=True, kw_only=True)
class PortainerEndpointBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Class to hold Portainer endpoint binary sensor description."""
state_fn: Callable[[PortainerCoordinatorData], bool | None]
CONTAINER_SENSORS: tuple[PortainerContainerBinarySensorEntityDescription, ...] = (
PortainerContainerBinarySensorEntityDescription(
CONTAINER_SENSORS: tuple[PortainerBinarySensorEntityDescription, ...] = (
PortainerBinarySensorEntityDescription(
key="status",
translation_key="status",
state_fn=lambda data: data.container.state == "running",
state_fn=lambda data: data.state == "running",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
ENDPOINT_SENSORS: tuple[PortainerEndpointBinarySensorEntityDescription, ...] = (
PortainerEndpointBinarySensorEntityDescription(
ENDPOINT_SENSORS: tuple[PortainerBinarySensorEntityDescription, ...] = (
PortainerBinarySensorEntityDescription(
key="status",
translation_key="status",
state_fn=lambda data: data.endpoint.status == 1, # 1 = Running | 2 = Stopped
@@ -80,7 +76,7 @@ async def async_setup_entry(
)
def _async_add_new_containers(
containers: list[tuple[PortainerCoordinatorData, PortainerContainerData]],
containers: list[tuple[PortainerCoordinatorData, DockerContainer]],
) -> None:
"""Add new container binary sensors."""
async_add_entities(
@@ -117,12 +113,12 @@ async def async_setup_entry(
class PortainerEndpointSensor(PortainerEndpointEntity, BinarySensorEntity):
"""Representation of a Portainer endpoint binary sensor entity."""
entity_description: PortainerEndpointBinarySensorEntityDescription
entity_description: PortainerBinarySensorEntityDescription
def __init__(
self,
coordinator: PortainerCoordinator,
entity_description: PortainerEndpointBinarySensorEntityDescription,
entity_description: PortainerBinarySensorEntityDescription,
device_info: PortainerCoordinatorData,
) -> None:
"""Initialize Portainer endpoint binary sensor entity."""
@@ -145,13 +141,13 @@ class PortainerEndpointSensor(PortainerEndpointEntity, BinarySensorEntity):
class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
"""Representation of a Portainer container sensor."""
entity_description: PortainerContainerBinarySensorEntityDescription
entity_description: PortainerBinarySensorEntityDescription
def __init__(
self,
coordinator: PortainerCoordinator,
entity_description: PortainerContainerBinarySensorEntityDescription,
device_info: PortainerContainerData,
entity_description: PortainerBinarySensorEntityDescription,
device_info: DockerContainer,
via_device: PortainerCoordinatorData,
) -> None:
"""Initialize the Portainer container sensor."""
@@ -168,4 +164,6 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.state_fn(self.container_data)
return self.entity_description.state_fn(
self.coordinator.data[self.endpoint_id].containers[self.device_name]
)

View File

@@ -12,6 +12,7 @@ from pyportainer.exceptions import (
PortainerConnectionError,
PortainerTimeoutError,
)
from pyportainer.models.docker import DockerContainer
from homeassistant.components.button import (
ButtonDeviceClass,
@@ -25,11 +26,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PortainerConfigEntry
from .const import DOMAIN
from .coordinator import (
PortainerContainerData,
PortainerCoordinator,
PortainerCoordinatorData,
)
from .coordinator import PortainerCoordinator, PortainerCoordinatorData
from .entity import PortainerContainerEntity
@@ -67,7 +64,7 @@ async def async_setup_entry(
coordinator = entry.runtime_data
def _async_add_new_containers(
containers: list[tuple[PortainerCoordinatorData, PortainerContainerData]],
containers: list[tuple[PortainerCoordinatorData, DockerContainer]],
) -> None:
"""Add new container button sensors."""
async_add_entities(
@@ -100,7 +97,7 @@ class PortainerButton(PortainerContainerEntity, ButtonEntity):
self,
coordinator: PortainerCoordinator,
entity_description: PortainerButtonDescription,
device_info: PortainerContainerData,
device_info: DockerContainer,
via_device: PortainerCoordinatorData,
) -> None:
"""Initialize the Portainer button entity."""

View File

@@ -2,7 +2,6 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
from datetime import timedelta
@@ -14,7 +13,7 @@ from pyportainer import (
PortainerConnectionError,
PortainerTimeoutError,
)
from pyportainer.models.docker import DockerContainer, DockerContainerStats
from pyportainer.models.docker import DockerContainer
from pyportainer.models.docker_inspect import DockerInfo, DockerVersion
from pyportainer.models.portainer import Endpoint
@@ -40,20 +39,11 @@ class PortainerCoordinatorData:
id: int
name: str | None
endpoint: Endpoint
containers: dict[str, PortainerContainerData]
containers: dict[str, DockerContainer]
docker_version: DockerVersion
docker_info: DockerInfo
@dataclass(slots=True)
class PortainerContainerData:
"""Container data held by the Portainer coordinator."""
container: DockerContainer
stats: DockerContainerStats
stats_pre: DockerContainerStats | None
class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorData]]):
"""Data Update Coordinator for Portainer."""
@@ -82,9 +72,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
Callable[[list[PortainerCoordinatorData]], None]
] = []
self.new_containers_callbacks: list[
Callable[
[list[tuple[PortainerCoordinatorData, PortainerContainerData]]], None
]
Callable[[list[tuple[PortainerCoordinatorData, DockerContainer]]], None]
] = []
async def _async_setup(self) -> None:
@@ -131,6 +119,8 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
else:
_LOGGER.debug("Fetched endpoints: %s", endpoints)
mapped_endpoints: dict[int, PortainerCoordinatorData] = {}
for endpoint in endpoints:
@@ -146,47 +136,6 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
containers = await self.portainer.get_containers(endpoint.id)
docker_version = await self.portainer.docker_version(endpoint.id)
docker_info = await self.portainer.docker_info(endpoint.id)
container_map: dict[str, PortainerContainerData] = {}
container_stats_task = [
(
container,
self.portainer.container_stats(
endpoint_id=endpoint.id,
container_id=container.id,
),
)
for container in containers
]
container_stats_gather = await asyncio.gather(
*[task for _, task in container_stats_task],
)
for (container, _), container_stats in zip(
container_stats_task, container_stats_gather, strict=False
):
container_name = container.names[0].replace("/", " ").strip()
# Store previous stats if available. This is used to calculate deltas for CPU and network usage
# In the first call it will be None, since it has nothing to compare with
# Added a walrus pattern to check if not None on prev_container, to keep mypy happy. :)
container_map[container_name] = PortainerContainerData(
container=container,
stats=container_stats,
stats_pre=(
prev_container.stats
if self.data
and (prev_data := self.data.get(endpoint.id)) is not None
and (
prev_container := prev_data.containers.get(
container_name
)
)
is not None
else None
),
)
except PortainerConnectionError as err:
_LOGGER.exception("Connection error")
raise UpdateFailed(
@@ -206,7 +155,10 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
id=endpoint.id,
name=endpoint.name,
endpoint=endpoint,
containers=container_map,
containers={
container.names[0].replace("/", " ").strip(): container
for container in containers
},
docker_version=docker_version,
docker_info=docker_info,
)
@@ -227,7 +179,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
# Surprise, we also handle containers here :)
current_containers = {
(endpoint.id, container.container.id)
(endpoint.id, container.id)
for endpoint in mapped_endpoints.values()
for container in endpoint.containers.values()
}

View File

@@ -30,11 +30,11 @@ def _serialize_coordinator(coordinator: PortainerCoordinator) -> dict[str, Any]:
},
"containers": [
{
"id": container.container.id,
"names": list(container.container.names or []),
"image": container.container.image,
"state": container.container.state,
"status": container.container.status,
"id": container.id,
"names": list(container.names or []),
"image": container.image,
"state": container.state,
"status": container.status,
}
for container in endpoint_data.containers.values()
],

View File

@@ -1,5 +1,6 @@
"""Base class for Portainer entities."""
from pyportainer.models.docker import DockerContainer
from yarl import URL
from homeassistant.const import CONF_URL
@@ -7,11 +8,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DEFAULT_NAME, DOMAIN
from .coordinator import (
PortainerContainerData,
PortainerCoordinator,
PortainerCoordinatorData,
)
from .coordinator import PortainerCoordinator, PortainerCoordinatorData
class PortainerCoordinatorEntity(CoordinatorEntity[PortainerCoordinator]):
@@ -50,22 +47,21 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
def __init__(
self,
device_info: PortainerContainerData,
device_info: DockerContainer,
coordinator: PortainerCoordinator,
via_device: PortainerCoordinatorData,
) -> None:
"""Initialize a Portainer container."""
super().__init__(coordinator)
self._device_info = device_info
self.device_id = self._device_info.container.id
self.device_id = self._device_info.id
self.endpoint_id = via_device.endpoint.id
# Container ID's are ephemeral, so use the container name for the unique ID
# The first one, should always be unique, it's fine if users have aliases
# According to Docker's API docs, the first name is unique
names = self._device_info.container.names
assert names, "Container names list unexpectedly empty"
self.device_name = names[0].replace("/", " ").strip()
assert self._device_info.names, "Container names list unexpectedly empty"
self.device_name = self._device_info.names[0].replace("/", " ").strip()
self._attr_device_info = DeviceInfo(
identifiers={
@@ -83,8 +79,3 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
),
translation_key=None if self.device_name else "unknown_container",
)
@property
def container_data(self) -> PortainerContainerData:
"""Return the coordinator data for this container."""
return self.coordinator.data[self.endpoint_id].containers[self.device_name]

View File

@@ -22,9 +22,6 @@
"cpu_total": {
"default": "mdi:cpu-64-bit"
},
"cpu_usage_total": {
"default": "mdi:cpu-64-bit"
},
"docker_version": {
"default": "mdi:docker"
},
@@ -37,18 +34,9 @@
"kernel_version": {
"default": "mdi:memory"
},
"memory_limit": {
"default": "mdi:memory"
},
"memory_total": {
"default": "mdi:memory"
},
"memory_usage": {
"default": "mdi:memory"
},
"memory_usage_percentage": {
"default": "mdi:memory"
},
"operating_system": {
"default": "mdi:chip"
},

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from pyportainer.models.docker import DockerContainer
from homeassistant.components.sensor import (
EntityCategory,
SensorDeviceClass,
@@ -13,15 +15,11 @@ from homeassistant.components.sensor import (
SensorStateClass,
StateType,
)
from homeassistant.const import PERCENTAGE, UnitOfInformation
from homeassistant.const import UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import (
PortainerConfigEntry,
PortainerContainerData,
PortainerCoordinator,
)
from .coordinator import PortainerConfigEntry, PortainerCoordinator
from .entity import (
PortainerContainerEntity,
PortainerCoordinatorData,
@@ -33,7 +31,7 @@ from .entity import (
class PortainerContainerSensorEntityDescription(SensorEntityDescription):
"""Class to hold Portainer container sensor description."""
value_fn: Callable[[PortainerContainerData], StateType]
value_fn: Callable[[DockerContainer], StateType]
@dataclass(frozen=True, kw_only=True)
@@ -47,70 +45,7 @@ CONTAINER_SENSORS: tuple[PortainerContainerSensorEntityDescription, ...] = (
PortainerContainerSensorEntityDescription(
key="image",
translation_key="image",
value_fn=lambda data: data.container.image,
),
PortainerContainerSensorEntityDescription(
key="memory_limit",
translation_key="memory_limit",
value_fn=lambda data: data.stats.memory_stats.limit,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.MEGABYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
PortainerContainerSensorEntityDescription(
key="memory_usage",
translation_key="memory_usage",
value_fn=lambda data: data.stats.memory_stats.usage,
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.MEGABYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
PortainerContainerSensorEntityDescription(
key="memory_usage_percentage",
translation_key="memory_usage_percentage",
value_fn=lambda data: (
(data.stats.memory_stats.usage / data.stats.memory_stats.limit) * 100.0
if data.stats.memory_stats.limit > 0 and data.stats.memory_stats.usage > 0
else 0.0
),
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
),
PortainerContainerSensorEntityDescription(
key="cpu_usage_total",
translation_key="cpu_usage_total",
value_fn=lambda data: (
(total_delta / system_delta) * data.stats.cpu_stats.online_cpus * 100.0
if (prev := data.stats_pre) is not None
and (
system_delta := (
data.stats.cpu_stats.system_cpu_usage
- prev.cpu_stats.system_cpu_usage
)
)
> 0
and (
total_delta := (
data.stats.cpu_stats.cpu_usage.total_usage
- prev.cpu_stats.cpu_usage.total_usage
)
)
>= 0
and data.stats.cpu_stats.online_cpus > 0
else 0.0
),
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.image,
),
)
ENDPOINT_SENSORS: tuple[PortainerEndpointSensorEntityDescription, ...] = (
@@ -239,7 +174,7 @@ async def async_setup_entry(
)
def _async_add_new_containers(
containers: list[tuple[PortainerCoordinatorData, PortainerContainerData]],
containers: list[tuple[PortainerCoordinatorData, DockerContainer]],
) -> None:
"""Add new container sensors."""
async_add_entities(
@@ -251,7 +186,7 @@ async def async_setup_entry(
)
for (endpoint, container) in containers
for entity_description in CONTAINER_SENSORS
if entity_description.value_fn(container) is not None
if entity_description.value_fn(container)
)
coordinator.new_endpoints_callbacks.append(_async_add_new_endpoints)
@@ -282,7 +217,7 @@ class PortainerContainerSensor(PortainerContainerEntity, SensorEntity):
self,
coordinator: PortainerCoordinator,
entity_description: PortainerContainerSensorEntityDescription,
device_info: PortainerContainerData,
device_info: DockerContainer,
via_device: PortainerCoordinatorData,
) -> None:
"""Initialize the Portainer container sensor."""
@@ -299,7 +234,9 @@ class PortainerContainerSensor(PortainerContainerEntity, SensorEntity):
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.container_data)
return self.entity_description.value_fn(
self.coordinator.data[self.endpoint_id].containers[self.device_name]
)
class PortainerEndpointSensor(PortainerEndpointEntity, SensorEntity):

View File

@@ -83,9 +83,6 @@
"cpu_total": {
"name": "Total CPU"
},
"cpu_usage_total": {
"name": "CPU usage total"
},
"docker_version": {
"name": "Docker version"
},
@@ -98,18 +95,9 @@
"kernel_version": {
"name": "Kernel version"
},
"memory_limit": {
"name": "Memory limit"
},
"memory_total": {
"name": "Total memory"
},
"memory_usage": {
"name": "Memory usage"
},
"memory_usage_percentage": {
"name": "Memory usage percentage"
},
"operating_system": {
"name": "Operating system"
},

View File

@@ -12,6 +12,7 @@ from pyportainer.exceptions import (
PortainerConnectionError,
PortainerTimeoutError,
)
from pyportainer.models.docker import DockerContainer
from homeassistant.components.switch import (
SwitchDeviceClass,
@@ -24,7 +25,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PortainerConfigEntry
from .const import DOMAIN
from .coordinator import PortainerContainerData, PortainerCoordinator
from .coordinator import PortainerCoordinator
from .entity import PortainerContainerEntity, PortainerCoordinatorData
@@ -32,7 +33,7 @@ from .entity import PortainerContainerEntity, PortainerCoordinatorData
class PortainerSwitchEntityDescription(SwitchEntityDescription):
"""Class to hold Portainer switch description."""
is_on_fn: Callable[[PortainerContainerData], bool | None]
is_on_fn: Callable[[DockerContainer], bool | None]
turn_on_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]]
@@ -71,7 +72,7 @@ SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = (
key="container",
translation_key="container",
device_class=SwitchDeviceClass.SWITCH,
is_on_fn=lambda data: data.container.state == "running",
is_on_fn=lambda data: data.state == "running",
turn_on_fn=perform_action,
turn_off_fn=perform_action,
),
@@ -87,7 +88,7 @@ async def async_setup_entry(
coordinator = entry.runtime_data
def _async_add_new_containers(
containers: list[tuple[PortainerCoordinatorData, PortainerContainerData]],
containers: list[tuple[PortainerCoordinatorData, DockerContainer]],
) -> None:
"""Add new container switch sensors."""
async_add_entities(
@@ -120,7 +121,7 @@ class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity):
self,
coordinator: PortainerCoordinator,
entity_description: PortainerSwitchEntityDescription,
device_info: PortainerContainerData,
device_info: DockerContainer,
via_device: PortainerCoordinatorData,
) -> None:
"""Initialize the Portainer container switch."""
@@ -132,7 +133,9 @@ class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity):
@property
def is_on(self) -> bool | None:
"""Return the state of the device."""
return self.entity_description.is_on_fn(self.container_data)
return self.entity_description.is_on_fn(
self.coordinator.data[self.endpoint_id].containers[self.device_name]
)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Start (turn on) the container."""

View File

@@ -99,7 +99,7 @@ class ProbeConfigFlow(ConfigFlow, domain=DOMAIN):
data={**user_input, CONF_MODEL: discovery.discovery_info.name},
)
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@@ -19,5 +19,5 @@
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"quality_scale": "platinum",
"requirements": ["reolink-aio==0.16.3"]
"requirements": ["reolink-aio==0.16.2"]
}

View File

@@ -835,7 +835,6 @@
"vehicle_type": {
"name": "Vehicle type",
"state": {
"bus": "Bus",
"motorcycle": "Motorcycle",
"pickup_truck": "Pickup truck",
"sedan": "Sedan",

View File

@@ -72,7 +72,7 @@ class RuuvitagConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@@ -16,5 +16,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/ruuvitag_ble",
"iot_class": "local_push",
"requirements": ["ruuvitag-ble==0.3.0"]
"requirements": ["ruuvitag-ble==0.2.1"]
}

View File

@@ -104,25 +104,7 @@ SENSOR_DESCRIPTIONS = {
state_class=SensorStateClass.MEASUREMENT,
entity_registry_enabled_default=False,
),
# Keys exported for dataformat 06/e1 sensors in newer versions of ruuvitag-ble
"pm1": SensorEntityDescription(
key=f"{SSDSensorDeviceClass.PM1}_{Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}",
device_class=SensorDeviceClass.PM1,
native_unit_of_measurement=Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
"pm4": SensorEntityDescription(
key=f"{SSDSensorDeviceClass.PM4}_{Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}",
device_class=SensorDeviceClass.PM4,
native_unit_of_measurement=Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
"pm10": SensorEntityDescription(
key=f"{SSDSensorDeviceClass.PM10}_{Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}",
device_class=SensorDeviceClass.PM10,
native_unit_of_measurement=Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
# Keys exported for dataformat 06 sensors in newer versions of ruuvitag-ble
"pm25": SensorEntityDescription(
key=f"{SSDSensorDeviceClass.PM25}_{Units.CONCENTRATION_MICROGRAMS_PER_CUBIC_METER}",
device_class=SensorDeviceClass.PM25,

View File

@@ -74,6 +74,7 @@ from .const import (
CONF_TRACE,
DOMAIN,
ENTITY_ID_FORMAT,
EVENT_SCRIPT_RELOADED,
EVENT_SCRIPT_STARTED,
LOGGER,
)
@@ -237,6 +238,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
if (conf := await component.async_prepare_reload(skip_reset=True)) is None:
return
await _async_process_config(hass, conf, component)
hass.bus.async_fire(EVENT_SCRIPT_RELOADED, context=service.context)
async def turn_on_service(service: ServiceCall) -> None:
"""Call a service to turn script on."""

View File

@@ -17,5 +17,6 @@ CONF_TRACE = "trace"
ENTITY_ID_FORMAT = DOMAIN + ".{}"
EVENT_SCRIPT_STARTED = "script_started"
EVENT_SCRIPT_RELOADED = "script_reloaded"
LOGGER = logging.getLogger(__package__)

View File

@@ -72,7 +72,7 @@ class SensirionConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@@ -72,7 +72,7 @@ class SensorProConfigFlow(ConfigFlow, domain=DOMAIN):
title=self._discovered_devices[address], data={}
)
current_addresses = self._async_current_ids(include_ignore=False)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:

View File

@@ -96,7 +96,7 @@ class SnoozConfigFlow(ConfigFlow, domain=DOMAIN):
self._abort_if_unique_id_configured()
return self._create_snooz_entry(discovered)
configured_addresses = self._async_current_ids(include_ignore=False)
configured_addresses = self._async_current_ids()
for info in async_discovered_service_info(self.hass):
address = info.address

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from datetime import date
import decimal
import logging
from typing import Any
@@ -41,7 +43,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_ADVANCED_OPTIONS, CONF_COLUMN_NAME, CONF_QUERY, DOMAIN
from .util import (
async_create_sessionmaker,
convert_value,
generate_lambda_stmt,
redact_credentials,
resolve_db_url,
@@ -252,6 +253,7 @@ class SQLSensor(ManualTriggerSensorEntity):
def _update(self) -> None:
"""Retrieve sensor data from the query."""
data = None
extra_state_attributes = {}
self._attr_extra_state_attributes = {}
sess: scoped_session = self.sessionmaker()
try:
@@ -270,7 +272,14 @@ class SQLSensor(ManualTriggerSensorEntity):
_LOGGER.debug("Query %s result in %s", self._query, res.items())
data = res[self._column_name]
for key, value in res.items():
self._attr_extra_state_attributes[key] = convert_value(value)
if isinstance(value, decimal.Decimal):
value = float(value)
elif isinstance(value, date):
value = value.isoformat()
elif isinstance(value, (bytes, bytearray)):
value = f"0x{value.hex()}"
extra_state_attributes[key] = value
self._attr_extra_state_attributes[key] = value
if data is not None and isinstance(data, (bytes, bytearray)):
data = f"0x{data.hex()}"

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
import datetime
import decimal
import logging
from sqlalchemy.engine import Result
@@ -24,7 +26,6 @@ from homeassistant.util.json import JsonValueType
from .const import CONF_QUERY, DOMAIN
from .util import (
async_create_sessionmaker,
convert_value,
generate_lambda_stmt,
redact_credentials,
resolve_db_url,
@@ -87,7 +88,14 @@ async def _async_query_service(
for row in result.mappings():
processed_row: dict[str, JsonValueType] = {}
for key, value in row.items():
processed_row[key] = convert_value(value)
if isinstance(value, decimal.Decimal):
processed_row[key] = float(value)
elif isinstance(value, datetime.date):
processed_row[key] = value.isoformat()
elif isinstance(value, (bytes, bytearray)):
processed_row[key] = f"0x{value.hex()}"
else:
processed_row[key] = value
rows.append(processed_row)
return rows
finally:

View File

@@ -2,10 +2,7 @@
from __future__ import annotations
from datetime import date
from decimal import Decimal
import logging
from typing import Any
import sqlalchemy
from sqlalchemy import lambda_stmt
@@ -226,16 +223,3 @@ def generate_lambda_stmt(query: str) -> StatementLambdaElement:
"""Generate the lambda statement."""
text = sqlalchemy.text(query)
return lambda_stmt(lambda: text, lambda_cache=_SQL_LAMBDA_CACHE)
def convert_value(value: Any) -> Any:
"""Convert value."""
match value:
case Decimal():
return float(value)
case date():
return value.isoformat()
case bytes() | bytearray():
return f"0x{value.hex()}"
case _:
return value

View File

@@ -40,17 +40,21 @@ async def async_setup_entry(
) -> None:
"""Set up all sensors for this entry."""
async_add_entities(
description.entity_class(config_entry.runtime_data, description)
StarlinkSensorEntity(config_entry.runtime_data, description)
for description in SENSORS
)
async_add_entities(
StarlinkRestoreSensor(config_entry.runtime_data, description)
for description in RESTORE_SENSORS
)
@dataclass(frozen=True, kw_only=True)
class StarlinkSensorEntityDescription(SensorEntityDescription):
"""Describes a Starlink sensor entity."""
value_fn: Callable[[StarlinkData], datetime | StateType]
entity_class: Callable
class StarlinkSensorEntity(StarlinkEntity, SensorEntity):
@@ -64,14 +68,14 @@ class StarlinkSensorEntity(StarlinkEntity, SensorEntity):
return self.entity_description.value_fn(self.coordinator.data)
class StarlinkAccumulationSensor(StarlinkSensorEntity, RestoreSensor):
"""A StarlinkAccumulationSensor for Starlink devices. Handles creating unique IDs."""
class StarlinkRestoreSensor(StarlinkSensorEntity, RestoreSensor):
"""A RestoreSensorEntity for Starlink devices. Handles creating unique IDs."""
_attr_native_value: int | float = 0
@property
def native_value(self) -> int | float:
"""Calculate new value from current value and the entity description."""
"""Calculate the sensor value from current value and the entity description."""
new_value = super().native_value
if TYPE_CHECKING:
assert isinstance(new_value, (int, float))
@@ -99,7 +103,6 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
suggested_display_precision=0,
value_fn=lambda data: data.status["pop_ping_latency_ms"],
entity_class=StarlinkSensorEntity,
),
StarlinkSensorEntityDescription(
key="azimuth",
@@ -110,7 +113,6 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
suggested_display_precision=0,
value_fn=lambda data: data.status["direction_azimuth"],
entity_class=StarlinkSensorEntity,
),
StarlinkSensorEntityDescription(
key="elevation",
@@ -121,7 +123,6 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
entity_registry_enabled_default=False,
suggested_display_precision=0,
value_fn=lambda data: data.status["direction_elevation"],
entity_class=StarlinkSensorEntity,
),
StarlinkSensorEntityDescription(
key="uplink_throughput",
@@ -132,7 +133,6 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
suggested_display_precision=1,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.status["uplink_throughput_bps"],
entity_class=StarlinkSensorEntity,
),
StarlinkSensorEntityDescription(
key="downlink_throughput",
@@ -143,17 +143,15 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
suggested_display_precision=1,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.status["downlink_throughput_bps"],
entity_class=StarlinkSensorEntity,
),
StarlinkSensorEntityDescription(
key="last_boot_time",
translation_key="last_restart",
translation_key="last_boot_time",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: (
now() - timedelta(seconds=data.status["uptime"], milliseconds=-500)
).replace(microsecond=0),
entity_class=StarlinkSensorEntity,
),
StarlinkSensorEntityDescription(
key="ping_drop_rate",
@@ -161,7 +159,6 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
state_class=SensorStateClass.MEASUREMENT,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda data: data.status["pop_ping_drop_rate"] * 100,
entity_class=StarlinkSensorEntity,
),
StarlinkSensorEntityDescription(
key="power",
@@ -170,8 +167,9 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
suggested_display_precision=0,
value_fn=lambda data: data.consumption["latest_power"],
entity_class=StarlinkSensorEntity,
),
)
RESTORE_SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
StarlinkSensorEntityDescription(
key="energy",
device_class=SensorDeviceClass.ENERGY,
@@ -179,7 +177,6 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
suggested_display_precision=1,
value_fn=lambda data: data.consumption["total_energy"],
entity_class=StarlinkAccumulationSensor,
),
StarlinkSensorEntityDescription(
key="download",
@@ -190,7 +187,6 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
suggested_display_precision=1,
suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
value_fn=lambda data: data.usage["download_usage"],
entity_class=StarlinkAccumulationSensor,
),
StarlinkSensorEntityDescription(
key="upload",
@@ -201,6 +197,5 @@ SENSORS: tuple[StarlinkSensorEntityDescription, ...] = (
suggested_display_precision=1,
suggested_unit_of_measurement=UnitOfInformation.GIGABYTES,
value_fn=lambda data: data.usage["upload_usage"],
entity_class=StarlinkAccumulationSensor,
),
)

View File

@@ -62,8 +62,8 @@
"elevation": {
"name": "[%key:common::config_flow::data::elevation%]"
},
"last_restart": {
"name": "Last restart"
"last_boot_time": {
"name": "Last boot time"
},
"ping": {
"name": "Ping"

View File

@@ -137,7 +137,7 @@ class SteamistConfigFlow(ConfigFlow, domain=DOMAIN):
device = self._discovered_devices[mac]
return self._async_create_entry_from_device(device)
current_unique_ids = self._async_current_ids(include_ignore=False)
current_unique_ids = self._async_current_ids()
current_hosts = {
entry.data[CONF_HOST]
for entry in self._async_current_entries(include_ignore=False)

View File

@@ -108,8 +108,8 @@ from .const import (
SERVICE_SEND_STICKER,
SERVICE_SEND_VIDEO,
SERVICE_SEND_VOICE,
SIGNAL_UPDATE_EVENT,
)
from .helpers import signal
_FILE_TYPES = ("animation", "document", "photo", "sticker", "video", "voice")
_LOGGER = logging.getLogger(__name__)
@@ -169,7 +169,7 @@ class BaseTelegramBot:
_LOGGER.debug("Firing event %s: %s", event_type, event_data)
self.hass.bus.async_fire(event_type, event_data, context=event_context)
async_dispatcher_send(self.hass, signal(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:

View File

@@ -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,
)
)

View File

@@ -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}"

View File

@@ -10,7 +10,6 @@ from kasa import Device, Feature
from homeassistant.components.number import (
DOMAIN as NUMBER_DOMAIN,
NumberDeviceClass,
NumberEntity,
NumberEntityDescription,
NumberMode,
@@ -57,7 +56,6 @@ NUMBER_DESCRIPTIONS: Final = (
TPLinkNumberEntityDescription(
key="temperature_offset",
mode=NumberMode.BOX,
device_class=NumberDeviceClass.TEMPERATURE_DELTA,
),
TPLinkNumberEntityDescription(
key="pan_step",

View File

@@ -1,30 +0,0 @@
"""Base class for Transmission entities."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TransmissionDataUpdateCoordinator
class TransmissionEntity(CoordinatorEntity[TransmissionDataUpdateCoordinator]):
"""Defines a base Transmission entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: TransmissionDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize Transmission entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = (
f"{coordinator.config_entry.entry_id}-{entity_description.key}"
)
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
manufacturer="Transmission",
)

View File

@@ -16,10 +16,13 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import STATE_IDLE, UnitOfDataRate
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import (
DOMAIN,
STATE_ATTR_TORRENT_INFO,
STATE_DOWNLOADING,
STATE_SEEDING,
@@ -27,7 +30,6 @@ from .const import (
SUPPORTED_ORDER_MODES,
)
from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator
from .entity import TransmissionEntity
MODES: dict[str, list[str] | None] = {
"started_torrents": ["downloading"],
@@ -138,10 +140,30 @@ async def async_setup_entry(
)
class TransmissionSensor(TransmissionEntity, SensorEntity):
class TransmissionSensor(
CoordinatorEntity[TransmissionDataUpdateCoordinator], SensorEntity
):
"""A base class for all Transmission sensors."""
entity_description: TransmissionSensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
coordinator: TransmissionDataUpdateCoordinator,
entity_description: TransmissionSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = (
f"{coordinator.config_entry.entry_id}-{entity_description.key}"
)
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
manufacturer="Transmission",
)
@property
def native_value(self) -> StateType:

View File

@@ -6,10 +6,12 @@ from typing import Any
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import TransmissionConfigEntry, TransmissionDataUpdateCoordinator
from .entity import TransmissionEntity
@dataclass(frozen=True, kw_only=True)
@@ -53,10 +55,30 @@ async def async_setup_entry(
)
class TransmissionSwitch(TransmissionEntity, SwitchEntity):
class TransmissionSwitch(
CoordinatorEntity[TransmissionDataUpdateCoordinator], SwitchEntity
):
"""Representation of a Transmission switch."""
entity_description: TransmissionSwitchEntityDescription
_attr_has_entity_name = True
def __init__(
self,
coordinator: TransmissionDataUpdateCoordinator,
entity_description: TransmissionSwitchEntityDescription,
) -> None:
"""Initialize the Transmission switch."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = (
f"{coordinator.config_entry.entry_id}-{entity_description.key}"
)
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
manufacturer="Transmission",
)
@property
def is_on(self) -> bool:

View File

@@ -7,5 +7,5 @@
"integration_type": "helper",
"iot_class": "local_push",
"quality_scale": "internal",
"requirements": ["cronsim==2.7"]
"requirements": ["cronsim==2.6"]
}

Some files were not shown because too many files have changed in this diff Show More