mirror of
https://github.com/home-assistant/core.git
synced 2026-05-13 23:21:47 +00:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6ba411b070 |
@@ -5,7 +5,8 @@
|
||||
# Copilot code review instructions
|
||||
|
||||
- Start review comments with a short, one-sentence summary of the suggested fix.
|
||||
- Do not comment on code style, formatting or linting issues.
|
||||
- Do comment on code style, formatting or linting issues.
|
||||
- When reviewing an integration, follow the instructions in .claude/skills/ha-integration-knowledge/SKILL.md
|
||||
|
||||
# GitHub Copilot & Claude Code Instructions
|
||||
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
---
|
||||
applyTo: "homeassistant/components/**, tests/components/**"
|
||||
excludeAgent: "cloud-agent"
|
||||
---
|
||||
|
||||
<!-- Automatically generated by gen_copilot_instructions.py, do not edit -->
|
||||
|
||||
|
||||
## File Locations
|
||||
- **Integration code**: `./homeassistant/components/<integration_domain>/`
|
||||
- **Integration tests**: `./tests/components/<integration_domain>/`
|
||||
|
||||
## General guidelines
|
||||
|
||||
- When looking for examples, prefer integrations with the platinum or gold quality scale level first.
|
||||
- Polling intervals are NOT user-configurable. Never add scan_interval, update_interval, or polling frequency options to config flows or config entries.
|
||||
- Do NOT allow users to set config entry names in config flows. Names are automatically generated or can be customized later in UI. Exception: helper integrations may allow custom names.
|
||||
- For entity actions and entity services, avoid requesting redundant defensive checks for fields already enforced by Home Assistant validation schemas and entity filters; only request extra guards when values bypass validation or are transformed unsafely.
|
||||
- When validation guarantees a key is present, prefer direct dictionary indexing (`data["key"]`) over `.get("key")` so invalid assumptions fail fast.
|
||||
- Integrations should be thin wrappers. Protocol parsing, device state machines, or other domain logic belong in a separate PyPI library, not in the integration itself. If unsure, ask before inlining.
|
||||
- "potato" is a forbidden word for an integration and should never be used.
|
||||
|
||||
The following platforms have extra guidelines:
|
||||
- **Diagnostics**: [`platform-diagnostics.md`](platform-diagnostics.md) for diagnostic data collection
|
||||
- **Repairs**: [`platform-repairs.md`](platform-repairs.md) for user-actionable repair issues
|
||||
|
||||
|
||||
## Integration Quality Scale
|
||||
|
||||
- When validating the quality scale rules, check them at https://developers.home-assistant.io/docs/core/integration-quality-scale/rules
|
||||
- When implementing or reviewing an integration, always consider the quality scale rules, since they promote best practices.
|
||||
|
||||
Template scale file: `./script/scaffold/templates/integration/integration/quality_scale.yaml`
|
||||
|
||||
### How Rules Apply
|
||||
1. **Check `manifest.json`**: Look for `"quality_scale"` key to determine integration level
|
||||
2. **Bronze Rules**: Always required for any integration with quality scale
|
||||
3. **Higher Tier Rules**: Only apply if integration targets that tier or higher
|
||||
4. **Rule Status**: Check `quality_scale.yaml` in integration folder for:
|
||||
- `done`: Rule implemented
|
||||
- `exempt`: Rule doesn't apply (with reason in comment)
|
||||
- `todo`: Rule needs implementation
|
||||
|
||||
|
||||
## Testing Requirements
|
||||
|
||||
- Tests should avoid interacting or mocking internal integration details. For more info, see https://developers.home-assistant.io/docs/development_testing/#writing-tests-for-integrations
|
||||
@@ -14,7 +14,7 @@ env:
|
||||
UV_HTTP_TIMEOUT: 60
|
||||
UV_SYSTEM_PYTHON: "true"
|
||||
# Base image version from https://github.com/home-assistant/docker
|
||||
BASE_IMAGE_VERSION: "2026.02.0"
|
||||
BASE_IMAGE_VERSION: "2026.01.0"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
permissions: {}
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.14.3
|
||||
3.14.2
|
||||
|
||||
Generated
-2
@@ -1203,8 +1203,6 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/notify_events/ @matrozov @papajojo
|
||||
/homeassistant/components/notion/ @bachya
|
||||
/tests/components/notion/ @bachya
|
||||
/homeassistant/components/novy_cooker_hood/ @piitaya
|
||||
/tests/components/novy_cooker_hood/ @piitaya
|
||||
/homeassistant/components/nrgkick/ @andijakl
|
||||
/tests/components/nrgkick/ @andijakl
|
||||
/homeassistant/components/nsw_fuel_station/ @nickw444
|
||||
|
||||
@@ -38,7 +38,6 @@ HVAC_MODE_MAPPING_ACTRONAIR_TO_HA = {
|
||||
"HEAT": HVACMode.HEAT,
|
||||
"FAN": HVACMode.FAN_ONLY,
|
||||
"AUTO": HVACMode.AUTO,
|
||||
"DRY": HVACMode.DRY,
|
||||
"OFF": HVACMode.OFF,
|
||||
}
|
||||
HVAC_MODE_MAPPING_HA_TO_ACTRONAIR = {
|
||||
@@ -80,6 +79,7 @@ class ActronAirClimateEntity(ClimateEntity):
|
||||
)
|
||||
_attr_name = None
|
||||
_attr_fan_modes = list(FAN_MODE_MAPPING_ACTRONAIR_TO_HA.values())
|
||||
_attr_hvac_modes = list(HVAC_MODE_MAPPING_ACTRONAIR_TO_HA.values())
|
||||
|
||||
|
||||
class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
|
||||
@@ -93,17 +93,6 @@ class ActronSystemClimate(ActronAirAcEntity, ActronAirClimateEntity):
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = self._serial_number
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of supported HVAC modes."""
|
||||
modes = [
|
||||
HVAC_MODE_MAPPING_ACTRONAIR_TO_HA[mode]
|
||||
for mode in self._status.user_aircon_settings.supported_modes
|
||||
if mode in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA
|
||||
]
|
||||
modes.append(HVACMode.OFF)
|
||||
return modes
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature that can be set."""
|
||||
@@ -190,18 +179,6 @@ class ActronZoneClimate(ActronAirZoneEntity, ActronAirClimateEntity):
|
||||
super().__init__(coordinator, zone)
|
||||
self._attr_unique_id: str = self._zone_identifier
|
||||
|
||||
@property
|
||||
def hvac_modes(self) -> list[HVACMode]:
|
||||
"""Return the list of supported HVAC modes."""
|
||||
status = self.coordinator.data
|
||||
modes = [
|
||||
HVAC_MODE_MAPPING_ACTRONAIR_TO_HA[mode]
|
||||
for mode in status.user_aircon_settings.supported_modes
|
||||
if mode in HVAC_MODE_MAPPING_ACTRONAIR_TO_HA
|
||||
]
|
||||
modes.append(HVACMode.OFF)
|
||||
return modes
|
||||
|
||||
@property
|
||||
def min_temp(self) -> float:
|
||||
"""Return the minimum temperature that can be set."""
|
||||
|
||||
@@ -4,10 +4,10 @@ from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from collections.abc import Callable, Mapping
|
||||
from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
from propcache.api import cached_property
|
||||
import voluptuous as vol
|
||||
@@ -229,11 +229,14 @@ def is_disabled_experimental_trigger(hass: HomeAssistant, platform: str) -> bool
|
||||
)
|
||||
|
||||
|
||||
class IfAction(condition_helper.ConditionsChecker):
|
||||
class IfAction(Protocol):
|
||||
"""Define the format of if_action."""
|
||||
|
||||
config: list[ConfigType]
|
||||
|
||||
def __call__(self, variables: Mapping[str, Any] | None = None) -> bool:
|
||||
"""AND all conditions."""
|
||||
|
||||
|
||||
def is_on(hass: HomeAssistant, entity_id: str) -> bool:
|
||||
"""Return true if specified automation entity_id is on.
|
||||
@@ -832,7 +835,7 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
if (
|
||||
not skip_condition
|
||||
and self._condition is not None
|
||||
and not self._condition.async_check(variables=variables)
|
||||
and not self._condition(variables)
|
||||
):
|
||||
self._logger.debug(
|
||||
"Conditions not met, aborting automation. Condition summary: %s",
|
||||
@@ -901,9 +904,6 @@ class AutomationEntity(BaseAutomationEntity, RestoreEntity):
|
||||
"""Remove listeners when removing automation from Home Assistant."""
|
||||
await super().async_will_remove_from_hass()
|
||||
await self._async_disable()
|
||||
self.action_script.async_unload()
|
||||
if self._condition is not None:
|
||||
self._condition.async_unload()
|
||||
|
||||
async def _async_enable_automation(self, event: Event) -> None:
|
||||
"""Start automation on startup."""
|
||||
|
||||
@@ -36,7 +36,6 @@ async def get_axis_api(
|
||||
username=config[CONF_USERNAME],
|
||||
password=config[CONF_PASSWORD],
|
||||
web_proto=config.get(CONF_PROTOCOL, "http"),
|
||||
websocket_enabled=True,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["axis"],
|
||||
"requirements": ["axis==69"],
|
||||
"requirements": ["axis==68"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "AXIS"
|
||||
|
||||
@@ -15,10 +15,7 @@ from aiohttp import web
|
||||
from dateutil.rrule import rrulestr
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.auth.models import User
|
||||
from homeassistant.auth.permissions.const import POLICY_CONTROL, POLICY_READ
|
||||
from homeassistant.components import frontend, http, websocket_api
|
||||
from homeassistant.components.http import KEY_HASS_USER
|
||||
from homeassistant.components.websocket_api import (
|
||||
ERR_INVALID_FORMAT,
|
||||
ERR_NOT_FOUND,
|
||||
@@ -35,7 +32,7 @@ from homeassistant.core import (
|
||||
SupportsResponse,
|
||||
callback,
|
||||
)
|
||||
from homeassistant.exceptions import HomeAssistantError, Unauthorized
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.debounce import Debouncer
|
||||
from homeassistant.helpers.entity import Entity, EntityDescription
|
||||
@@ -789,10 +786,6 @@ class CalendarEventView(http.HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request, entity_id: str) -> web.Response:
|
||||
"""Return calendar events."""
|
||||
user: User = request[KEY_HASS_USER]
|
||||
if not user.permissions.check_entity(entity_id, POLICY_READ):
|
||||
raise Unauthorized(entity_id=entity_id)
|
||||
|
||||
if not (entity := self.component.get_entity(entity_id)) or not isinstance(
|
||||
entity, CalendarEntity
|
||||
):
|
||||
@@ -844,14 +837,10 @@ class CalendarListView(http.HomeAssistantView):
|
||||
|
||||
async def get(self, request: web.Request) -> web.Response:
|
||||
"""Retrieve calendar list."""
|
||||
user: User = request[KEY_HASS_USER]
|
||||
hass = request.app[http.KEY_HASS]
|
||||
entity_perm = user.permissions.check_entity
|
||||
calendar_list: list[dict[str, str]] = []
|
||||
|
||||
for entity in self.component.entities:
|
||||
if not entity_perm(entity.entity_id, POLICY_READ):
|
||||
continue
|
||||
state = hass.states.get(entity.entity_id)
|
||||
assert state
|
||||
calendar_list.append({"name": state.name, "entity_id": entity.entity_id})
|
||||
@@ -871,9 +860,6 @@ async def handle_calendar_event_create(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle creation of a calendar event."""
|
||||
if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL):
|
||||
raise Unauthorized(entity_id=msg["entity_id"])
|
||||
|
||||
if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
|
||||
connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
|
||||
return
|
||||
@@ -913,8 +899,6 @@ async def handle_calendar_event_delete(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle delete of a calendar event."""
|
||||
if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL):
|
||||
raise Unauthorized(entity_id=msg["entity_id"])
|
||||
|
||||
if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
|
||||
connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
|
||||
@@ -960,10 +944,7 @@ async def handle_calendar_event_delete(
|
||||
async def handle_calendar_event_update(
|
||||
hass: HomeAssistant, connection: ActiveConnection, msg: dict[str, Any]
|
||||
) -> None:
|
||||
"""Handle update of a calendar event."""
|
||||
if not connection.user.permissions.check_entity(msg["entity_id"], POLICY_CONTROL):
|
||||
raise Unauthorized(entity_id=msg["entity_id"])
|
||||
|
||||
"""Handle creation of a calendar event."""
|
||||
if not (entity := hass.data[DATA_COMPONENT].get_entity(msg["entity_id"])):
|
||||
connection.send_error(msg["id"], ERR_NOT_FOUND, "Entity not found")
|
||||
return
|
||||
@@ -1008,9 +989,6 @@ async def handle_calendar_event_subscribe(
|
||||
"""Subscribe to calendar event updates."""
|
||||
entity_id: str = msg["entity_id"]
|
||||
|
||||
if not connection.user.permissions.check_entity(entity_id, POLICY_READ):
|
||||
raise Unauthorized(entity_id=entity_id)
|
||||
|
||||
if not (entity := hass.data[DATA_COMPONENT].get_entity(entity_id)):
|
||||
connection.send_error(
|
||||
msg["id"],
|
||||
|
||||
@@ -4,11 +4,7 @@ from collections.abc import Mapping
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.helpers.condition import (
|
||||
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR,
|
||||
Condition,
|
||||
EntityConditionBase,
|
||||
)
|
||||
from homeassistant.helpers.condition import Condition, EntityConditionBase
|
||||
|
||||
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
|
||||
from .models import CoverDomainSpec
|
||||
@@ -18,7 +14,6 @@ class CoverConditionBase(EntityConditionBase):
|
||||
"""Base condition for cover state checks."""
|
||||
|
||||
_domain_specs: Mapping[str, CoverDomainSpec]
|
||||
_schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR
|
||||
|
||||
def is_valid_state(self, entity_state: State) -> bool:
|
||||
"""Check if the state matches the expected cover state."""
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
awning_is_closed:
|
||||
fields: *condition_common_fields
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -11,9 +10,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Awning is closed"
|
||||
@@ -23,9 +19,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Awning is open"
|
||||
@@ -35,9 +28,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Blind is closed"
|
||||
@@ -47,9 +37,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Blind is open"
|
||||
@@ -59,9 +46,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Curtain is closed"
|
||||
@@ -71,9 +55,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Curtain is open"
|
||||
@@ -83,9 +64,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shade is closed"
|
||||
@@ -95,9 +73,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shade is open"
|
||||
@@ -107,9 +82,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shutter is closed"
|
||||
@@ -119,9 +91,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::cover::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::cover::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Shutter is open"
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_closed:
|
||||
fields: *condition_common_fields
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -11,9 +10,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::door::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::door::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Door is closed"
|
||||
@@ -23,9 +19,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::door::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::door::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Door is open"
|
||||
|
||||
@@ -35,7 +35,7 @@ PRESET_AUTO = "auto"
|
||||
# again always round-trips to the same Duco state.
|
||||
_SPEED_LEVEL_PERCENTAGES: list[int] = [
|
||||
(i + 1) * 100 // len(ORDERED_NAMED_FAN_SPEEDS)
|
||||
for i, _ in enumerate(ORDERED_NAMED_FAN_SPEEDS)
|
||||
for i in range(len(ORDERED_NAMED_FAN_SPEEDS))
|
||||
]
|
||||
|
||||
# Maps every active Duco state (including timed MAN variants) to its
|
||||
|
||||
@@ -15,7 +15,7 @@ from homeassistant.config_entries import (
|
||||
OptionsFlow,
|
||||
SubentryFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
|
||||
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.helpers import config_validation as cv, selector
|
||||
|
||||
@@ -94,7 +94,7 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a flow initiated by the user."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(
|
||||
title="",
|
||||
title=user_input[CONF_NAME],
|
||||
data={
|
||||
CONF_LATITUDE: user_input[CONF_LATITUDE],
|
||||
CONF_LONGITUDE: user_input[CONF_LONGITUDE],
|
||||
@@ -118,11 +118,13 @@ class ForecastSolarFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_NAME): str,
|
||||
vol.Required(CONF_LATITUDE): cv.latitude,
|
||||
vol.Required(CONF_LONGITUDE): cv.longitude,
|
||||
}
|
||||
).extend(PLANE_SCHEMA.schema),
|
||||
{
|
||||
CONF_NAME: self.hass.config.location_name,
|
||||
CONF_LATITUDE: self.hass.config.latitude,
|
||||
CONF_LONGITUDE: self.hass.config.longitude,
|
||||
CONF_DECLINATION: DEFAULT_DECLINATION,
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
"declination": "Declination (0 = Horizontal, 90 = Vertical)",
|
||||
"latitude": "[%key:common::config_flow::data::latitude%]",
|
||||
"longitude": "[%key:common::config_flow::data::longitude%]",
|
||||
"modules_power": "Total Watt peak power of your solar modules"
|
||||
"modules_power": "Total Watt peak power of your solar modules",
|
||||
"name": "[%key:common::config_flow::data::name%]"
|
||||
},
|
||||
"description": "Fill in the data of your solar panels. Please refer to the documentation if a field is unclear."
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@ BUTTONS: Final = [
|
||||
device_class=ButtonDeviceClass.UPDATE,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_action=lambda avm_wrapper: avm_wrapper.async_trigger_firmware_update(),
|
||||
entity_registry_enabled_default=False,
|
||||
),
|
||||
FritzButtonDescription(
|
||||
key="reboot",
|
||||
@@ -97,33 +96,6 @@ def repair_issue_cleanup(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None:
|
||||
)
|
||||
|
||||
|
||||
def repair_issue_firmware_update(hass: HomeAssistant, avm_wrapper: AvmWrapper) -> None:
|
||||
"""Repair issue for firmware update button."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
if (
|
||||
(
|
||||
entity_button := entity_registry.async_get_entity_id(
|
||||
"button", DOMAIN, f"{avm_wrapper.unique_id}-firmware_update"
|
||||
)
|
||||
)
|
||||
and (entity_entry := entity_registry.async_get(entity_button))
|
||||
and not entity_entry.disabled
|
||||
):
|
||||
# Deprecate the 'firmware update' button: create a Repairs issue for users
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
domain=DOMAIN,
|
||||
issue_id="deprecated_firmware_update_button",
|
||||
is_fixable=False,
|
||||
is_persistent=True,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="deprecated_firmware_update_button",
|
||||
translation_placeholders={"removal_version": "2026.11.0"},
|
||||
breaks_in_ha_version="2026.11.0",
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: FritzConfigEntry,
|
||||
@@ -140,7 +112,6 @@ async def async_setup_entry(
|
||||
if avm_wrapper.mesh_role == MeshRoles.SLAVE:
|
||||
async_add_entities(entities_list)
|
||||
repair_issue_cleanup(hass, avm_wrapper)
|
||||
repair_issue_firmware_update(hass, avm_wrapper)
|
||||
return
|
||||
|
||||
data_fritz = hass.data[FRITZ_DATA_KEY]
|
||||
@@ -160,7 +131,6 @@ async def async_setup_entry(
|
||||
)
|
||||
|
||||
repair_issue_cleanup(hass, avm_wrapper)
|
||||
repair_issue_firmware_update(hass, avm_wrapper)
|
||||
|
||||
|
||||
class FritzButton(ButtonEntity):
|
||||
@@ -194,12 +164,6 @@ class FritzButton(ButtonEntity):
|
||||
"Please update your automations and dashboards to remove any usage of this button. "
|
||||
"The action is now performed automatically at each data refresh",
|
||||
)
|
||||
elif self.entity_description.key == "firmware_update":
|
||||
_LOGGER.warning(
|
||||
"The 'firmware update' button is deprecated and will be removed in Home Assistant Core "
|
||||
"2026.11.0. It has been superseded by an update entity. Please update your automations "
|
||||
"and dashboards to remove any usage of this button",
|
||||
)
|
||||
await self.entity_description.press_action(self.avm_wrapper)
|
||||
|
||||
|
||||
|
||||
@@ -211,10 +211,6 @@
|
||||
"deprecated_cleanup_button": {
|
||||
"description": "The 'Cleanup' button is deprecated and will be removed in Home Assistant Core {removal_version}. Please update your automations and dashboards to remove any usage of this button. The action is now performed automatically at each data refresh.",
|
||||
"title": "'Cleanup' button is deprecated"
|
||||
},
|
||||
"deprecated_firmware_update_button": {
|
||||
"description": "The 'Firmware update' button is deprecated and will be removed in Home Assistant Core {removal_version}. It has been superseded by an update entity. Please update your automations and dashboards to remove any usage of this button.",
|
||||
"title": "'Firmware update' button is deprecated"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_closed:
|
||||
fields: *condition_common_fields
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -11,9 +10,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::garage_door::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::garage_door::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Garage door is closed"
|
||||
@@ -23,9 +19,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::garage_door::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::garage_door::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Garage door is open"
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_closed:
|
||||
fields: *condition_common_fields
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -11,9 +10,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::gate::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::gate::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gate is closed"
|
||||
@@ -23,9 +19,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::gate::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::gate::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gate is open"
|
||||
|
||||
@@ -47,15 +47,15 @@ SCHEMA_WEBSOCKET_EVENT = vol.Schema(
|
||||
extra=vol.ALLOW_EXTRA,
|
||||
)
|
||||
|
||||
# Endpoints needed for ingress can't require admin because add-ons can set `panel_admin: false`
|
||||
RE_ADDONS_INFO_ENDPOINT = r"/addons/[^/]+/info"
|
||||
WS_ADDONS_INFO_ENDPOINT = re.compile(r"^" + RE_ADDONS_INFO_ENDPOINT + r"$")
|
||||
# Endpoints needed for ingress can't require admin because addons can set `panel_admin: false`
|
||||
# fmt: off
|
||||
WS_NO_ADMIN_ENDPOINTS = re.compile(
|
||||
r"^(?:"
|
||||
r"/ingress/(session|validate_session)"
|
||||
f"|{RE_ADDONS_INFO_ENDPOINT}"
|
||||
r"|/ingress/(session|validate_session)"
|
||||
r"|/addons/[^/]+/info"
|
||||
r")$"
|
||||
)
|
||||
# fmt: on
|
||||
|
||||
_LOGGER: logging.Logger = logging.getLogger(__package__)
|
||||
|
||||
@@ -92,7 +92,6 @@ def websocket_subscribe(
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.ws_require_user(only_supervisor=True)
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required(WS_TYPE): WS_TYPE_EVENT,
|
||||
@@ -151,12 +150,7 @@ async def websocket_supervisor_api(
|
||||
msg[WS_ID], code=websocket_api.ERR_UNKNOWN_ERROR, message=str(err)
|
||||
)
|
||||
else:
|
||||
data = result.get(ATTR_DATA, {})
|
||||
# Remove options from add-on info for non-admin users, as options can contain
|
||||
# sensitive information and the frontend does not require it for ingress.
|
||||
if not connection.user.is_admin and WS_ADDONS_INFO_ENDPOINT.match(command):
|
||||
data.pop("options", None)
|
||||
connection.send_result(msg[WS_ID], data)
|
||||
connection.send_result(msg[WS_ID], result.get(ATTR_DATA, {}))
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
"title": "[%key:component::homeassistant::issues::config_entry_unique_id_collision::title%]"
|
||||
},
|
||||
"country_not_configured": {
|
||||
"description": "No country has been configured. Click the \"Learn more\" button below to set your country.",
|
||||
"description": "No country has been configured, please update the configuration by clicking on the \"learn more\" button below.",
|
||||
"title": "The country has not been configured"
|
||||
},
|
||||
"deprecated_architecture": {
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["rf-protocols==2.2.0"]
|
||||
"requirements": ["rf-protocols==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from aiohue.v2 import HueBridgeV2
|
||||
@@ -30,8 +29,6 @@ ATTR_DYNAMIC = "dynamic"
|
||||
ATTR_SPEED = "speed"
|
||||
ATTR_BRIGHTNESS = "brightness"
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -52,18 +49,10 @@ async def async_setup_entry(
|
||||
event_type: EventType, resource: HueScene | HueSmartScene
|
||||
) -> None:
|
||||
"""Add entity from Hue resource."""
|
||||
# Catch creation errors to continue adding other scenes even if one fails
|
||||
try:
|
||||
entity: HueSceneEntityBase
|
||||
if isinstance(resource, HueSmartScene):
|
||||
entity = HueSmartSceneEntity(bridge, api.scenes, resource)
|
||||
else:
|
||||
entity = HueSceneEntity(bridge, api.scenes, resource)
|
||||
except KeyError, StopIteration:
|
||||
LOGGER.exception("Unable to create Hue scene entity for %s", resource.id)
|
||||
return
|
||||
|
||||
async_add_entities([entity])
|
||||
if isinstance(resource, HueSmartScene):
|
||||
async_add_entities([HueSmartSceneEntity(bridge, api.scenes, resource)])
|
||||
else:
|
||||
async_add_entities([HueSceneEntity(bridge, api.scenes, resource)])
|
||||
|
||||
# add all current items in controller
|
||||
for item in api.scenes:
|
||||
|
||||
@@ -13,11 +13,7 @@ from iaqualink.exception import (
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.util.ssl import SSL_ALPN_HTTP11_HTTP2
|
||||
@@ -88,16 +84,12 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle confirmation of reauthentication."""
|
||||
errors = {}
|
||||
|
||||
config_entry = (
|
||||
self._get_reconfigure_entry()
|
||||
if self.source == SOURCE_RECONFIGURE
|
||||
else self._get_reauth_entry()
|
||||
)
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
if user_input is not None:
|
||||
errors = await self._async_test_credentials(user_input)
|
||||
if not errors:
|
||||
return self.async_update_reload_and_abort(
|
||||
config_entry,
|
||||
reauth_entry,
|
||||
title=user_input[CONF_USERNAME],
|
||||
data_updates={
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
@@ -106,15 +98,7 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id=(
|
||||
"reconfigure" if self.source == SOURCE_RECONFIGURE else "reauth_confirm"
|
||||
),
|
||||
step_id="reauth_confirm",
|
||||
data_schema=CREDENTIALS_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
return await self.async_step_reauth_confirm(user_input)
|
||||
|
||||
@@ -58,7 +58,7 @@ rules:
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: done
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices: todo
|
||||
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"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%]",
|
||||
@@ -20,21 +19,9 @@
|
||||
"password": "[%key:component::iaqualink::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::iaqualink::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "[%key:component::iaqualink::config::step::user::description%]",
|
||||
"description": "Please enter the username and password for your iAquaLink account.",
|
||||
"title": "Reauthenticate iAquaLink"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::iaqualink::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::iaqualink::config::step::user::data_description::username%]"
|
||||
},
|
||||
"description": "[%key:component::iaqualink::config::step::user::description%]",
|
||||
"title": "Reconnect iAquaLink"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -78,10 +78,7 @@ async def async_reload(hass: HomeAssistant, service_call: ServiceCall) -> None:
|
||||
new_config = await async_integration_yaml_config(hass, DOMAIN)
|
||||
existing_intents = hass.data[DOMAIN]
|
||||
|
||||
for intent_type, conf in existing_intents.items():
|
||||
if isinstance(conf.get(CONF_ACTION), script.Script):
|
||||
await conf[CONF_ACTION].async_stop()
|
||||
conf[CONF_ACTION].async_unload()
|
||||
for intent_type in existing_intents:
|
||||
intent.async_remove(hass, intent_type)
|
||||
|
||||
if not new_config or DOMAIN not in new_config:
|
||||
|
||||
@@ -7,7 +7,6 @@ from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from chip.clusters import Objects as clusters
|
||||
from matter_server.common.custom_clusters import HeimanCluster
|
||||
|
||||
from homeassistant.components.button import (
|
||||
ButtonDeviceClass,
|
||||
@@ -169,15 +168,4 @@ DISCOVERY_SCHEMAS = [
|
||||
value_contains=clusters.WaterHeaterManagement.Commands.CancelBoost.command_id,
|
||||
allow_multi=True, # Also used in water_heater
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.BUTTON,
|
||||
entity_description=MatterButtonEntityDescription(
|
||||
key="HeimanSmokeCoAlarmTemporaryMuteRequest",
|
||||
translation_key="temporary_mute_request",
|
||||
command=HeimanCluster.Commands.MutingSensor,
|
||||
),
|
||||
entity_class=MatterCommandButton,
|
||||
required_attributes=(HeimanCluster.Attributes.AcceptedCommandList,),
|
||||
value_contains=HeimanCluster.Commands.MutingSensor.command_id,
|
||||
),
|
||||
]
|
||||
|
||||
@@ -525,6 +525,7 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="EveEnergySensorWatt",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
suggested_display_precision=2,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -552,6 +553,7 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="EveEnergySensorWattAccumulated",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -786,6 +788,7 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="ThirdRealityEnergySensorWatt",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
suggested_display_precision=2,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -802,6 +805,7 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="ThirdRealityEnergySensorWattAccumulated",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -818,6 +822,7 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="NeoEnergySensorWatt",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
suggested_display_precision=2,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
@@ -832,6 +837,7 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="NeoEnergySensorWattAccumulated",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
|
||||
suggested_display_precision=1,
|
||||
state_class=SensorStateClass.TOTAL_INCREASING,
|
||||
@@ -889,6 +895,7 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="ElectricalPowerMeasurementWatt",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfPower.MILLIWATT,
|
||||
suggested_unit_of_measurement=UnitOfPower.WATT,
|
||||
suggested_display_precision=2,
|
||||
@@ -1044,6 +1051,7 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="ElectricalEnergyMeasurementCumulativeEnergyImported",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
@@ -1063,6 +1071,7 @@ DISCOVERY_SCHEMAS = [
|
||||
key="ElectricalEnergyMeasurementCumulativeEnergyExported",
|
||||
translation_key="energy_exported",
|
||||
device_class=SensorDeviceClass.ENERGY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfEnergy.MILLIWATT_HOUR,
|
||||
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
|
||||
suggested_display_precision=3,
|
||||
@@ -1081,6 +1090,7 @@ DISCOVERY_SCHEMAS = [
|
||||
entity_description=MatterSensorEntityDescription(
|
||||
key="ElectricalMeasurementActivePower",
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
suggested_display_precision=2,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
|
||||
@@ -141,9 +141,6 @@
|
||||
},
|
||||
"stop": {
|
||||
"name": "[%key:common::action::stop%]"
|
||||
},
|
||||
"temporary_mute_request": {
|
||||
"name": "Temporary mute"
|
||||
}
|
||||
},
|
||||
"climate": {
|
||||
|
||||
@@ -316,14 +316,4 @@ DISCOVERY_SCHEMAS = [
|
||||
value_contains=clusters.EnergyEvse.Commands.EnableCharging.command_id,
|
||||
allow_multi=True,
|
||||
),
|
||||
MatterDiscoverySchema(
|
||||
platform=Platform.SWITCH,
|
||||
entity_description=MatterNumericSwitchEntityDescription(
|
||||
key="EveChildLock",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
translation_key="child_lock",
|
||||
),
|
||||
entity_class=MatterNumericSwitch,
|
||||
required_attributes=(clusters.EveCluster.Attributes.ChildLock,),
|
||||
),
|
||||
]
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "Model Context Protocol Server",
|
||||
"codeowners": ["@allenporter"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http", "conversation"],
|
||||
"dependencies": ["homeassistant", "http", "conversation"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/mcp_server",
|
||||
"integration_type": "service",
|
||||
"iot_class": "local_push",
|
||||
|
||||
@@ -145,11 +145,6 @@ class NoboZone(NoboBaseEntity, ClimateEntity):
|
||||
@callback
|
||||
def _read_state(self) -> None:
|
||||
"""Read the current state from the hub. These are only local calls."""
|
||||
if self._id not in self._nobo.zones:
|
||||
# Zone removed via the Nobø app; mark unavailable.
|
||||
self._attr_available = False
|
||||
return
|
||||
self._attr_available = True
|
||||
state = self._nobo.get_current_zone_mode(self._id, dt_util.now())
|
||||
self._attr_hvac_mode = HVACMode.AUTO
|
||||
self._attr_preset_mode = PRESET_NONE
|
||||
|
||||
@@ -94,7 +94,6 @@ class NoboGlobalSelector(NoboBaseEntity, SelectEntity):
|
||||
|
||||
@callback
|
||||
def _read_state(self) -> None:
|
||||
"""Read the current state from the hub. These are only local calls."""
|
||||
for override in self._nobo.overrides.values():
|
||||
if override["target_type"] == nobo.API.OVERRIDE_TARGET_GLOBAL:
|
||||
self._attr_current_option = self._modes[override["mode"]]
|
||||
@@ -137,12 +136,6 @@ class NoboProfileSelector(NoboBaseEntity, SelectEntity):
|
||||
|
||||
@callback
|
||||
def _read_state(self) -> None:
|
||||
"""Read the current state from the hub. These are only local calls."""
|
||||
if self._id not in self._nobo.zones:
|
||||
# Zone removed via the Nobø app; mark unavailable.
|
||||
self._attr_available = False
|
||||
return
|
||||
self._attr_available = True
|
||||
self._profiles = {
|
||||
profile["week_profile_id"]: profile["name"].replace("\xa0", " ")
|
||||
for profile in self._nobo.week_profiles.values()
|
||||
|
||||
@@ -71,11 +71,6 @@ class NoboTemperatureSensor(NoboBaseEntity, SensorEntity):
|
||||
@callback
|
||||
def _read_state(self) -> None:
|
||||
"""Read the current state from the hub. This is a local call."""
|
||||
if self._id not in self._nobo.components:
|
||||
# Component removed via the Nobø app; mark unavailable.
|
||||
self._attr_available = False
|
||||
return
|
||||
self._attr_available = True
|
||||
value = self._nobo.get_current_component_temperature(self._id)
|
||||
if value is None:
|
||||
self._attr_native_value = None
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
"""The Novy Cooker Hood integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS: list[Platform] = [Platform.FAN, Platform.LIGHT]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up Novy Cooker Hood from a config entry."""
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
@@ -1,16 +0,0 @@
|
||||
"""Helpers for loading Novy cooker-hood RF commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from rf_protocols import CodeCollection, get_codes
|
||||
|
||||
COMMAND_LIGHT: Final = "light"
|
||||
COMMAND_PLUS: Final = "plus"
|
||||
COMMAND_MINUS: Final = "minus"
|
||||
|
||||
|
||||
def get_codes_for_code(code: int) -> CodeCollection:
|
||||
"""Return the bundled `rf-protocols` collection for a Novy cooker-hood code."""
|
||||
return get_codes(f"novy/cooker_hood/code_{code}")
|
||||
@@ -1,136 +0,0 @@
|
||||
"""Config flow for the Novy Cooker Hood integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.radio_frequency import (
|
||||
async_get_transmitters,
|
||||
async_send_command,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er, selector
|
||||
|
||||
from .commands import COMMAND_LIGHT, get_codes_for_code
|
||||
from .const import (
|
||||
CODE_MAX,
|
||||
CODE_MIN,
|
||||
CONF_CODE,
|
||||
CONF_TRANSMITTER,
|
||||
DEFAULT_CODE,
|
||||
DOMAIN,
|
||||
FREQUENCY,
|
||||
MODULATION,
|
||||
)
|
||||
|
||||
_CODE_OPTIONS = [str(code) for code in range(CODE_MIN, CODE_MAX + 1)]
|
||||
_TOGGLE_GAP = 1.5
|
||||
|
||||
|
||||
class NovyCookerHoodConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Novy Cooker Hood."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the flow."""
|
||||
self._transmitter_entity_id: str | None = None
|
||||
self._transmitter_id: str | None = None
|
||||
self._code: int = DEFAULT_CODE
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Pick a transmitter and code."""
|
||||
try:
|
||||
transmitters = async_get_transmitters(self.hass, FREQUENCY, MODULATION)
|
||||
except HomeAssistantError:
|
||||
return self.async_abort(reason="no_transmitters")
|
||||
|
||||
if not transmitters:
|
||||
return self.async_abort(reason="no_compatible_transmitters")
|
||||
|
||||
if user_input is not None:
|
||||
registry = er.async_get(self.hass)
|
||||
entity_entry = registry.async_get(user_input[CONF_TRANSMITTER])
|
||||
assert entity_entry is not None
|
||||
code = int(user_input[CONF_CODE])
|
||||
await self.async_set_unique_id(f"{entity_entry.id}_{code}")
|
||||
self._abort_if_unique_id_configured()
|
||||
self._transmitter_entity_id = entity_entry.entity_id
|
||||
self._transmitter_id = entity_entry.id
|
||||
self._code = code
|
||||
return await self.async_step_test_light()
|
||||
|
||||
schema: dict[Any, Any] = {
|
||||
vol.Required(
|
||||
CONF_TRANSMITTER,
|
||||
default=self._transmitter_entity_id or vol.UNDEFINED,
|
||||
): selector.EntitySelector(
|
||||
selector.EntitySelectorConfig(include_entities=transmitters),
|
||||
),
|
||||
vol.Required(CONF_CODE, default=str(self._code)): selector.SelectSelector(
|
||||
selector.SelectSelectorConfig(
|
||||
options=_CODE_OPTIONS,
|
||||
mode=selector.SelectSelectorMode.DROPDOWN,
|
||||
translation_key="code",
|
||||
)
|
||||
),
|
||||
}
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(schema),
|
||||
)
|
||||
|
||||
async def async_step_test_light(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Toggle the hood light on then off so it ends in its starting state."""
|
||||
assert self._transmitter_entity_id is not None
|
||||
try:
|
||||
command = await get_codes_for_code(self._code).async_load_command(
|
||||
COMMAND_LIGHT
|
||||
)
|
||||
await async_send_command(self.hass, self._transmitter_entity_id, command)
|
||||
await asyncio.sleep(_TOGGLE_GAP)
|
||||
await async_send_command(self.hass, self._transmitter_entity_id, command)
|
||||
except HomeAssistantError:
|
||||
return await self.async_step_test_failed()
|
||||
return self.async_show_menu(
|
||||
step_id="test_light",
|
||||
menu_options=["finish", "retry"],
|
||||
description_placeholders={"code": str(self._code)},
|
||||
)
|
||||
|
||||
async def async_step_test_failed(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Re-show the failure menu (only Retry available)."""
|
||||
return self.async_show_menu(
|
||||
step_id="test_failed",
|
||||
menu_options=["retry"],
|
||||
description_placeholders={"code": str(self._code)},
|
||||
)
|
||||
|
||||
async def async_step_retry(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Return to the code selection step."""
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_finish(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Create the config entry."""
|
||||
assert self._transmitter_id is not None
|
||||
return self.async_create_entry(
|
||||
title="Novy Cooker Hood",
|
||||
data={
|
||||
CONF_TRANSMITTER: self._transmitter_id,
|
||||
CONF_CODE: self._code,
|
||||
},
|
||||
)
|
||||
@@ -1,21 +0,0 @@
|
||||
"""Constants for the Novy Cooker Hood integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from rf_protocols import ModulationType
|
||||
|
||||
DOMAIN: Final = "novy_cooker_hood"
|
||||
|
||||
CONF_TRANSMITTER: Final = "transmitter"
|
||||
CONF_CODE: Final = "code"
|
||||
|
||||
CODE_MIN: Final = 1
|
||||
CODE_MAX: Final = 10
|
||||
DEFAULT_CODE: Final = 1
|
||||
|
||||
FREQUENCY: Final = 433_920_000
|
||||
MODULATION: Final = ModulationType.OOK
|
||||
|
||||
SPEED_COUNT: Final = 4
|
||||
@@ -1,76 +0,0 @@
|
||||
"""Common entity for the Novy Cooker Hood integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import Event, EventStateChangedData, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .const import CONF_TRANSMITTER, DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class NovyCookerHoodEntity(Entity):
|
||||
"""Novy Cooker Hood base entity."""
|
||||
|
||||
_attr_assumed_state = True
|
||||
_attr_has_entity_name = True
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Initialize the entity."""
|
||||
self._transmitter = entry.data[CONF_TRANSMITTER]
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
manufacturer="Novy",
|
||||
model="Cooker Hood",
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to transmitter entity state changes."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
transmitter_entity_id = er.async_validate_entity_id(
|
||||
er.async_get(self.hass), self._transmitter
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_transmitter_state_changed(
|
||||
event: Event[EventStateChangedData],
|
||||
) -> None:
|
||||
"""Handle transmitter entity state changes."""
|
||||
new_state = event.data["new_state"]
|
||||
transmitter_available = (
|
||||
new_state is not None and new_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
if transmitter_available != self.available:
|
||||
_LOGGER.info(
|
||||
"Transmitter %s used by %s is %s",
|
||||
transmitter_entity_id,
|
||||
self.entity_id,
|
||||
"available" if transmitter_available else "unavailable",
|
||||
)
|
||||
|
||||
self._attr_available = transmitter_available
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass,
|
||||
[transmitter_entity_id],
|
||||
_async_transmitter_state_changed,
|
||||
)
|
||||
)
|
||||
|
||||
transmitter_state = self.hass.states.get(transmitter_entity_id)
|
||||
self._attr_available = (
|
||||
transmitter_state is not None
|
||||
and transmitter_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
@@ -1,143 +0,0 @@
|
||||
"""Fan platform for the Novy Cooker Hood (calibrated speed control)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.fan import ATTR_PERCENTAGE, FanEntity, FanEntityFeature
|
||||
from homeassistant.components.radio_frequency import async_send_command
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.util.percentage import (
|
||||
percentage_to_ranged_value,
|
||||
ranged_value_to_percentage,
|
||||
)
|
||||
|
||||
from .commands import COMMAND_MINUS, COMMAND_PLUS, get_codes_for_code
|
||||
from .const import CONF_CODE, SPEED_COUNT
|
||||
from .entity import NovyCookerHoodEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
_SPEED_RANGE = (1, SPEED_COUNT)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Novy Cooker Hood fan platform."""
|
||||
async_add_entities([NovyCookerHoodFan(config_entry)])
|
||||
|
||||
|
||||
class NovyCookerHoodFan(NovyCookerHoodEntity, FanEntity, RestoreEntity):
|
||||
"""Calibration-based fan: each change resets to off then climbs to target."""
|
||||
|
||||
_attr_name = None
|
||||
_attr_speed_count = SPEED_COUNT
|
||||
_attr_supported_features = (
|
||||
FanEntityFeature.TURN_ON
|
||||
| FanEntityFeature.TURN_OFF
|
||||
| FanEntityFeature.SET_SPEED
|
||||
)
|
||||
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Initialize the fan."""
|
||||
super().__init__(entry)
|
||||
self._codes = get_codes_for_code(entry.data[CONF_CODE])
|
||||
self._level = 0
|
||||
self._attr_unique_id = entry.entry_id
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return whether the fan is currently on."""
|
||||
return self._level > 0
|
||||
|
||||
@property
|
||||
def percentage(self) -> int:
|
||||
"""Return the current speed as a percentage."""
|
||||
if self._level == 0:
|
||||
return 0
|
||||
return ranged_value_to_percentage(_SPEED_RANGE, self._level)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore the last known speed level from the saved percentage."""
|
||||
await super().async_added_to_hass()
|
||||
last = await self.async_get_last_state()
|
||||
if last is None:
|
||||
return
|
||||
last_pct = last.attributes.get(ATTR_PERCENTAGE)
|
||||
if isinstance(last_pct, (int, float)) and last_pct > 0:
|
||||
self._level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, last_pct))
|
||||
|
||||
async def async_turn_on(
|
||||
self,
|
||||
percentage: int | None = None,
|
||||
preset_mode: str | None = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Turn on at the requested level (default = 1)."""
|
||||
if percentage is None or percentage <= 0:
|
||||
level = 1
|
||||
else:
|
||||
level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, percentage))
|
||||
await self._async_set_level(level)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the fan off by sending the calibration sequence to level 0."""
|
||||
await self._async_set_level(0)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the fan speed via calibration."""
|
||||
if percentage <= 0:
|
||||
await self._async_set_level(0)
|
||||
return
|
||||
level = math.ceil(percentage_to_ranged_value(_SPEED_RANGE, percentage))
|
||||
await self._async_set_level(level)
|
||||
|
||||
async def async_increase_speed(self, percentage_step: int | None = None) -> None:
|
||||
"""Bump speed up by N hardware levels (no recalibration)."""
|
||||
steps = self._steps_from_percentage(percentage_step)
|
||||
plus = await self._codes.async_load_command(COMMAND_PLUS)
|
||||
for _ in range(steps):
|
||||
await self._async_send(plus)
|
||||
self._level = min(SPEED_COUNT, self._level + steps)
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_decrease_speed(self, percentage_step: int | None = None) -> None:
|
||||
"""Bump speed down by N hardware levels (no recalibration)."""
|
||||
steps = self._steps_from_percentage(percentage_step)
|
||||
minus = await self._codes.async_load_command(COMMAND_MINUS)
|
||||
for _ in range(steps):
|
||||
await self._async_send(minus)
|
||||
self._level = max(0, self._level - steps)
|
||||
self.async_write_ha_state()
|
||||
|
||||
@staticmethod
|
||||
def _steps_from_percentage(percentage_step: int | None) -> int:
|
||||
"""Convert a percentage step into a number of hardware level presses."""
|
||||
if percentage_step is None:
|
||||
return 1
|
||||
return math.ceil(percentage_step * SPEED_COUNT / 100)
|
||||
|
||||
async def _async_set_level(self, level: int) -> None:
|
||||
"""Reset to off with `SPEED_COUNT` minus presses, then climb to level."""
|
||||
minus = await self._codes.async_load_command(COMMAND_MINUS)
|
||||
for _ in range(SPEED_COUNT):
|
||||
await self._async_send(minus)
|
||||
if level > 0:
|
||||
plus = await self._codes.async_load_command(COMMAND_PLUS)
|
||||
for _ in range(level):
|
||||
await self._async_send(plus)
|
||||
self._level = level
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_send(self, command: Any) -> None:
|
||||
"""Send a single RF command via the configured transmitter."""
|
||||
await async_send_command(
|
||||
self.hass, self._transmitter, command, context=self._context
|
||||
)
|
||||
@@ -1,67 +0,0 @@
|
||||
"""Light platform for the Novy Cooker Hood."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.light import ColorMode, LightEntity
|
||||
from homeassistant.components.radio_frequency import async_send_command
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .commands import COMMAND_LIGHT, get_codes_for_code
|
||||
from .const import CONF_CODE
|
||||
from .entity import NovyCookerHoodEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the Novy Cooker Hood light platform."""
|
||||
async_add_entities([NovyCookerHoodLight(config_entry)])
|
||||
|
||||
|
||||
class NovyCookerHoodLight(NovyCookerHoodEntity, LightEntity, RestoreEntity):
|
||||
"""Novy cooker hood light toggled via a single RF press."""
|
||||
|
||||
_attr_color_mode = ColorMode.ONOFF
|
||||
_attr_supported_color_modes = {ColorMode.ONOFF}
|
||||
_attr_translation_key = "light"
|
||||
|
||||
def __init__(self, entry: ConfigEntry) -> None:
|
||||
"""Initialize the light."""
|
||||
super().__init__(entry)
|
||||
self._codes = get_codes_for_code(entry.data[CONF_CODE])
|
||||
self._attr_unique_id = entry.entry_id
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Restore the last known on/off state."""
|
||||
await super().async_added_to_hass()
|
||||
if (last_state := await self.async_get_last_state()) is not None:
|
||||
self._attr_is_on = last_state.state == STATE_ON
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the light on by sending the toggle command."""
|
||||
await self._async_send_command(COMMAND_LIGHT)
|
||||
self._attr_is_on = True
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the light off by sending the toggle command."""
|
||||
await self._async_send_command(COMMAND_LIGHT)
|
||||
self._attr_is_on = False
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def _async_send_command(self, name: str) -> None:
|
||||
"""Load the named command and send it via the configured transmitter."""
|
||||
command = await self._codes.async_load_command(name)
|
||||
await async_send_command(
|
||||
self.hass, self._transmitter, command, context=self._context
|
||||
)
|
||||
@@ -1,12 +0,0 @@
|
||||
{
|
||||
"domain": "novy_cooker_hood",
|
||||
"name": "Novy Cooker Hood",
|
||||
"codeowners": ["@piitaya"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["radio_frequency"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/novy_cooker_hood",
|
||||
"integration_type": "device",
|
||||
"iot_class": "assumed_state",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["rf-protocols==2.2.0"]
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not register custom service actions.
|
||||
appropriate-polling:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration transmits RF commands and does not poll.
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not register custom service actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup: done
|
||||
entity-unique-id: done
|
||||
has-entity-name: done
|
||||
runtime-data:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use runtime data.
|
||||
test-before-configure: done
|
||||
test-before-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF transmission is a one-way broadcast with no device to contact at setup.
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not authenticate.
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not support discovery.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF devices cannot be discovered.
|
||||
docs-data-update:
|
||||
status: exempt
|
||||
comment: |
|
||||
RF transmission is one-way; there is no data update.
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry represents a single static device.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: |
|
||||
The light entity represents the primary device function.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: |
|
||||
Light entities do not have device classes.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
The light entity represents the primary device function.
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
The light entity uses the default icon for its state.
|
||||
reconfiguration-flow: todo
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: |
|
||||
No known repairable issues.
|
||||
stale-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry represents a single static device.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not use a web session.
|
||||
strict-typing: todo
|
||||
@@ -1,60 +0,0 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"no_compatible_transmitters": "No radio frequency transmitter supports 433.92 MHz OOK transmissions. Please add a compatible transmitter first.",
|
||||
"no_transmitters": "No radio frequency transmitters are available. Please set up a transmitter first."
|
||||
},
|
||||
"step": {
|
||||
"test_failed": {
|
||||
"description": "Could not send the test command for code {code}. Check that your radio frequency transmitter is online, then press Retry.",
|
||||
"menu_options": {
|
||||
"retry": "Retry"
|
||||
},
|
||||
"title": "Test failed"
|
||||
},
|
||||
"test_light": {
|
||||
"description": "Toggled the hood light on and off using code {code}. Did you see it react? Press Finish to save, or Retry to pick a different code.",
|
||||
"menu_options": {
|
||||
"finish": "Finish",
|
||||
"retry": "Retry"
|
||||
},
|
||||
"title": "Verify the code"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"code": "Code",
|
||||
"transmitter": "Radio frequency transmitter"
|
||||
},
|
||||
"data_description": {
|
||||
"code": "The code your hood is paired with (1-10). Code 1 is the factory default.",
|
||||
"transmitter": "The radio frequency transmitter used to control the Novy cooker hood."
|
||||
},
|
||||
"description": "After you submit, Home Assistant will toggle the hood light on and off to verify the code works."
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"light": {
|
||||
"light": {
|
||||
"name": "[%key:component::light::title%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"code": {
|
||||
"options": {
|
||||
"1": "Code 1",
|
||||
"2": "Code 2",
|
||||
"3": "Code 3",
|
||||
"4": "Code 4",
|
||||
"5": "Code 5",
|
||||
"6": "Code 6",
|
||||
"7": "Code 7",
|
||||
"8": "Code 8",
|
||||
"9": "Code 9",
|
||||
"10": "Code 10"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/radio_frequency",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["rf-protocols==2.2.0"]
|
||||
"requirements": ["rf-protocols==2.1.0"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
from synology_dsm.api.core.external_usb import (
|
||||
@@ -32,6 +32,7 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
from homeassistant.util.dt import utcnow
|
||||
|
||||
from . import SynoApi
|
||||
from .const import CONF_VOLUMES, ENTITY_UNIT_LOAD
|
||||
@@ -326,7 +327,8 @@ INFORMATION_SENSORS: tuple[SynologyDSMSensorEntityDescription, ...] = (
|
||||
SynologyDSMSensorEntityDescription(
|
||||
api_key=SynoDSMInformation.API_KEY,
|
||||
key="uptime",
|
||||
device_class=SensorDeviceClass.UPTIME,
|
||||
translation_key="uptime",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_registry_enabled_default=False,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
),
|
||||
@@ -543,6 +545,17 @@ class SynoDSMExternalUSBSensor(SynologyDSMDeviceEntity, SynoDSMSensor):
|
||||
class SynoDSMInfoSensor(SynoDSMSensor):
|
||||
"""Representation a Synology information sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
api: SynoApi,
|
||||
coordinator: SynologyDSMCentralUpdateCoordinator,
|
||||
description: SynologyDSMSensorEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize the Synology SynoDSMInfoSensor entity."""
|
||||
super().__init__(api, coordinator, description)
|
||||
self._previous_uptime: str | None = None
|
||||
self._last_boot: datetime | None = None
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return the state."""
|
||||
@@ -550,4 +563,11 @@ class SynoDSMInfoSensor(SynoDSMSensor):
|
||||
if attr is None:
|
||||
return None
|
||||
|
||||
if self.entity_description.key == "uptime":
|
||||
# reboot happened or entity creation
|
||||
if self._previous_uptime is None or self._previous_uptime > attr:
|
||||
self._last_boot = utcnow() - timedelta(seconds=attr)
|
||||
|
||||
self._previous_uptime = attr
|
||||
return self._last_boot
|
||||
return attr # type: ignore[no-any-return]
|
||||
|
||||
@@ -157,6 +157,9 @@
|
||||
"temperature": {
|
||||
"name": "[%key:component::sensor::entity_component::temperature::name%]"
|
||||
},
|
||||
"uptime": {
|
||||
"name": "Last boot"
|
||||
},
|
||||
"volume_disk_temp_avg": {
|
||||
"name": "Average disk temp"
|
||||
},
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tesla-fleet-api"],
|
||||
"requirements": ["tesla-fleet-api==1.4.7"]
|
||||
"requirements": ["tesla-fleet-api==1.4.5"]
|
||||
}
|
||||
|
||||
@@ -262,7 +262,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
device = DeviceInfo(
|
||||
identifiers={(DOMAIN, vin)},
|
||||
manufacturer="Tesla",
|
||||
configuration_url=f"https://teslemetry.com/console/vehicle/{vin}",
|
||||
configuration_url="https://teslemetry.com/console",
|
||||
name=product["display_name"],
|
||||
model=vehicle.model,
|
||||
model_id=vin[3],
|
||||
@@ -324,7 +324,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
device = DeviceInfo(
|
||||
identifiers={(DOMAIN, str(site_id))},
|
||||
manufacturer="Tesla",
|
||||
configuration_url=f"https://teslemetry.com/console/energy/{site_id}",
|
||||
configuration_url="https://teslemetry.com/console",
|
||||
name=product.get("site_name", "Energy Site"),
|
||||
serial_number=str(site_id),
|
||||
)
|
||||
@@ -514,7 +514,7 @@ def async_setup_energy_device(
|
||||
*data.get("components_gateways", []),
|
||||
*data.get("components_batteries", []),
|
||||
):
|
||||
if (part_name := component.get("part_name")) and part_name != "Unknown":
|
||||
if part_name := component.get("part_name"):
|
||||
models.add(part_name)
|
||||
if models:
|
||||
energysite.device["model"] = ", ".join(sorted(models))
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tesla-fleet-api"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["tesla-fleet-api==1.4.7", "teslemetry-stream==0.9.0"]
|
||||
"requirements": ["tesla-fleet-api==1.4.5", "teslemetry-stream==0.9.0"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["tessie", "tesla-fleet-api"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.4.7"]
|
||||
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.4.5"]
|
||||
}
|
||||
|
||||
@@ -26,7 +26,6 @@ from .const import AUTH_IMPLEMENTATION, DATA_HASS_CONFIG, DOMAIN, TibberConfigEn
|
||||
from .coordinator import (
|
||||
TibberDataAPICoordinator,
|
||||
TibberDataCoordinator,
|
||||
TibberFetchPriceCoordinator,
|
||||
TibberPriceCoordinator,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
@@ -45,7 +44,6 @@ class TibberRuntimeData:
|
||||
session: OAuth2Session
|
||||
data_api_coordinator: TibberDataAPICoordinator | None = field(default=None)
|
||||
data_coordinator: TibberDataCoordinator | None = field(default=None)
|
||||
fetch_price_coordinator: TibberFetchPriceCoordinator | None = field(default=None)
|
||||
price_coordinator: TibberPriceCoordinator | None = field(default=None)
|
||||
_client: tibber.Tibber | None = None
|
||||
|
||||
@@ -133,11 +131,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TibberConfigEntry) -> bo
|
||||
raise ConfigEntryNotReady("Fatal HTTP error from Tibber API") from err
|
||||
|
||||
if tibber_connection.get_homes(only_active=True):
|
||||
fetch_price_coordinator = TibberFetchPriceCoordinator(hass, entry)
|
||||
await fetch_price_coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data.fetch_price_coordinator = fetch_price_coordinator
|
||||
|
||||
price_coordinator = TibberPriceCoordinator(hass, entry, fetch_price_coordinator)
|
||||
price_coordinator = TibberPriceCoordinator(hass, entry)
|
||||
await price_coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data.price_coordinator = price_coordinator
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ from homeassistant.components.recorder.statistics import (
|
||||
statistics_during_period,
|
||||
)
|
||||
from homeassistant.const import UnitOfEnergy
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.unit_conversion import EnergyConverter
|
||||
@@ -102,7 +102,7 @@ class TibberCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
|
||||
config_entry: TibberConfigEntry,
|
||||
*,
|
||||
name: str,
|
||||
update_interval: timedelta | None = None,
|
||||
update_interval: timedelta,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
@@ -278,54 +278,21 @@ class TibberDataCoordinator(TibberCoordinator[None]):
|
||||
|
||||
|
||||
class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]):
|
||||
"""Handle Tibber price data."""
|
||||
"""Handle Tibber price data and insert statistics."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: TibberConfigEntry,
|
||||
price_fetch_coordinator: TibberFetchPriceCoordinator,
|
||||
) -> None:
|
||||
"""Initialize the price coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
config_entry,
|
||||
name=f"{DOMAIN} price",
|
||||
update_interval=timedelta(minutes=1),
|
||||
)
|
||||
self._price_fetch_coordinator = price_fetch_coordinator
|
||||
self._unsub_price_fetch_listener: CALLBACK_TYPE | None = None
|
||||
|
||||
@callback
|
||||
def _build_price_data(self) -> dict[str, TibberHomeData]:
|
||||
"""Build derived price data from the fetched Tibber homes."""
|
||||
return {
|
||||
home_id: _build_home_data(home)
|
||||
for home_id, home in (self._price_fetch_coordinator.data or {}).items()
|
||||
}
|
||||
|
||||
@callback
|
||||
def _async_handle_price_fetch_update(self) -> None:
|
||||
"""Update derived price data when fetched prices change."""
|
||||
self.update_interval = self._time_until_next_15_minute()
|
||||
self.async_set_updated_data(self._build_price_data())
|
||||
|
||||
@callback
|
||||
def _schedule_refresh(self) -> None:
|
||||
"""Start listening to fetched price data when entities subscribe."""
|
||||
super()._schedule_refresh()
|
||||
if self._unsub_price_fetch_listener is None:
|
||||
self._unsub_price_fetch_listener = (
|
||||
self._price_fetch_coordinator.async_add_listener(
|
||||
self._async_handle_price_fetch_update
|
||||
)
|
||||
)
|
||||
|
||||
def _unschedule_refresh(self) -> None:
|
||||
"""Stop listening to fetched price data when unused."""
|
||||
super()._unschedule_refresh()
|
||||
if self._unsub_price_fetch_listener is not None:
|
||||
self._unsub_price_fetch_listener()
|
||||
self._unsub_price_fetch_listener = None
|
||||
self._tomorrow_price_poll_threshold_seconds = random.uniform(0, 3600 * 10)
|
||||
|
||||
def _time_until_next_15_minute(self) -> timedelta:
|
||||
"""Return time until the next 15-minute boundary (0, 15, 30, 45) in UTC."""
|
||||
@@ -342,30 +309,7 @@ class TibberPriceCoordinator(TibberCoordinator[dict[str, TibberHomeData]]):
|
||||
return next_run - now
|
||||
|
||||
async def _async_update_data(self) -> dict[str, TibberHomeData]:
|
||||
self.update_interval = self._time_until_next_15_minute()
|
||||
return self._build_price_data()
|
||||
|
||||
|
||||
class TibberFetchPriceCoordinator(TibberCoordinator[dict[str, tibber.TibberHome]]):
|
||||
"""Fetch Tibber price data from the API."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: TibberConfigEntry,
|
||||
) -> None:
|
||||
"""Initialize the price coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
config_entry,
|
||||
name=f"{DOMAIN} price fetch",
|
||||
)
|
||||
self._tomorrow_price_poll_threshold_seconds = random.uniform(
|
||||
3600 * 14, 3600 * 22
|
||||
)
|
||||
|
||||
async def _async_update_data(self) -> dict[str, tibber.TibberHome]:
|
||||
"""Fetch latest price data via API and return per-home data."""
|
||||
"""Update data via API and return per-home data for sensors."""
|
||||
tibber_connection = await self._async_get_client()
|
||||
active_homes = tibber_connection.get_homes(only_active=True)
|
||||
|
||||
@@ -397,31 +341,28 @@ class TibberFetchPriceCoordinator(TibberCoordinator[dict[str, tibber.TibberHome]
|
||||
return True
|
||||
if _has_prices_tomorrow(home):
|
||||
return False
|
||||
if now >= today_start + timedelta(
|
||||
seconds=self._tomorrow_price_poll_threshold_seconds
|
||||
if (today_end - now).total_seconds() < (
|
||||
self._tomorrow_price_poll_threshold_seconds
|
||||
):
|
||||
return True
|
||||
return False
|
||||
|
||||
self.update_interval = timedelta(seconds=random.uniform(60, 60 * 10))
|
||||
homes_to_update = [home for home in active_homes if _needs_update(home)]
|
||||
|
||||
try:
|
||||
await asyncio.gather(
|
||||
*(
|
||||
home.update_info_and_price_info()
|
||||
for home in active_homes
|
||||
if _needs_update(home)
|
||||
if homes_to_update:
|
||||
await asyncio.gather(
|
||||
*(home.update_info_and_price_info() for home in homes_to_update)
|
||||
)
|
||||
)
|
||||
except tibber.exceptions.RateLimitExceededError as err:
|
||||
raise UpdateFailed(
|
||||
f"Rate limit exceeded, retry after {err.retry_after} seconds",
|
||||
retry_after=err.retry_after,
|
||||
) from err
|
||||
except tibber.exceptions.HttpExceptionError as err:
|
||||
raise UpdateFailed(f"Error communicating with API ({err})") from err
|
||||
except tibber.RetryableHttpExceptionError as err:
|
||||
raise UpdateFailed(f"Error communicating with API ({err.status})") from err
|
||||
except tibber.FatalHttpExceptionError as err:
|
||||
raise UpdateFailed(f"Error communicating with API ({err.status})") from err
|
||||
|
||||
return {home.home_id: home for home in active_homes}
|
||||
result = {home.home_id: _build_home_data(home) for home in active_homes}
|
||||
|
||||
self.update_interval = self._time_until_next_15_minute()
|
||||
return result
|
||||
|
||||
|
||||
class TibberDataAPICoordinator(TibberCoordinator[dict[str, TibberDevice]]):
|
||||
|
||||
@@ -750,7 +750,7 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator
|
||||
) -> None:
|
||||
"""Initialize the sensor."""
|
||||
super().__init__(coordinator=coordinator, tibber_home=tibber_home)
|
||||
self._price_data_available = False
|
||||
self._attr_available = False
|
||||
self._attr_native_unit_of_measurement = tibber_home.price_unit
|
||||
self._attr_extra_state_attributes = {
|
||||
"app_nickname": None,
|
||||
@@ -771,11 +771,6 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator
|
||||
self._device_name = self._home_name
|
||||
self._update_attributes()
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if the sensor is available."""
|
||||
return super().available and self._price_data_available
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
self._update_attributes()
|
||||
@@ -789,8 +784,7 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator
|
||||
(home_data := data.get(self._tibber_home.home_id)) is None
|
||||
or (current_price := home_data.get("current_price")) is None
|
||||
):
|
||||
self._price_data_available = False
|
||||
self._attr_native_value = None
|
||||
self._attr_available = False
|
||||
return
|
||||
|
||||
self._attr_native_unit_of_measurement = home_data.get(
|
||||
@@ -811,7 +805,7 @@ class TibberSensorElPrice(TibberSensor, CoordinatorEntity[TibberPriceCoordinator
|
||||
self._attr_extra_state_attributes["estimated_annual_consumption"] = home_data[
|
||||
"estimated_annual_consumption"
|
||||
]
|
||||
self._price_data_available = True
|
||||
self._attr_available = True
|
||||
|
||||
|
||||
class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]):
|
||||
|
||||
@@ -41,7 +41,6 @@ ATTR_REMAINING = "remaining"
|
||||
ATTR_FINISHES_AT = "finishes_at"
|
||||
ATTR_RESTORE = "restore"
|
||||
ATTR_FINISHED_AT = "finished_at"
|
||||
ATTR_LAST_ACTION = "last_action"
|
||||
|
||||
CONF_DURATION = "duration"
|
||||
CONF_RESTORE = "restore"
|
||||
@@ -203,7 +202,6 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
def __init__(self, config: ConfigType) -> None:
|
||||
"""Initialize a timer."""
|
||||
self._config: dict = config
|
||||
self._last_action: str | None = None
|
||||
self._state: str = STATUS_IDLE
|
||||
self._configured_duration = cv.time_period_str(config[CONF_DURATION])
|
||||
self._running_duration: timedelta = self._configured_duration
|
||||
@@ -251,7 +249,6 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
attrs: dict[str, Any] = {
|
||||
ATTR_DURATION: _format_timedelta(self._running_duration),
|
||||
ATTR_EDITABLE: self.editable,
|
||||
ATTR_LAST_ACTION: self._last_action,
|
||||
}
|
||||
if self._end is not None:
|
||||
attrs[ATTR_FINISHES_AT] = self._end.isoformat()
|
||||
@@ -277,7 +274,6 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
|
||||
# Begin restoring state
|
||||
self._state = state.state
|
||||
self._last_action = state.attributes.get(ATTR_LAST_ACTION)
|
||||
|
||||
# Nothing more to do if the timer is idle
|
||||
if self._state == STATUS_IDLE:
|
||||
@@ -325,7 +321,8 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
|
||||
self._end = start + self._remaining
|
||||
|
||||
self._fire_event_and_write_state(event)
|
||||
self.async_write_ha_state()
|
||||
self.hass.bus.async_fire(event, {ATTR_ENTITY_ID: self.entity_id})
|
||||
|
||||
self._listener = async_track_point_in_utc_time(
|
||||
self.hass, self._async_finished, self._end
|
||||
@@ -352,8 +349,6 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
self._listener()
|
||||
self._end += duration
|
||||
self._remaining = new_remaining
|
||||
# We don't use _fire_event_and_write_state here because we don't want to
|
||||
# update last_action
|
||||
self.async_write_ha_state()
|
||||
self.hass.bus.async_fire(EVENT_TIMER_CHANGED, {ATTR_ENTITY_ID: self.entity_id})
|
||||
self._listener = async_track_point_in_utc_time(
|
||||
@@ -371,7 +366,8 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
self._remaining = self._end - dt_util.utcnow().replace(microsecond=0)
|
||||
self._state = STATUS_PAUSED
|
||||
self._end = None
|
||||
self._fire_event_and_write_state(EVENT_TIMER_PAUSED)
|
||||
self.async_write_ha_state()
|
||||
self.hass.bus.async_fire(EVENT_TIMER_PAUSED, {ATTR_ENTITY_ID: self.entity_id})
|
||||
|
||||
@callback
|
||||
def async_cancel(self) -> None:
|
||||
@@ -386,7 +382,10 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
self._end = None
|
||||
self._remaining = None
|
||||
self._running_duration = self._configured_duration
|
||||
self._fire_event_and_write_state(EVENT_TIMER_CANCELLED)
|
||||
self.async_write_ha_state()
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TIMER_CANCELLED, {ATTR_ENTITY_ID: self.entity_id}
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_finish(self) -> None:
|
||||
@@ -404,8 +403,10 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
self._end = None
|
||||
self._remaining = None
|
||||
self._running_duration = self._configured_duration
|
||||
self._fire_event_and_write_state(
|
||||
EVENT_TIMER_FINISHED, extra_attrs={ATTR_FINISHED_AT: end.isoformat()}
|
||||
self.async_write_ha_state()
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TIMER_FINISHED,
|
||||
{ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()},
|
||||
)
|
||||
|
||||
@callback
|
||||
@@ -420,8 +421,10 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
self._end = None
|
||||
self._remaining = None
|
||||
self._running_duration = self._configured_duration
|
||||
self._fire_event_and_write_state(
|
||||
EVENT_TIMER_FINISHED, extra_attrs={ATTR_FINISHED_AT: end.isoformat()}
|
||||
self.async_write_ha_state()
|
||||
self.hass.bus.async_fire(
|
||||
EVENT_TIMER_FINISHED,
|
||||
{ATTR_ENTITY_ID: self.entity_id, ATTR_FINISHED_AT: end.isoformat()},
|
||||
)
|
||||
|
||||
async def async_update_config(self, config: ConfigType) -> None:
|
||||
@@ -432,14 +435,3 @@ class Timer(collection.CollectionEntity, RestoreEntity):
|
||||
self._running_duration = self._configured_duration
|
||||
self._restore = config.get(CONF_RESTORE, DEFAULT_RESTORE)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def _fire_event_and_write_state(
|
||||
self, event: str, *, extra_attrs: dict[str, Any] | None = None
|
||||
) -> None:
|
||||
"""Fire the event and write state."""
|
||||
self._last_action = event.partition(".")[2]
|
||||
self.async_write_ha_state()
|
||||
event_data = {ATTR_ENTITY_ID: self.entity_id}
|
||||
if extra_attrs:
|
||||
event_data.update(extra_attrs)
|
||||
self.hass.bus.async_fire(event, event_data)
|
||||
|
||||
@@ -6,7 +6,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.device_registry import DeviceEntry
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
@@ -51,19 +51,6 @@ async def async_setup_entry(
|
||||
hub = config_entry.runtime_data = UnifiHub(hass, config_entry, api)
|
||||
await hub.initialize()
|
||||
|
||||
# Pre-populate device registry with UniFi devices before forwarding to
|
||||
# platforms. Without this, device_tracker entities may be registered as
|
||||
# disabled-by-default if their platform is set up before another platform
|
||||
# creates the device entry, since their default enabled state depends on
|
||||
# the matching device existing in the registry. Other fields are populated
|
||||
# when entities with DeviceInfo are added by their respective platforms.
|
||||
device_registry = dr.async_get(hass)
|
||||
for device in hub.api.devices.values():
|
||||
device_registry.async_get_or_create(
|
||||
config_entry_id=config_entry.entry_id,
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, device.mac)},
|
||||
)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS)
|
||||
hub.async_update_device_registry()
|
||||
hub.entity_loader.load_entities()
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Config flow for UniFi Network integration.
|
||||
|
||||
Provides user initiated configuration flow.
|
||||
Discovery of UniFi Network instances through unifi_discovery.
|
||||
Reauthentication when issue with credentials are reported.
|
||||
Discovery of UniFi Network instances hosted on UDM and UDM Pro devices
|
||||
through SSDP. Reauthentication when issue with credentials are reported.
|
||||
Configuration of options through options flow.
|
||||
"""
|
||||
|
||||
@@ -13,6 +13,7 @@ import operator
|
||||
import socket
|
||||
from types import MappingProxyType
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from aiounifi.interfaces.sites import Sites
|
||||
import voluptuous as vol
|
||||
@@ -34,7 +35,11 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.typing import DiscoveryInfoType
|
||||
from homeassistant.helpers.service_info.ssdp import (
|
||||
ATTR_UPNP_MODEL_DESCRIPTION,
|
||||
ATTR_UPNP_SERIAL,
|
||||
SsdpServiceInfo,
|
||||
)
|
||||
|
||||
from . import UnifiConfigEntry
|
||||
from .const import (
|
||||
@@ -61,6 +66,12 @@ DEFAULT_SITE_ID = "default"
|
||||
DEFAULT_VERIFY_SSL = False
|
||||
|
||||
|
||||
MODEL_PORTS = {
|
||||
"UniFi Dream Machine": 443,
|
||||
"UniFi Dream Machine Pro": 443,
|
||||
}
|
||||
|
||||
|
||||
class UnifiFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a UniFi Network config flow."""
|
||||
|
||||
@@ -133,10 +144,7 @@ class UnifiFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
vol.Optional(
|
||||
CONF_PORT, default=self.config.get(CONF_PORT, DEFAULT_PORT)
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_VERIFY_SSL,
|
||||
default=self.config.get(CONF_VERIFY_SSL, DEFAULT_VERIFY_SSL),
|
||||
): bool,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): bool,
|
||||
}
|
||||
|
||||
return self.async_show_form(
|
||||
@@ -207,34 +215,33 @@ class UnifiFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
return await self.async_step_user()
|
||||
|
||||
async def async_step_integration_discovery(
|
||||
self, discovery_info: DiscoveryInfoType
|
||||
async def async_step_ssdp(
|
||||
self, discovery_info: SsdpServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle discovery via unifi_discovery."""
|
||||
source_ip = discovery_info["source_ip"]
|
||||
if not source_ip:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
mac_address = format_mac(discovery_info["hw_addr"])
|
||||
direct_connect_domain = discovery_info.get("direct_connect_domain")
|
||||
host = direct_connect_domain or source_ip
|
||||
"""Handle a discovered UniFi device."""
|
||||
parsed_url = urlparse(discovery_info.ssdp_location)
|
||||
model_description = discovery_info.upnp[ATTR_UPNP_MODEL_DESCRIPTION]
|
||||
mac_address = format_mac(discovery_info.upnp[ATTR_UPNP_SERIAL])
|
||||
|
||||
self.config = {
|
||||
CONF_HOST: host,
|
||||
CONF_VERIFY_SSL: bool(direct_connect_domain),
|
||||
CONF_HOST: parsed_url.hostname,
|
||||
}
|
||||
|
||||
for entry in self._async_current_entries(include_ignore=False):
|
||||
if entry.data.get(CONF_HOST) in (source_ip, direct_connect_domain):
|
||||
return self.async_abort(reason="already_configured")
|
||||
self._async_abort_entries_match({CONF_HOST: self.config[CONF_HOST]})
|
||||
|
||||
await self.async_set_unique_id(mac_address)
|
||||
self._abort_if_unique_id_configured(updates=self.config)
|
||||
|
||||
self.context["title_placeholders"] = {
|
||||
CONF_HOST: host,
|
||||
CONF_HOST: self.config[CONF_HOST],
|
||||
CONF_SITE_ID: DEFAULT_SITE_ID,
|
||||
}
|
||||
self.context["configuration_url"] = f"https://{host}"
|
||||
|
||||
if (port := MODEL_PORTS.get(model_description)) is not None:
|
||||
self.config[CONF_PORT] = port
|
||||
self.context["configuration_url"] = (
|
||||
f"https://{self.config[CONF_HOST]}:{port}"
|
||||
)
|
||||
|
||||
return await self.async_step_user()
|
||||
|
||||
|
||||
@@ -3,11 +3,28 @@
|
||||
"name": "UniFi Network",
|
||||
"codeowners": ["@Kane610"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["unifi_discovery"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/unifi",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiounifi"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["aiounifi==90"]
|
||||
"requirements": ["aiounifi==90"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
"modelDescription": "UniFi Dream Machine"
|
||||
},
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
"modelDescription": "UniFi Dream Machine Pro"
|
||||
},
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
"modelDescription": "UniFi Dream Machine SE"
|
||||
},
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
"modelDescription": "UniFi Dream Machine Pro Max"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -35,9 +35,7 @@ rules:
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: done
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: Discovery is handled via unifi_discovery dependency using SOURCE_INTEGRATION_DISCOVERY.
|
||||
discovery: done
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "UniFi Network site is already configured",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"configuration_updated": "Configuration updated",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
|
||||
@@ -8,7 +8,6 @@ DOMAIN = "unifi_discovery"
|
||||
# This must be static (not a runtime registry) because consumers may not be loaded
|
||||
# when initial discovery runs — the same pattern DHCP/SSDP use with manifest matchers.
|
||||
CONSUMER_MAPPING: dict[UnifiService, str] = {
|
||||
UnifiService.Access: "unifi_access",
|
||||
UnifiService.Network: "unifi",
|
||||
UnifiService.Protect: "unifiprotect",
|
||||
UnifiService.Access: "unifi_access",
|
||||
}
|
||||
|
||||
@@ -52,9 +52,9 @@ DEVICES_THAT_ADOPT = {
|
||||
DEVICES_WITH_ENTITIES = DEVICES_THAT_ADOPT | {ModelType.NVR}
|
||||
DEVICES_FOR_SUBSCRIBE = DEVICES_WITH_ENTITIES | {ModelType.EVENT}
|
||||
|
||||
# Public API devices WebSocket: NVR (for arm_mode updates), Relay
|
||||
# (for relay output state updates), and Siren (for siren active-state updates).
|
||||
DEVICES_WS_SUBSCRIBED_MODELS = {ModelType.NVR, ModelType.RELAY, ModelType.SIREN}
|
||||
# Public API devices WebSocket: NVR (for arm_mode updates) and Siren
|
||||
# (for siren active-state updates).
|
||||
DEVICES_WS_SUBSCRIBED_MODELS = {ModelType.NVR, ModelType.SIREN}
|
||||
|
||||
MIN_REQUIRED_PROTECT_V = Version("6.0.0")
|
||||
OUTDATED_LOG_MESSAGE = (
|
||||
|
||||
@@ -19,7 +19,6 @@ from uiprotect.data import (
|
||||
ModelType,
|
||||
ProtectAdoptableDeviceModel,
|
||||
PTZPatrol,
|
||||
Relay,
|
||||
Siren,
|
||||
WSSubscriptionMessage,
|
||||
)
|
||||
@@ -85,9 +84,6 @@ class ProtectData:
|
||||
self._subscriptions: defaultdict[
|
||||
str, set[Callable[[ProtectDeviceType], None]]
|
||||
] = defaultdict(set)
|
||||
self._relay_subscriptions: defaultdict[str, set[Callable[[Relay], None]]] = (
|
||||
defaultdict(set)
|
||||
)
|
||||
self._siren_subscriptions: defaultdict[str, set[Callable[[Siren], None]]] = (
|
||||
defaultdict(set)
|
||||
)
|
||||
@@ -188,10 +184,8 @@ class ProtectData:
|
||||
|
||||
The API client pre-filters messages to the model types listed in
|
||||
DEVICES_WS_SUBSCRIBED_MODELS. NVR messages signal the private NVR so
|
||||
alarm entities pick up the new arm state. Relay messages dispatch
|
||||
the merged Relay object by mac so relay-output entities can refresh.
|
||||
Siren messages dispatch the merged Siren object by mac so siren entities
|
||||
can refresh.
|
||||
alarm entities pick up the new arm state. Siren messages dispatch
|
||||
the merged Siren object by mac so siren entities can refresh.
|
||||
"""
|
||||
new_obj = message.new_obj
|
||||
if new_obj is None:
|
||||
@@ -203,14 +197,6 @@ class ProtectData:
|
||||
if new_obj.model is ModelType.NVR:
|
||||
self._async_signal_device_update(self.api.bootstrap.nvr)
|
||||
return
|
||||
if new_obj.model is ModelType.RELAY:
|
||||
relay = cast(Relay, new_obj)
|
||||
mac = relay.mac
|
||||
if subscriptions := self._relay_subscriptions.get(mac):
|
||||
_LOGGER.debug("Updating relay: %s (%s)", relay.name, mac)
|
||||
for update_callback in subscriptions:
|
||||
update_callback(relay)
|
||||
return
|
||||
if new_obj.model is ModelType.SIREN:
|
||||
self._async_signal_siren_update(cast(Siren, new_obj))
|
||||
|
||||
@@ -386,10 +372,6 @@ class ProtectData:
|
||||
for device in self.get_by_types(DEVICES_THAT_ADOPT):
|
||||
self._async_signal_device_update(device)
|
||||
if self.api.has_public_bootstrap:
|
||||
for relay in self.api.public_bootstrap.relays.values():
|
||||
if subscriptions := self._relay_subscriptions.get(relay.mac):
|
||||
for subscription_callback in subscriptions:
|
||||
subscription_callback(relay)
|
||||
for siren in self.api.public_bootstrap.sirens.values():
|
||||
self._async_signal_siren_update(siren)
|
||||
|
||||
@@ -420,23 +402,6 @@ class ProtectData:
|
||||
if not self._subscriptions[mac]:
|
||||
del self._subscriptions[mac]
|
||||
|
||||
@callback
|
||||
def async_subscribe_relay(
|
||||
self, mac: str, update_callback: Callable[[Relay], None]
|
||||
) -> CALLBACK_TYPE:
|
||||
"""Add a callback subscriber for relay updates."""
|
||||
self._relay_subscriptions[mac].add(update_callback)
|
||||
return partial(self._async_unsubscribe_relay, mac, update_callback)
|
||||
|
||||
@callback
|
||||
def _async_unsubscribe_relay(
|
||||
self, mac: str, update_callback: Callable[[Relay], None]
|
||||
) -> None:
|
||||
"""Remove a relay callback subscriber."""
|
||||
self._relay_subscriptions[mac].remove(update_callback)
|
||||
if not self._relay_subscriptions[mac]:
|
||||
del self._relay_subscriptions[mac]
|
||||
|
||||
@callback
|
||||
def async_subscribe_siren(
|
||||
self, mac: str, update_callback: Callable[[Siren], None]
|
||||
|
||||
@@ -641,9 +641,6 @@
|
||||
"privacy_mode": {
|
||||
"name": "Privacy mode"
|
||||
},
|
||||
"relay_output": {
|
||||
"name": "Output {output_name}"
|
||||
},
|
||||
"ssh_enabled": {
|
||||
"name": "[%key:component::unifiprotect::entity::binary_sensor::ssh_enabled::name%]"
|
||||
},
|
||||
@@ -700,9 +697,6 @@
|
||||
"ptz_preset_not_found": {
|
||||
"message": "Could not find PTZ preset with name {preset_name} on camera {camera_name}"
|
||||
},
|
||||
"relay_not_available": {
|
||||
"message": "Relay is no longer available"
|
||||
},
|
||||
"service_error": {
|
||||
"message": "Error calling UniFi Protect service, check the logs for more details"
|
||||
},
|
||||
|
||||
@@ -5,29 +5,22 @@ from __future__ import annotations
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from functools import partial
|
||||
from typing import Any, Literal
|
||||
from typing import Any
|
||||
|
||||
from uiprotect.data import (
|
||||
Camera,
|
||||
ModelType,
|
||||
ProtectAdoptableDeviceModel,
|
||||
PublicRelayOutput,
|
||||
RecordingMode,
|
||||
Relay,
|
||||
RelayOutputState,
|
||||
VideoMode,
|
||||
)
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
|
||||
from .const import DEFAULT_ATTRIBUTION, DEFAULT_BRAND, DOMAIN
|
||||
from .data import ProtectData, ProtectDeviceType, UFPConfigEntry
|
||||
from .entity import (
|
||||
BaseProtectEntity,
|
||||
@@ -428,12 +421,6 @@ NVR_SWITCHES: tuple[ProtectSwitchEntityDescription, ...] = (
|
||||
),
|
||||
)
|
||||
|
||||
_RELAY_STATE_MAP: dict[RelayOutputState, bool] = {
|
||||
RelayOutputState.ON: True,
|
||||
RelayOutputState.OFF: False,
|
||||
RelayOutputState.OFF_OTP: False,
|
||||
}
|
||||
|
||||
_MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = {
|
||||
ModelType.CAMERA: CAMERA_SWITCHES,
|
||||
ModelType.LIGHT: LIGHT_SWITCHES,
|
||||
@@ -575,119 +562,3 @@ async def async_setup_entry(
|
||||
for switch in NVR_SWITCHES
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
# Public API: relay output switches. Only available when the public
|
||||
# bootstrap has been primed (requires API key + supported NVR firmware).
|
||||
api = data.api
|
||||
if api.has_public_bootstrap:
|
||||
relay_entities: list[ProtectRelayOutputSwitch] = [
|
||||
ProtectRelayOutputSwitch(data, relay, output)
|
||||
for relay in api.public_bootstrap.relays.values()
|
||||
for output in relay.outputs
|
||||
]
|
||||
if relay_entities:
|
||||
async_add_entities(relay_entities)
|
||||
|
||||
|
||||
class ProtectRelayOutputSwitch(SwitchEntity):
|
||||
"""Switch entity for a single relay output channel (Public API).
|
||||
|
||||
The relay device and its outputs are exposed through UniFi Protect's
|
||||
public integration API and cached in :attr:`ProtectApiClient.public_bootstrap`.
|
||||
Each output channel is represented as its own switch entity; turning it
|
||||
on/off goes through :meth:`Relay.activate_output`.
|
||||
"""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_attribution = DEFAULT_ATTRIBUTION
|
||||
_attr_should_poll = False
|
||||
_attr_translation_key = "relay_output"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: ProtectData,
|
||||
relay: Relay,
|
||||
output: PublicRelayOutput,
|
||||
) -> None:
|
||||
"""Initialize the relay output switch."""
|
||||
self.data = data
|
||||
self._relay_id = relay.id
|
||||
self._relay_mac = relay.mac
|
||||
self._output_id = output.id
|
||||
self._attr_unique_id = f"{relay.mac}_relay_output_{output.id}"
|
||||
self._attr_translation_placeholders = {
|
||||
"output_name": output.name or str(output.id),
|
||||
}
|
||||
nvr = data.api.bootstrap.nvr
|
||||
self._attr_device_info = DeviceInfo(
|
||||
connections={(dr.CONNECTION_NETWORK_MAC, relay.mac)},
|
||||
identifiers={(DOMAIN, relay.mac)},
|
||||
manufacturer=DEFAULT_BRAND,
|
||||
name=relay.name,
|
||||
model="Relay",
|
||||
via_device=(DOMAIN, nvr.mac),
|
||||
)
|
||||
self._update_from_relay(relay)
|
||||
|
||||
@property
|
||||
def _relay(self) -> Relay | None:
|
||||
api = self.data.api
|
||||
if not api.has_public_bootstrap:
|
||||
return None
|
||||
return api.public_bootstrap.relays.get(self._relay_id)
|
||||
|
||||
@callback
|
||||
def _update_from_relay(self, relay: Relay) -> None:
|
||||
"""Refresh ``_attr_is_on`` and availability from the cached relay."""
|
||||
output = relay.get_output(self._output_id)
|
||||
if output is None:
|
||||
self._attr_available = False
|
||||
self._attr_is_on = None
|
||||
return
|
||||
self._attr_available = self.data.last_update_success
|
||||
self._attr_is_on = (
|
||||
_RELAY_STATE_MAP.get(output.state) if output.state is not None else None
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_updated(self, relay: Relay) -> None:
|
||||
"""Handle a public relay WS update for this relay."""
|
||||
prev_state = (self._attr_available, self._attr_is_on)
|
||||
self._update_from_relay(relay)
|
||||
# If the relay was removed from the bootstrap while the WS update
|
||||
# was in flight, mark unavailable so commands cannot succeed.
|
||||
if self._relay is None:
|
||||
self._attr_available = False
|
||||
if (self._attr_available, self._attr_is_on) != prev_state:
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to public relay WS updates dispatched by ProtectData."""
|
||||
await super().async_added_to_hass()
|
||||
self.async_on_remove(
|
||||
self.data.async_subscribe_relay(self._relay_mac, self._async_updated)
|
||||
)
|
||||
|
||||
async def _activate_output(self, state: Literal["on", "off"]) -> None:
|
||||
"""Send activate_output to the relay, raising if unavailable."""
|
||||
if (relay := self._relay) is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="relay_not_available",
|
||||
)
|
||||
if relay.get_output(self._output_id) is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="relay_not_available",
|
||||
)
|
||||
await relay.activate_output(self._output_id, state=state)
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the relay output on."""
|
||||
await self._activate_output("on")
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the relay output off."""
|
||||
await self._activate_output("off")
|
||||
|
||||
@@ -555,19 +555,7 @@ async def websocket_usb_list_serial_ports(
|
||||
except OSError as err:
|
||||
connection.send_error(msg["id"], websocket_api.ERR_UNKNOWN_ERROR, str(err))
|
||||
return
|
||||
|
||||
result = []
|
||||
for port in ports:
|
||||
entry = dataclasses.asdict(port)
|
||||
|
||||
if isinstance(port, USBDevice):
|
||||
matchers = async_get_usb_matchers_for_device(hass, port)
|
||||
entry["matching_integrations"] = list(
|
||||
dict.fromkeys(matcher["domain"] for matcher in matchers)
|
||||
)
|
||||
else:
|
||||
entry["matching_integrations"] = []
|
||||
|
||||
result.append(entry)
|
||||
|
||||
connection.send_result(msg["id"], result)
|
||||
connection.send_result(
|
||||
msg["id"],
|
||||
[dataclasses.asdict(port) for port in ports],
|
||||
)
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["PyViCare"],
|
||||
"requirements": ["PyViCare==2.60.1"]
|
||||
"requirements": ["PyViCare==2.59.0"]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/victron_gx",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "platinum",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["victron-mqtt==2026.4.17"],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -24,6 +24,7 @@ from .coordinator import VodafoneConfigEntry, VodafoneStationRouter
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
NOT_AVAILABLE: list = ["", "N/A", "0.0.0.0"]
|
||||
UPTIME_DEVIATION = 60
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
@@ -37,6 +38,24 @@ class VodafoneStationEntityDescription(SensorEntityDescription):
|
||||
is_suitable: Callable[[dict], bool] = lambda val: True
|
||||
|
||||
|
||||
def _calculate_uptime(
|
||||
coordinator: VodafoneStationRouter,
|
||||
last_value: str | datetime | float | None,
|
||||
key: str,
|
||||
) -> datetime:
|
||||
"""Calculate device uptime."""
|
||||
|
||||
delta_uptime = coordinator.api.convert_uptime(coordinator.data.sensors[key])
|
||||
|
||||
if (
|
||||
not isinstance(last_value, datetime)
|
||||
or abs((delta_uptime - last_value).total_seconds()) > UPTIME_DEVIATION
|
||||
):
|
||||
return delta_uptime
|
||||
|
||||
return last_value
|
||||
|
||||
|
||||
def _line_connection(
|
||||
coordinator: VodafoneStationRouter,
|
||||
last_value: str | datetime | float | None,
|
||||
@@ -116,11 +135,10 @@ SENSOR_TYPES: Final = (
|
||||
),
|
||||
VodafoneStationEntityDescription(
|
||||
key="sys_uptime",
|
||||
device_class=SensorDeviceClass.UPTIME,
|
||||
translation_key="sys_uptime",
|
||||
device_class=SensorDeviceClass.TIMESTAMP,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
value=lambda coordinator, last_value, key: coordinator.api.convert_uptime(
|
||||
coordinator.data.sensors[key]
|
||||
),
|
||||
value=_calculate_uptime,
|
||||
),
|
||||
VodafoneStationEntityDescription(
|
||||
key="sys_cpu_usage",
|
||||
|
||||
@@ -113,6 +113,9 @@
|
||||
"sys_reboot_cause": {
|
||||
"name": "Reboot cause"
|
||||
},
|
||||
"sys_uptime": {
|
||||
"name": "Uptime"
|
||||
},
|
||||
"up_stream": {
|
||||
"name": "WAN upload rate"
|
||||
}
|
||||
|
||||
@@ -125,12 +125,6 @@ class WolSwitch(SwitchEntity):
|
||||
self._state = True
|
||||
self.schedule_update_ha_state()
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Clean up script when removing from Home Assistant."""
|
||||
if self._off_script is not None:
|
||||
await self._off_script.async_stop()
|
||||
self._off_script.async_unload()
|
||||
|
||||
def turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the device off if an off action is present."""
|
||||
if self._off_script is not None:
|
||||
|
||||
@@ -192,17 +192,10 @@ class WattsVisionDeviceCoordinator(DataUpdateCoordinator[WattsVisionDeviceData])
|
||||
)
|
||||
|
||||
def _handle_hub_update(self) -> None:
|
||||
"""Handle updates from hub coordinator.
|
||||
|
||||
Update data and notify listeners without rescheduling the refresh
|
||||
interval, so an in-flight fast-polling cycle is not interrupted.
|
||||
"""
|
||||
"""Handle updates from hub coordinator."""
|
||||
if self.hub_coordinator.data and self.device_id in self.hub_coordinator.data:
|
||||
self.data = WattsVisionDeviceData(
|
||||
device=self.hub_coordinator.data[self.device_id]
|
||||
)
|
||||
self.last_update_success = True
|
||||
self.async_update_listeners()
|
||||
device = self.hub_coordinator.data[self.device_id]
|
||||
self.async_set_updated_data(WattsVisionDeviceData(device=device))
|
||||
|
||||
async def _async_update_data(self) -> WattsVisionDeviceData:
|
||||
"""Refresh specific device."""
|
||||
|
||||
@@ -8,11 +8,6 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_closed:
|
||||
fields: *condition_common_fields
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -11,9 +10,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::window::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::window::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Window is closed"
|
||||
@@ -23,9 +19,6 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::window::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::window::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Window is open"
|
||||
|
||||
@@ -41,7 +41,6 @@ from homeassistant.helpers.selector import (
|
||||
FileSelector,
|
||||
FileSelectorConfig,
|
||||
SerialPortSelector,
|
||||
SerialPortSelectorConfig,
|
||||
)
|
||||
from homeassistant.helpers.service_info.usb import UsbServiceInfo
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
@@ -210,15 +209,7 @@ class BaseZhaFlow(ConfigEntryBaseFlow):
|
||||
{
|
||||
vol.Required(
|
||||
CONF_DEVICE_PATH, default=default_path
|
||||
): SerialPortSelector(
|
||||
SerialPortSelectorConfig(
|
||||
extra_recommended_domains=[
|
||||
"homeassistant_yellow",
|
||||
"homeassistant_sky_connect",
|
||||
"homeassistant_connect_zbt2",
|
||||
]
|
||||
)
|
||||
),
|
||||
): SerialPortSelector(),
|
||||
}
|
||||
)
|
||||
return self.async_show_form(step_id="choose_serial_port", data_schema=schema)
|
||||
|
||||
@@ -27,12 +27,7 @@ from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
|
||||
|
||||
from .const import DOMAIN
|
||||
from .helpers import (
|
||||
SIGNAL_REMOVE_ENTITIES,
|
||||
SIGNAL_REMOVE_ENTITY,
|
||||
EntityData,
|
||||
convert_zha_error_to_ha_error,
|
||||
)
|
||||
from .helpers import SIGNAL_REMOVE_ENTITIES, EntityData, convert_zha_error_to_ha_error
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -168,16 +163,6 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
|
||||
partial(self.async_remove, force_remove=True),
|
||||
)
|
||||
)
|
||||
self._unsubs.append(
|
||||
async_dispatcher_connect(
|
||||
self.hass,
|
||||
(
|
||||
f"{SIGNAL_REMOVE_ENTITY}_"
|
||||
f"{self.entity_data.entity.PLATFORM}_{self.unique_id}"
|
||||
),
|
||||
self.async_remove,
|
||||
)
|
||||
)
|
||||
self.entity_data.device_proxy.gateway_proxy.register_entity_reference(
|
||||
self.entity_id,
|
||||
self.entity_data,
|
||||
@@ -204,7 +189,6 @@ class ZHAEntity(LogMixin, RestoreEntity, Entity):
|
||||
for unsub in self._unsubs[:]:
|
||||
unsub()
|
||||
self._unsubs.remove(unsub)
|
||||
self.entity_data.device_proxy.gateway_proxy.remove_entity_reference(self)
|
||||
await super().async_will_remove_from_hass()
|
||||
self.remove_future.set_result(True)
|
||||
|
||||
|
||||
@@ -77,8 +77,6 @@ from zha.zigbee.cluster_handlers import ClusterBindEvent, ClusterConfigureReport
|
||||
from zha.zigbee.device import (
|
||||
ClusterHandlerConfigurationComplete,
|
||||
Device,
|
||||
DeviceEntityAddedEvent,
|
||||
DeviceEntityRemovedEvent,
|
||||
DeviceFirmwareInfoUpdatedEvent,
|
||||
ZHAEvent,
|
||||
)
|
||||
@@ -208,7 +206,6 @@ DEBUG_RELAY_LOGGERS = [DEBUG_COMP_ZHA, DEBUG_COMP_ZIGPY, DEBUG_LIB_ZHA]
|
||||
ZHA_GW_MSG_LOG_ENTRY = "log_entry"
|
||||
ZHA_GW_MSG_LOG_OUTPUT = "log_output"
|
||||
SIGNAL_REMOVE_ENTITIES = "zha_remove_entities"
|
||||
SIGNAL_REMOVE_ENTITY = "zha_remove_entity"
|
||||
GROUP_ENTITY_DOMAINS = [Platform.LIGHT, Platform.SWITCH, Platform.FAN]
|
||||
SIGNAL_ADD_ENTITIES = "zha_add_entities"
|
||||
ENTITIES = "entities"
|
||||
@@ -498,41 +495,6 @@ class ZHADeviceProxy(EventBase):
|
||||
},
|
||||
)
|
||||
|
||||
@callback
|
||||
def handle_zha_device_entity_added_event(
|
||||
self, event: DeviceEntityAddedEvent
|
||||
) -> None:
|
||||
"""Handle a new entity being added to a device at runtime."""
|
||||
key = (event.platform, event.unique_id)
|
||||
if (entity := self.device.platform_entities.get(key)) is None:
|
||||
return
|
||||
ha_zha_data = get_zha_data(self.gateway_proxy.hass)
|
||||
ha_zha_data.platforms[Platform(event.platform)].append(
|
||||
EntityData(entity=entity, device_proxy=self, group_proxy=None)
|
||||
)
|
||||
async_dispatcher_send(self.gateway_proxy.hass, SIGNAL_ADD_ENTITIES)
|
||||
|
||||
@callback
|
||||
def handle_zha_device_entity_removed_event(
|
||||
self, event: DeviceEntityRemovedEvent
|
||||
) -> None:
|
||||
"""Handle an entity being removed from a device at runtime."""
|
||||
if not event.remove:
|
||||
# Soft remove: signal the entity to unload; registry entry stays
|
||||
async_dispatcher_send(
|
||||
self.gateway_proxy.hass,
|
||||
f"{SIGNAL_REMOVE_ENTITY}_{event.platform}_{event.unique_id}",
|
||||
)
|
||||
return
|
||||
|
||||
# Hard remove: delete from registry, also works without a live entity loaded
|
||||
entity_registry = er.async_get(self.gateway_proxy.hass)
|
||||
domain = Platform(event.platform)
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
domain, DOMAIN, event.unique_id
|
||||
):
|
||||
entity_registry.async_remove(entity_id)
|
||||
|
||||
|
||||
class EntityReference(NamedTuple):
|
||||
"""Describes an entity reference."""
|
||||
@@ -852,12 +814,13 @@ class ZHAGatewayProxy(EventBase):
|
||||
|
||||
def remove_entity_reference(self, entity: ZHAEntity) -> None:
|
||||
"""Remove entity reference for given entity_id if found."""
|
||||
ieee = entity.entity_data.device_proxy.device.ieee
|
||||
if (entity_refs := self._ha_entity_refs.get(ieee)) is None:
|
||||
return
|
||||
self._ha_entity_refs[ieee] = [
|
||||
e for e in entity_refs if e.ha_entity_id != entity.entity_id
|
||||
]
|
||||
if entity.zha_device.ieee in self.ha_entity_refs:
|
||||
entity_refs = self.ha_entity_refs.get(entity.zha_device.ieee)
|
||||
self.ha_entity_refs[entity.zha_device.ieee] = [
|
||||
e
|
||||
for e in entity_refs # type: ignore[union-attr]
|
||||
if e.ha_entity_id != entity.entity_id
|
||||
]
|
||||
|
||||
def _async_get_or_create_device_proxy(self, zha_device: Device) -> ZHADeviceProxy:
|
||||
"""Get or create a ZHA device."""
|
||||
|
||||
@@ -17,7 +17,6 @@ from .coordinator import ZinvoltConfigEntry, ZinvoltDeviceCoordinator
|
||||
_PLATFORMS: list[Platform] = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
]
|
||||
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
"""Select platform for Zinvolt integration."""
|
||||
|
||||
from zinvolt.models import SmartMode
|
||||
|
||||
from homeassistant.components.select import SelectEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .coordinator import ZinvoltConfigEntry, ZinvoltDeviceCoordinator
|
||||
from .entity import ZinvoltEntity
|
||||
|
||||
MODE_MAP = {
|
||||
SmartMode.DYNAMIC: "dynamic",
|
||||
SmartMode.SELF_USE: "self_use",
|
||||
SmartMode.PERFORMANCE: "fast_discharge",
|
||||
SmartMode.CHARGED: "charged",
|
||||
SmartMode.DEFAULT: "idle",
|
||||
SmartMode.FEED: "fast_charge",
|
||||
}
|
||||
|
||||
HA_TO_MODE = {v: k for k, v in MODE_MAP.items()}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ZinvoltConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Initialize the entries."""
|
||||
|
||||
async_add_entities(
|
||||
ZinvoltBatteryMode(coordinator) for coordinator in entry.runtime_data.values()
|
||||
)
|
||||
|
||||
|
||||
class ZinvoltBatteryMode(ZinvoltEntity, SelectEntity):
|
||||
"""Zinvolt select."""
|
||||
|
||||
_attr_options = list(HA_TO_MODE.keys())
|
||||
_attr_translation_key = "battery_mode"
|
||||
|
||||
def __init__(self, coordinator: ZinvoltDeviceCoordinator) -> None:
|
||||
"""Initialize the select."""
|
||||
super().__init__(coordinator)
|
||||
self._attr_unique_id = f"{coordinator.data.battery.serial_number}.mode"
|
||||
|
||||
@property
|
||||
def current_option(self) -> str | None:
|
||||
"""Return the current battery mode."""
|
||||
return MODE_MAP.get(self.coordinator.data.battery.smart_mode)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set battery mode."""
|
||||
await self.coordinator.client.set_smart_mode(
|
||||
self.coordinator.battery.identifier, HA_TO_MODE[option]
|
||||
)
|
||||
await self.coordinator.async_request_refresh()
|
||||
@@ -61,19 +61,6 @@
|
||||
"upper_threshold": {
|
||||
"name": "Maximum charge level"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"battery_mode": {
|
||||
"name": "Mode",
|
||||
"state": {
|
||||
"charged": "Charged",
|
||||
"dynamic": "Dynamic",
|
||||
"fast_charge": "Fast charge",
|
||||
"fast_discharge": "Fast discharge",
|
||||
"idle": "[%key:common::state::idle%]",
|
||||
"self_use": "Self-use"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
|
||||
@@ -47,7 +47,6 @@ from .helpers import (
|
||||
is_opening_state_notification_value,
|
||||
)
|
||||
from .models import (
|
||||
FirmwareVersionRange,
|
||||
NewZWaveDiscoverySchema,
|
||||
ValueType,
|
||||
ZwaveDiscoveryInfo,
|
||||
@@ -1347,38 +1346,6 @@ DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [
|
||||
),
|
||||
entity_class=ZWaveBooleanBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
# Fibaro FGMS001 Motion Sensor:
|
||||
# On firmware <= 2.8 the device supports Binary Sensor CC v1, which
|
||||
# does not give us any information about the type of the sensor.
|
||||
# As a result it is exposed via the generic "Any" sensor type,
|
||||
# which fits no other discovery schema.
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
manufacturer_id={0x010F},
|
||||
product_type={0x0800, 0x0801, 0x8800},
|
||||
product_id={
|
||||
0x1001,
|
||||
0x1002,
|
||||
0x2001,
|
||||
0x2002,
|
||||
0x3001,
|
||||
0x3002,
|
||||
0x4001,
|
||||
0x4002,
|
||||
0x6001,
|
||||
},
|
||||
firmware_version_range=FirmwareVersionRange(max="2.8"),
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
command_class={CommandClass.SENSOR_BINARY},
|
||||
property={"Any"},
|
||||
type={ValueType.BOOLEAN},
|
||||
),
|
||||
entity_description=BinarySensorEntityDescription(
|
||||
key="motion",
|
||||
device_class=BinarySensorDeviceClass.MOTION,
|
||||
),
|
||||
entity_class=ZWaveBooleanBinarySensor,
|
||||
),
|
||||
NewZWaveDiscoverySchema(
|
||||
platform=Platform.BINARY_SENSOR,
|
||||
primary_value=ZWaveValueDiscoverySchema(
|
||||
|
||||
Generated
-1
@@ -498,7 +498,6 @@ FLOWS = {
|
||||
"nobo_hub",
|
||||
"nordpool",
|
||||
"notion",
|
||||
"novy_cooker_hood",
|
||||
"nrgkick",
|
||||
"ntfy",
|
||||
"nuheat",
|
||||
|
||||
@@ -4765,12 +4765,6 @@
|
||||
"config_flow": true,
|
||||
"iot_class": "cloud_polling"
|
||||
},
|
||||
"novy_cooker_hood": {
|
||||
"name": "Novy Cooker Hood",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "assumed_state"
|
||||
},
|
||||
"nrgkick": {
|
||||
"name": "NRGkick",
|
||||
"integration_type": "device",
|
||||
|
||||
Generated
+18
@@ -341,6 +341,24 @@ SSDP = {
|
||||
"manufacturer": "Synology",
|
||||
},
|
||||
],
|
||||
"unifi": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
"modelDescription": "UniFi Dream Machine",
|
||||
},
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
"modelDescription": "UniFi Dream Machine Pro",
|
||||
},
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
"modelDescription": "UniFi Dream Machine SE",
|
||||
},
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
"modelDescription": "UniFi Dream Machine Pro Max",
|
||||
},
|
||||
],
|
||||
"unifi_discovery": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
||||
@@ -1160,26 +1160,6 @@ class TodoGetItemsTool(Tool):
|
||||
return {"success": True, "result": items}
|
||||
|
||||
|
||||
def _live_context_match_error(
|
||||
match_result: intent.MatchTargetsResult,
|
||||
name_filter: str | None,
|
||||
area_filter: str | None,
|
||||
domain_filter: list[str] | None,
|
||||
) -> str:
|
||||
"""Build an actionable error message for a failed GetLiveContext match."""
|
||||
reason = match_result.no_match_reason
|
||||
if reason is intent.MatchFailedReason.INVALID_AREA:
|
||||
return f"Area '{match_result.no_match_name}' does not exist"
|
||||
if reason is intent.MatchFailedReason.NAME:
|
||||
return f"No exposed entities matched name '{name_filter}'"
|
||||
if reason is intent.MatchFailedReason.AREA:
|
||||
return f"No exposed entities found in area '{area_filter}'"
|
||||
if reason is intent.MatchFailedReason.DOMAIN:
|
||||
domains = ", ".join(domain_filter) if domain_filter else ""
|
||||
return f"No exposed entities found in domain(s): {domains}"
|
||||
return "No entities matched the provided filter"
|
||||
|
||||
|
||||
class GetLiveContextTool(Tool):
|
||||
"""Tool for getting the current state of exposed entities.
|
||||
|
||||
@@ -1193,25 +1173,7 @@ class GetLiveContextTool(Tool):
|
||||
"Provides real-time information about the CURRENT state, value, or mode of devices, sensors, entities, or areas. "
|
||||
"Use this tool for: "
|
||||
"1. Answering questions about current conditions (e.g., 'Is the light on?'). "
|
||||
"2. As the first step in conditional actions (e.g., 'If the weather is rainy, turn off sprinklers' requires checking the weather first). "
|
||||
"You may filter for devices by name, domain, and area, including combining those filters. "
|
||||
"Prefer filtering by domain when searching for multiple devices of the same type."
|
||||
)
|
||||
parameters = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
"name",
|
||||
description="Filter entities by name or alias (case-insensitive).",
|
||||
): cv.string,
|
||||
vol.Optional(
|
||||
"domain",
|
||||
description="Filter entities by domain (e.g. 'light', 'sensor'). Accepts a single domain or a list.",
|
||||
): vol.Any(cv.string, [cv.string]),
|
||||
vol.Optional(
|
||||
"area",
|
||||
description="Filter entities by area name or alias (case-insensitive).",
|
||||
): cv.string,
|
||||
}
|
||||
"2. As the first step in conditional actions (e.g., 'If the weather is rainy, turn off sprinklers' requires checking the weather first)."
|
||||
)
|
||||
|
||||
async def async_call(
|
||||
@@ -1226,62 +1188,12 @@ class GetLiveContextTool(Tool):
|
||||
# exposed if no assistant is configured.
|
||||
return {"success": False, "error": "No assistant configured"}
|
||||
|
||||
args = self.parameters(tool_input.tool_args)
|
||||
exposed_entities = _get_exposed_entities(hass, llm_context.assistant)
|
||||
|
||||
if not exposed_entities["entities"]:
|
||||
return {"success": False, "error": NO_ENTITIES_PROMPT}
|
||||
|
||||
name_filter = args.get("name")
|
||||
area_filter = args.get("area")
|
||||
domain_filter = args.get("domain")
|
||||
|
||||
if isinstance(domain_filter, str):
|
||||
domain_filter = [domain_filter]
|
||||
|
||||
if domain_filter is not None:
|
||||
domain_filter = [
|
||||
normalized_domain
|
||||
for domain in domain_filter
|
||||
if (normalized_domain := domain.strip().lower())
|
||||
]
|
||||
|
||||
if name_filter or area_filter or domain_filter:
|
||||
exposed_states = [
|
||||
state
|
||||
for entity_id in exposed_entities["entities"]
|
||||
if (state := hass.states.get(entity_id)) is not None
|
||||
]
|
||||
match_result = intent.async_match_targets(
|
||||
hass,
|
||||
intent.MatchTargetsConstraints(
|
||||
name=name_filter,
|
||||
area_name=area_filter,
|
||||
domains=domain_filter,
|
||||
),
|
||||
states=exposed_states,
|
||||
)
|
||||
|
||||
if not match_result.is_match:
|
||||
return {
|
||||
"success": False,
|
||||
"error": _live_context_match_error(
|
||||
match_result, name_filter, area_filter, domain_filter
|
||||
),
|
||||
}
|
||||
|
||||
matched_ids = {state.entity_id for state in match_result.states}
|
||||
entities = [
|
||||
info
|
||||
for entity_id, info in exposed_entities["entities"].items()
|
||||
if entity_id in matched_ids
|
||||
]
|
||||
else:
|
||||
entities = list(exposed_entities["entities"].values())
|
||||
|
||||
prompt = [
|
||||
"Live Context: An overview of the areas and the devices in this smart home:",
|
||||
yaml_util.dump(entities),
|
||||
yaml_util.dump(list(exposed_entities["entities"].values())),
|
||||
]
|
||||
return {
|
||||
"success": True,
|
||||
|
||||
@@ -702,7 +702,7 @@ class _ScriptRun:
|
||||
with trace_path(condition_path):
|
||||
for idx, cond in enumerate(conditions):
|
||||
with trace_path(str(idx)):
|
||||
if cond.async_check(variables=variables) is False:
|
||||
if cond(hass, variables) is False:
|
||||
return False
|
||||
except exceptions.ConditionError as ex:
|
||||
self._log(
|
||||
@@ -753,7 +753,7 @@ class _ScriptRun:
|
||||
trace_element = trace_stack_top(trace_stack_cv)
|
||||
if trace_element:
|
||||
trace_element.reuse_by_child = True
|
||||
check = cond.async_check(variables=self._variables)
|
||||
check = cond(self._hass, self._variables)
|
||||
except exceptions.ConditionError as ex:
|
||||
self._log("Error in 'condition' evaluation:\n%s", ex, level=logging.WARNING)
|
||||
check = False
|
||||
|
||||
@@ -1771,11 +1771,9 @@ class SelectSelector(Selector[SelectSelectorConfig]):
|
||||
return [parent_schema(vol.Schema(str)(val)) for val in data]
|
||||
|
||||
|
||||
class SerialPortSelectorConfig(BaseSelectorConfig, total=False):
|
||||
class SerialPortSelectorConfig(BaseSelectorConfig):
|
||||
"""Class to represent a serial port selector config."""
|
||||
|
||||
extra_recommended_domains: list[str]
|
||||
|
||||
|
||||
@SELECTORS.register("serial_port")
|
||||
class SerialPortSelector(Selector[SerialPortSelectorConfig]):
|
||||
@@ -1783,11 +1781,7 @@ class SerialPortSelector(Selector[SerialPortSelectorConfig]):
|
||||
|
||||
selector_type = "serial_port"
|
||||
|
||||
CONFIG_SCHEMA = make_selector_config_schema(
|
||||
{
|
||||
vol.Optional("extra_recommended_domains"): [str],
|
||||
}
|
||||
)
|
||||
CONFIG_SCHEMA = make_selector_config_schema()
|
||||
|
||||
def __init__(self, config: SerialPortSelectorConfig | None = None) -> None:
|
||||
"""Instantiate a selector."""
|
||||
|
||||
Generated
+1
-1
@@ -47,7 +47,7 @@ python-slugify==8.0.4
|
||||
PyTurboJPEG==1.8.3
|
||||
PyYAML==6.0.3
|
||||
requests==2.33.1
|
||||
rf-protocols==2.2.0
|
||||
rf-protocols==2.1.0
|
||||
securetar==2026.4.1
|
||||
SQLAlchemy==2.0.49
|
||||
standard-aifc==3.13.0
|
||||
|
||||
Generated
+4
-5
@@ -99,7 +99,7 @@ PyTransportNSW==0.1.1
|
||||
PyTurboJPEG==1.8.3
|
||||
|
||||
# homeassistant.components.vicare
|
||||
PyViCare==2.60.1
|
||||
PyViCare==2.59.0
|
||||
|
||||
# homeassistant.components.xiaomi_aqara
|
||||
PyXiaomiGateway==0.14.3
|
||||
@@ -600,7 +600,7 @@ avea==1.6.1
|
||||
# avion==0.10
|
||||
|
||||
# homeassistant.components.axis
|
||||
axis==69
|
||||
axis==68
|
||||
|
||||
# homeassistant.components.fujitsu_fglair
|
||||
ayla-iot-unofficial==1.4.7
|
||||
@@ -2841,9 +2841,8 @@ renson-endura-delta==1.7.2
|
||||
reolink-aio==0.19.1
|
||||
|
||||
# homeassistant.components.honeywell_string_lights
|
||||
# homeassistant.components.novy_cooker_hood
|
||||
# homeassistant.components.radio_frequency
|
||||
rf-protocols==2.2.0
|
||||
rf-protocols==2.1.0
|
||||
|
||||
# homeassistant.components.idteck_prox
|
||||
rfk101py==0.0.1
|
||||
@@ -3097,7 +3096,7 @@ temperusb==1.6.1
|
||||
# homeassistant.components.tesla_fleet
|
||||
# homeassistant.components.teslemetry
|
||||
# homeassistant.components.tessie
|
||||
tesla-fleet-api==1.4.7
|
||||
tesla-fleet-api==1.4.5
|
||||
|
||||
# homeassistant.components.powerwall
|
||||
tesla-powerwall==0.5.2
|
||||
|
||||
Generated
+4
-5
@@ -96,7 +96,7 @@ PyTransportNSW==0.1.1
|
||||
PyTurboJPEG==1.8.3
|
||||
|
||||
# homeassistant.components.vicare
|
||||
PyViCare==2.60.1
|
||||
PyViCare==2.59.0
|
||||
|
||||
# homeassistant.components.xiaomi_aqara
|
||||
PyXiaomiGateway==0.14.3
|
||||
@@ -552,7 +552,7 @@ autoskope_client==1.4.1
|
||||
av==16.0.1
|
||||
|
||||
# homeassistant.components.axis
|
||||
axis==69
|
||||
axis==68
|
||||
|
||||
# homeassistant.components.fujitsu_fglair
|
||||
ayla-iot-unofficial==1.4.7
|
||||
@@ -2425,9 +2425,8 @@ renson-endura-delta==1.7.2
|
||||
reolink-aio==0.19.1
|
||||
|
||||
# homeassistant.components.honeywell_string_lights
|
||||
# homeassistant.components.novy_cooker_hood
|
||||
# homeassistant.components.radio_frequency
|
||||
rf-protocols==2.2.0
|
||||
rf-protocols==2.1.0
|
||||
|
||||
# homeassistant.components.rflink
|
||||
rflink==0.0.67
|
||||
@@ -2627,7 +2626,7 @@ temperusb==1.6.1
|
||||
# homeassistant.components.tesla_fleet
|
||||
# homeassistant.components.teslemetry
|
||||
# homeassistant.components.tessie
|
||||
tesla-fleet-api==1.4.7
|
||||
tesla-fleet-api==1.4.5
|
||||
|
||||
# homeassistant.components.powerwall
|
||||
tesla-powerwall==0.5.2
|
||||
|
||||
@@ -15,53 +15,15 @@ GENERATED_MESSAGE = (
|
||||
|
||||
AGENTS_FILE = Path("AGENTS.md")
|
||||
OUTPUT_FILE = Path(".github/copilot-instructions.md")
|
||||
INTEGRATION_SKILL_FILE = Path(".claude/skills/ha-integration-knowledge/SKILL.md")
|
||||
INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE = Path(
|
||||
".github/instructions/integrations.instructions.md"
|
||||
)
|
||||
|
||||
COPILOT_SPECIFIC_INSTRUCTIONS = """
|
||||
# Copilot code review instructions
|
||||
|
||||
- Start review comments with a short, one-sentence summary of the suggested fix.
|
||||
- Do not comment on code style, formatting or linting issues.
|
||||
- Do comment on code style, formatting or linting issues.
|
||||
- When reviewing an integration, follow the instructions in .claude/skills/ha-integration-knowledge/SKILL.md
|
||||
"""
|
||||
|
||||
INTEGRATION_PATH_SPECIFIC_INSTRUCTIONS = """---
|
||||
applyTo: "homeassistant/components/**, tests/components/**"
|
||||
excludeAgent: "cloud-agent"
|
||||
---
|
||||
"""
|
||||
|
||||
|
||||
def _strip_frontmatter(text: str) -> str:
|
||||
"""Strip YAML frontmatter from the start of a markdown document."""
|
||||
if not text.startswith("---\n"):
|
||||
return text
|
||||
|
||||
end = text.find("\n---\n", 4)
|
||||
if end == -1:
|
||||
return text
|
||||
|
||||
return text[end + len("\n---\n") :].lstrip("\n")
|
||||
|
||||
|
||||
def generate_integration_path_specific_instructions() -> str:
|
||||
"""Generate instructions for integration paths."""
|
||||
if not INTEGRATION_SKILL_FILE.exists():
|
||||
print(f"Error: {INTEGRATION_SKILL_FILE} not found")
|
||||
sys.exit(1)
|
||||
|
||||
skill_content = _strip_frontmatter(INTEGRATION_SKILL_FILE.read_text())
|
||||
|
||||
return (
|
||||
INTEGRATION_PATH_SPECIFIC_INSTRUCTIONS
|
||||
+ "\n"
|
||||
+ GENERATED_MESSAGE
|
||||
+ "\n"
|
||||
+ skill_content
|
||||
)
|
||||
|
||||
|
||||
def generate_output() -> str:
|
||||
"""Generate the copilot-instructions.md content."""
|
||||
@@ -79,44 +41,30 @@ def generate_output() -> str:
|
||||
return "\n".join(output_parts)
|
||||
|
||||
|
||||
def check_file(path: Path, expected_content: str):
|
||||
"""Check if the file exists and has the expected content."""
|
||||
if not path.exists():
|
||||
print(f"Error: {path} does not exist")
|
||||
sys.exit(1)
|
||||
|
||||
existing = path.read_text()
|
||||
if existing != expected_content:
|
||||
print(f"Error: {path} is out of date")
|
||||
print("Please run: python -m script.gen_copilot_instructions")
|
||||
sys.exit(1)
|
||||
|
||||
print(f"{path} is up to date")
|
||||
|
||||
|
||||
def main(validate: bool = False) -> int:
|
||||
"""Run the script."""
|
||||
if not Path("homeassistant").is_dir():
|
||||
print("Run this from HA root dir")
|
||||
return 1
|
||||
|
||||
main_content = generate_output()
|
||||
integration_path_specific_content = (
|
||||
generate_integration_path_specific_instructions()
|
||||
)
|
||||
content = generate_output()
|
||||
|
||||
if validate:
|
||||
check_file(OUTPUT_FILE, main_content)
|
||||
check_file(
|
||||
INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE, integration_path_specific_content
|
||||
)
|
||||
if not OUTPUT_FILE.exists():
|
||||
print(f"Error: {OUTPUT_FILE} does not exist")
|
||||
return 1
|
||||
|
||||
existing = OUTPUT_FILE.read_text()
|
||||
if existing != content:
|
||||
print(f"Error: {OUTPUT_FILE} is out of date")
|
||||
print("Please run: python -m script.gen_copilot_instructions")
|
||||
return 1
|
||||
|
||||
print(f"{OUTPUT_FILE} is up to date")
|
||||
return 0
|
||||
|
||||
OUTPUT_FILE.write_text(main_content)
|
||||
OUTPUT_FILE.write_text(content)
|
||||
print(f"Generated {OUTPUT_FILE}")
|
||||
|
||||
INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE.write_text(integration_path_specific_content)
|
||||
print(f"Generated {INTEGRATION_PATH_SPECIFIC_OUTPUT_FILE}")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -2,7 +2,7 @@
|
||||
# Automatically generated by hassfest.
|
||||
#
|
||||
# To update, run python3 -m script.hassfest -p docker
|
||||
FROM python:3.14.3-alpine
|
||||
FROM python:3.14.2-alpine
|
||||
|
||||
ENV \
|
||||
UV_SYSTEM_PYTHON=true \
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from actron_neo_api import ActronAirAPIError
|
||||
from actron_neo_api.models.settings import ActronAirModeSupport
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
@@ -363,122 +362,3 @@ async def test_zone_hvac_mode_inactive(
|
||||
|
||||
state = hass.states.get("climate.living_room")
|
||||
assert state.state == "off"
|
||||
|
||||
|
||||
async def test_system_hvac_modes_default(
|
||||
hass: HomeAssistant,
|
||||
mock_actron_api: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test system reports correct HVAC modes when DRY is not supported."""
|
||||
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("climate.test_system")
|
||||
assert state.attributes["hvac_modes"] == [
|
||||
HVACMode.COOL,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.AUTO,
|
||||
HVACMode.OFF,
|
||||
]
|
||||
|
||||
|
||||
async def test_system_hvac_modes_with_dry(
|
||||
hass: HomeAssistant,
|
||||
mock_actron_api: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test system reports DRY HVAC mode when hardware supports it."""
|
||||
|
||||
status = mock_actron_api.state_manager.get_status.return_value
|
||||
status.user_aircon_settings.mode_support = ActronAirModeSupport(
|
||||
Cool=True, Heat=True, Fan=True, Auto=True, Dry=True
|
||||
)
|
||||
|
||||
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("climate.test_system")
|
||||
assert state.attributes["hvac_modes"] == [
|
||||
HVACMode.COOL,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.AUTO,
|
||||
HVACMode.DRY,
|
||||
HVACMode.OFF,
|
||||
]
|
||||
|
||||
|
||||
async def test_system_hvac_modes_no_mode_support(
|
||||
hass: HomeAssistant,
|
||||
mock_actron_api: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test system falls back to default modes when ModeSupport is absent."""
|
||||
status = mock_actron_api.state_manager.get_status.return_value
|
||||
status.user_aircon_settings.mode_support = None
|
||||
|
||||
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("climate.test_system")
|
||||
assert state.attributes["hvac_modes"] == [
|
||||
HVACMode.COOL,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.AUTO,
|
||||
HVACMode.OFF,
|
||||
]
|
||||
|
||||
|
||||
async def test_zone_hvac_modes_with_dry(
|
||||
hass: HomeAssistant,
|
||||
mock_actron_api: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_zone: MagicMock,
|
||||
) -> None:
|
||||
"""Test zone reports DRY HVAC mode when hardware supports it."""
|
||||
|
||||
status = mock_actron_api.state_manager.get_status.return_value
|
||||
status.user_aircon_settings.mode_support = ActronAirModeSupport(
|
||||
Cool=True, Heat=True, Fan=True, Auto=True, Dry=True
|
||||
)
|
||||
status.remote_zone_info = [mock_zone]
|
||||
|
||||
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("climate.living_room")
|
||||
assert state.attributes["hvac_modes"] == [
|
||||
HVACMode.COOL,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.AUTO,
|
||||
HVACMode.DRY,
|
||||
HVACMode.OFF,
|
||||
]
|
||||
|
||||
|
||||
async def test_zone_hvac_modes_no_mode_support(
|
||||
hass: HomeAssistant,
|
||||
mock_actron_api: MagicMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_zone: MagicMock,
|
||||
) -> None:
|
||||
"""Test zone falls back to default modes when ModeSupport is absent."""
|
||||
status = mock_actron_api.state_manager.get_status.return_value
|
||||
status.user_aircon_settings.mode_support = None
|
||||
status.remote_zone_info = [mock_zone]
|
||||
|
||||
with patch("homeassistant.components.actron_air.PLATFORMS", [Platform.CLIMATE]):
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
state = hass.states.get("climate.living_room")
|
||||
assert state.attributes["hvac_modes"] == [
|
||||
HVACMode.COOL,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.AUTO,
|
||||
HVACMode.OFF,
|
||||
]
|
||||
|
||||
@@ -4050,53 +4050,3 @@ async def test_reload_when_labs_flag_changes(
|
||||
await hass.async_block_till_done()
|
||||
assert len(calls) == 1
|
||||
assert calls[-1].data.get("event") == "test_event2"
|
||||
|
||||
|
||||
async def test_remove_automation_unloads_condition_and_script(
|
||||
hass: HomeAssistant,
|
||||
calls: list[ServiceCall],
|
||||
) -> None:
|
||||
"""Test that removing an automation unloads its condition and action script."""
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
automation.DOMAIN,
|
||||
{
|
||||
automation.DOMAIN: {
|
||||
"id": "sun",
|
||||
"alias": "test_unload",
|
||||
"trigger": {"platform": "event", "event_type": "test_event"},
|
||||
"condition": {
|
||||
"condition": "state",
|
||||
"entity_id": "binary_sensor.test",
|
||||
"state": "on",
|
||||
},
|
||||
"action": {"action": "test.automation"},
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
entity = hass.data[automation.DATA_COMPONENT].get_entity("automation.test_unload")
|
||||
assert entity is not None
|
||||
assert isinstance(entity, AutomationEntity)
|
||||
|
||||
# Reload with empty config to remove the automation
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.config.load_yaml_config_file",
|
||||
autospec=True,
|
||||
return_value={automation.DOMAIN: []},
|
||||
),
|
||||
patch.object(
|
||||
entity._condition, "async_unload", wraps=entity._condition.async_unload
|
||||
) as condition_unload,
|
||||
patch.object(
|
||||
entity.action_script,
|
||||
"async_unload",
|
||||
wraps=entity.action_script.async_unload,
|
||||
) as script_unload,
|
||||
):
|
||||
await hass.services.async_call(automation.DOMAIN, SERVICE_RELOAD, blocking=True)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition_unload.assert_called_once()
|
||||
script_unload.assert_called_once()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user