Compare commits

..

1 Commits

Author SHA1 Message Date
abmantis 6ba411b070 Remove autogenerated list of skills from copilot-instructions 2026-04-28 18:42:39 +01:00
159 changed files with 621 additions and 5641 deletions
+2 -1
View File
@@ -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
+1 -1
View File
@@ -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
View File
@@ -1 +1 @@
3.14.3
3.14.2
Generated
-2
View File
@@ -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
+1 -24
View File
@@ -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."""
-1
View File
@@ -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,
)
)
+1 -1
View File
@@ -29,7 +29,7 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["axis"],
"requirements": ["axis==69"],
"requirements": ["axis==68"],
"ssdp": [
{
"manufacturer": "AXIS"
+2 -24
View File
@@ -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"],
+1 -6
View File
@@ -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"
+1 -1
View File
@@ -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."
}
-36
View File
@@ -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"]
}
+4 -15
View File
@@ -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:
-12
View File
@@ -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,
),
]
+10
View File
@@ -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": {
-10
View File
@@ -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"]
}
+1 -7
View File
@@ -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
+20 -79
View File
@@ -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]]):
+3 -9
View File
@@ -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]):
+16 -24
View File
@@ -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)
+1 -14
View File
@@ -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()
+30 -23
View File
@@ -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()
+19 -2
View File
@@ -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 = (
+2 -37
View File
@@ -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"
},
+1 -130
View File
@@ -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")
+4 -16
View File
@@ -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:
+3 -10
View File
@@ -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"
+1 -10
View File
@@ -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)
+1 -17
View File
@@ -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)
+7 -44
View File
@@ -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(
-1
View File
@@ -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",
+18
View File
@@ -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",
+2 -90
View File
@@ -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,
+2 -2
View File
@@ -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
+2 -8
View File
@@ -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."""
+1 -1
View File
@@ -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
+4 -5
View File
@@ -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
+4 -5
View File
@@ -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 -67
View File
@@ -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
+1 -1
View File
@@ -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 \
-120
View File
@@ -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,
]
-50
View File
@@ -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