Compare commits

..

1 Commits

Author SHA1 Message Date
Erik 918185fda4 Extend script continue_on_error suppression when calling actions 2026-04-21 10:01:08 +02:00
423 changed files with 2109 additions and 21904 deletions
+2 -2
View File
@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
with:
category: "/language:python"
+1 -1
View File
@@ -18,7 +18,7 @@ repos:
exclude_types: [csv, json, html]
exclude: ^tests/fixtures/|homeassistant/generated/|tests/components/.*/snapshots/
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.24.1
rev: v1.24.0
hooks:
- id: zizmor
args:
Generated
-4
View File
@@ -400,8 +400,6 @@ CLAUDE.md @home-assistant/core
/tests/components/dnsip/ @gjohansson-ST
/homeassistant/components/door/ @home-assistant/core
/tests/components/door/ @home-assistant/core
/homeassistant/components/doorbell/ @home-assistant/core
/tests/components/doorbell/ @home-assistant/core
/homeassistant/components/doorbird/ @oblogic7 @bdraco @flacjacket
/tests/components/doorbird/ @oblogic7 @bdraco @flacjacket
/homeassistant/components/dormakaba_dkey/ @emontnemery
@@ -1255,8 +1253,6 @@ CLAUDE.md @home-assistant/core
/tests/components/open_meteo/ @frenck
/homeassistant/components/open_router/ @joostlek @ab3lson
/tests/components/open_router/ @joostlek @ab3lson
/homeassistant/components/openai_conversation/ @Shulyaka
/tests/components/openai_conversation/ @Shulyaka
/homeassistant/components/opendisplay/ @g4bri3lDev
/tests/components/opendisplay/ @g4bri3lDev
/homeassistant/components/openerz/ @misialq
+19 -26
View File
@@ -30,7 +30,7 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .const import CONF_POLLING, DOMAIN, LOGGER
from .const import CONF_POLLING, DOMAIN, DOMAIN_DATA, LOGGER
from .services import async_setup_services
ATTR_DEVICE_NAME = "device_name"
@@ -67,16 +67,13 @@ class AbodeSystem:
logout_listener: CALLBACK_TYPE | None = None
type AbodeConfigEntry = ConfigEntry[AbodeSystem]
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Abode component."""
async_setup_services(hass)
return True
async def async_setup_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Abode integration from a config entry."""
username = entry.data[CONF_USERNAME]
password = entry.data[CONF_PASSWORD]
@@ -102,54 +99,50 @@ async def async_setup_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> boo
except (AbodeException, ConnectTimeout, HTTPError) as ex:
raise ConfigEntryNotReady(f"Unable to connect to Abode: {ex}") from ex
entry.runtime_data = AbodeSystem(abode, polling)
hass.data[DOMAIN_DATA] = AbodeSystem(abode, polling)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
await setup_hass_events(hass, entry)
await hass.async_add_executor_job(setup_abode_events, hass, entry)
await setup_hass_events(hass)
await hass.async_add_executor_job(setup_abode_events, hass)
return True
def _shutdown_client(abode: Abode) -> None:
"""Shutdown client."""
abode.events.stop()
abode.logout()
async def async_unload_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
await hass.async_add_executor_job(_shutdown_client, entry.runtime_data.abode)
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.stop)
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.logout)
if logout_listener := entry.runtime_data.logout_listener:
if logout_listener := hass.data[DOMAIN_DATA].logout_listener:
logout_listener()
hass.data.pop(DOMAIN_DATA)
return unload_ok
async def setup_hass_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None:
async def setup_hass_events(hass: HomeAssistant) -> None:
"""Home Assistant start and stop callbacks."""
def logout(event: Event) -> None:
"""Logout of Abode."""
if not entry.runtime_data.polling:
entry.runtime_data.abode.events.stop()
if not hass.data[DOMAIN_DATA].polling:
hass.data[DOMAIN_DATA].abode.events.stop()
entry.runtime_data.abode.logout()
hass.data[DOMAIN_DATA].abode.logout()
LOGGER.info("Logged out of Abode")
if not entry.runtime_data.polling:
await hass.async_add_executor_job(entry.runtime_data.abode.events.start)
if not hass.data[DOMAIN_DATA].polling:
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.start)
entry.runtime_data.logout_listener = hass.bus.async_listen_once(
hass.data[DOMAIN_DATA].logout_listener = hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_STOP, logout
)
def setup_abode_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None:
def setup_abode_events(hass: HomeAssistant) -> None:
"""Event callbacks."""
def event_callback(event: str, event_json: dict[str, str]) -> None:
@@ -186,6 +179,6 @@ def setup_abode_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None:
]
for event in events:
entry.runtime_data.abode.events.add_event_callback(
hass.data[DOMAIN_DATA].abode.events.add_event_callback(
event, partial(event_callback, event)
)
@@ -9,20 +9,21 @@ from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode alarm control panel device."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
[AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))]
)
@@ -10,21 +10,22 @@ from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.enum import try_parse_enum
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode binary sensor devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
device_types = [
"connectivity",
+5 -4
View File
@@ -12,13 +12,14 @@ import requests
from requests.models import Response
from homeassistant.components.camera import Camera
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import Event, HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import Throttle
from . import AbodeConfigEntry, AbodeSystem
from .const import LOGGER
from . import AbodeSystem
from .const import DOMAIN_DATA, LOGGER
from .entity import AbodeDevice
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
@@ -26,11 +27,11 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode camera devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeCamera(data, device, timeline.CAPTURE_IMAGE)
+7
View File
@@ -3,10 +3,17 @@
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import AbodeSystem
LOGGER = logging.getLogger(__package__)
DOMAIN = "abode"
DOMAIN_DATA: HassKey[AbodeSystem] = HassKey(DOMAIN)
ATTRIBUTION = "Data provided by goabode.com"
CONF_POLLING = "polling"
+4 -3
View File
@@ -5,20 +5,21 @@ from typing import Any
from jaraco.abode.devices.cover import Cover
from homeassistant.components.cover import CoverEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode cover devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeCover(data, device)
+2 -2
View File
@@ -7,7 +7,7 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from . import AbodeSystem
from .const import ATTRIBUTION, DOMAIN
from .const import ATTRIBUTION, DOMAIN, DOMAIN_DATA
class AbodeEntity(Entity):
@@ -29,7 +29,7 @@ class AbodeEntity(Entity):
self._update_connection_status,
)
self._data.entity_ids.add(self.entity_id)
self.hass.data[DOMAIN_DATA].entity_ids.add(self.entity_id)
async def async_will_remove_from_hass(self) -> None:
"""Unsubscribe from Abode connection status updates."""
+4 -3
View File
@@ -16,20 +16,21 @@ from homeassistant.components.light import (
ColorMode,
LightEntity,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode light devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeLight(data, device)
+4 -3
View File
@@ -5,20 +5,21 @@ from typing import Any
from jaraco.abode.devices.lock import Lock
from homeassistant.components.lock import LockEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeDevice
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode lock devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeLock(data, device)
+5 -3
View File
@@ -14,11 +14,13 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import LIGHT_LUX, PERCENTAGE, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry, AbodeSystem
from . import AbodeSystem
from .const import DOMAIN_DATA
from .entity import AbodeDevice
ABODE_TEMPERATURE_UNIT_HA_UNIT = {
@@ -64,11 +66,11 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode sensor devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
async_add_entities(
AbodeSensor(data, device, description)
+4 -18
View File
@@ -2,21 +2,15 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from jaraco.abode.exceptions import Exception as AbodeException
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.dispatcher import dispatcher_send
from .const import DOMAIN, LOGGER
if TYPE_CHECKING:
from . import AbodeConfigEntry, AbodeSystem
from .const import DOMAIN, DOMAIN_DATA, LOGGER
ATTR_SETTING = "setting"
ATTR_VALUE = "value"
@@ -31,21 +25,13 @@ CAPTURE_IMAGE_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
AUTOMATION_SCHEMA = vol.Schema({ATTR_ENTITY_ID: cv.entity_ids})
def _get_abode_system(hass: HomeAssistant) -> AbodeSystem:
"""Return the Abode system for the loaded config entry."""
entries: list[AbodeConfigEntry] = hass.config_entries.async_loaded_entries(DOMAIN)
if not entries:
raise ServiceValidationError("Abode integration is not loaded")
return entries[0].runtime_data
def _change_setting(call: ServiceCall) -> None:
"""Change an Abode system setting."""
setting = call.data[ATTR_SETTING]
value = call.data[ATTR_VALUE]
try:
_get_abode_system(call.hass).abode.set_setting(setting, value)
call.hass.data[DOMAIN_DATA].abode.set_setting(setting, value)
except AbodeException as ex:
LOGGER.warning(ex)
@@ -56,7 +42,7 @@ def _capture_image(call: ServiceCall) -> None:
target_entities = [
entity_id
for entity_id in _get_abode_system(call.hass).entity_ids
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
if entity_id in entity_ids
]
@@ -71,7 +57,7 @@ def _trigger_automation(call: ServiceCall) -> None:
target_entities = [
entity_id
for entity_id in _get_abode_system(call.hass).entity_ids
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
if entity_id in entity_ids
]
+4 -3
View File
@@ -7,11 +7,12 @@ from typing import Any, cast
from jaraco.abode.devices.switch import Switch
from homeassistant.components.switch import SwitchEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import AbodeConfigEntry
from .const import DOMAIN_DATA
from .entity import AbodeAutomation, AbodeDevice
DEVICE_TYPES = ["switch", "valve"]
@@ -19,11 +20,11 @@ DEVICE_TYPES = ["switch", "valve"]
async def async_setup_entry(
hass: HomeAssistant,
entry: AbodeConfigEntry,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Abode switch devices."""
data = entry.runtime_data
data = hass.data[DOMAIN_DATA]
entities: list[SwitchEntity] = [
AbodeSwitch(data, device)
@@ -36,9 +36,7 @@ def _make_detected_condition(
) -> type[Condition]:
"""Create a detected condition for a binary sensor device class."""
return make_entity_state_condition(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)},
STATE_ON,
support_duration=True,
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON
)
@@ -47,9 +45,7 @@ def _make_cleared_condition(
) -> type[Condition]:
"""Create a cleared condition for a binary sensor device class."""
return make_entity_state_condition(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)},
STATE_OFF,
support_duration=True,
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF
)
@@ -249,11 +249,6 @@
.condition_binary_common: &condition_binary_common
fields:
behavior: *condition_behavior
for:
required: true
default: 00:00:00
selector:
duration:
is_gas_detected:
<<: *condition_binary_common
@@ -1,7 +1,6 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
@@ -25,9 +24,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Carbon monoxide cleared"
@@ -37,9 +33,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Carbon monoxide detected"
@@ -61,9 +54,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Gas cleared"
@@ -73,9 +63,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Gas detected"
@@ -181,9 +168,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Smoke cleared"
@@ -193,9 +177,6 @@
"fields": {
"behavior": {
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::air_quality::common::condition_for_name%]"
}
},
"name": "Smoke detected"
@@ -4,7 +4,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR,
Condition,
EntityStateConditionBase,
make_entity_state_condition,
@@ -26,7 +25,6 @@ class EntityStateRequiredFeaturesCondition(EntityStateConditionBase):
"""State condition."""
_required_features: int
_schema = ENTITY_STATE_CONDITION_SCHEMA_ANY_ALL_FOR
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain with the required features."""
@@ -84,11 +82,9 @@ CONDITIONS: dict[str, type[Condition]] = {
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"is_disarmed": make_entity_state_condition(
DOMAIN, AlarmControlPanelState.DISARMED, support_duration=True
),
"is_disarmed": make_entity_state_condition(DOMAIN, AlarmControlPanelState.DISARMED),
"is_triggered": make_entity_state_condition(
DOMAIN, AlarmControlPanelState.TRIGGERED, support_duration=True
DOMAIN, AlarmControlPanelState.TRIGGERED
),
}
@@ -1,9 +1,9 @@
.condition_common: &condition_common
target: &condition_common_target
target:
entity:
domain: alarm_control_panel
fields: &condition_common_fields
behavior: &condition_common_behavior
behavior:
required: true
default: any
selector:
@@ -13,20 +13,10 @@
- all
- any
.condition_common_for: &condition_common_for
target: *condition_common_target
fields: &condition_common_for_fields
behavior: *condition_common_behavior
for:
required: true
default: 00:00:00
selector:
duration:
is_armed: *condition_common
is_armed_away:
fields: *condition_common_for_fields
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
@@ -34,7 +24,7 @@ is_armed_away:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
is_armed_home:
fields: *condition_common_for_fields
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
@@ -42,7 +32,7 @@ is_armed_home:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
is_armed_night:
fields: *condition_common_for_fields
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
@@ -50,13 +40,13 @@ is_armed_night:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
is_armed_vacation:
fields: *condition_common_for_fields
fields: *condition_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
is_disarmed: *condition_common_for
is_disarmed: *condition_common
is_triggered: *condition_common_for
is_triggered: *condition_common
@@ -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"
},
@@ -20,9 +19,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed away"
@@ -32,9 +28,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed home"
@@ -44,9 +37,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed night"
@@ -56,9 +46,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is armed vacation"
@@ -68,9 +55,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is disarmed"
@@ -80,9 +64,6 @@
"fields": {
"behavior": {
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::alarm_control_panel::common::condition_for_name%]"
}
},
"name": "Alarm is triggered"
@@ -43,6 +43,7 @@ from homeassistant.helpers.selector import (
from homeassistant.helpers.typing import VolDictType
from .const import (
CODE_EXECUTION_UNSUPPORTED_MODELS,
CONF_CHAT_MODEL,
CONF_CODE_EXECUTION,
CONF_MAX_TOKENS,
@@ -65,6 +66,7 @@ from .const import (
DOMAIN,
MIN_THINKING_BUDGET,
TOOL_SEARCH_UNSUPPORTED_MODELS,
WEB_SEARCH_UNSUPPORTED_MODELS,
PromptCaching,
)
from .coordinator import model_alias
@@ -387,6 +389,8 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
else cv.positive_int,
}
model = self.options[CONF_CHAT_MODEL]
if (
self.model_info.capabilities
and self.model_info.capabilities.thinking.supported
@@ -441,34 +445,43 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
else:
self.options.pop(CONF_THINKING_EFFORT, None)
step_schema.update(
{
if not model.startswith(tuple(CODE_EXECUTION_UNSUPPORTED_MODELS)):
step_schema[
vol.Optional(
CONF_CODE_EXECUTION,
default=DEFAULT[CONF_CODE_EXECUTION],
): bool,
vol.Optional(
CONF_WEB_SEARCH,
default=DEFAULT[CONF_WEB_SEARCH],
): bool,
vol.Optional(
CONF_WEB_SEARCH_MAX_USES,
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
): int,
vol.Optional(
CONF_WEB_SEARCH_USER_LOCATION,
default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
): bool,
}
)
)
] = bool
else:
self.options.pop(CONF_CODE_EXECUTION, None)
if not model.startswith(tuple(WEB_SEARCH_UNSUPPORTED_MODELS)):
step_schema.update(
{
vol.Optional(
CONF_WEB_SEARCH,
default=DEFAULT[CONF_WEB_SEARCH],
): bool,
vol.Optional(
CONF_WEB_SEARCH_MAX_USES,
default=DEFAULT[CONF_WEB_SEARCH_MAX_USES],
): int,
vol.Optional(
CONF_WEB_SEARCH_USER_LOCATION,
default=DEFAULT[CONF_WEB_SEARCH_USER_LOCATION],
): bool,
}
)
else:
self.options.pop(CONF_WEB_SEARCH, None)
self.options.pop(CONF_WEB_SEARCH_MAX_USES, None)
self.options.pop(CONF_WEB_SEARCH_USER_LOCATION, None)
self.options.pop(CONF_WEB_SEARCH_CITY, None)
self.options.pop(CONF_WEB_SEARCH_REGION, None)
self.options.pop(CONF_WEB_SEARCH_COUNTRY, None)
self.options.pop(CONF_WEB_SEARCH_TIMEZONE, None)
model = self.options[CONF_CHAT_MODEL]
if not model.startswith(tuple(TOOL_SEARCH_UNSUPPORTED_MODELS)):
step_schema[
vol.Optional(
@@ -50,6 +50,15 @@ DEFAULT = {
CONF_WEB_SEARCH_MAX_USES: 5,
}
WEB_SEARCH_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
CODE_EXECUTION_UNSUPPORTED_MODELS = [
"claude-3-haiku",
]
TOOL_SEARCH_UNSUPPORTED_MODELS = [
"claude-3",
"claude-haiku",
]
@@ -28,7 +28,9 @@ _model_short_form = re.compile(r"[^\d]-\d$")
@callback
def model_alias(model_id: str) -> str:
"""Resolve alias from versioned model name."""
if model_id[-2:-1] != "-" and not model_id.endswith("-preview"):
if model_id == "claude-3-haiku-20240307" or model_id.endswith("-preview"):
return model_id
if model_id[-2:-1] != "-":
model_id = model_id[:-9]
if _model_short_form.search(model_id):
return model_id + "-0"
+1 -5
View File
@@ -124,14 +124,10 @@ def _format_tool(
tool: llm.Tool, custom_serializer: Callable[[Any], Any] | None
) -> ToolParam:
"""Format tool specification."""
unsupported_keys = {"oneOf", "anyOf", "allOf"}
schema = convert(tool.parameters, custom_serializer=custom_serializer)
schema = {k: v for k, v in schema.items() if k not in unsupported_keys}
return ToolParam(
name=tool.name,
description=tool.description or "",
input_schema=schema,
input_schema=convert(tool.parameters, custom_serializer=custom_serializer),
)
@@ -7,17 +7,13 @@ from .const import DOMAIN
from .entity import AssistSatelliteState
CONDITIONS: dict[str, type[Condition]] = {
"is_idle": make_entity_state_condition(
DOMAIN, AssistSatelliteState.IDLE, support_duration=True
),
"is_listening": make_entity_state_condition(
DOMAIN, AssistSatelliteState.LISTENING, support_duration=True
),
"is_idle": make_entity_state_condition(DOMAIN, AssistSatelliteState.IDLE),
"is_listening": make_entity_state_condition(DOMAIN, AssistSatelliteState.LISTENING),
"is_processing": make_entity_state_condition(
DOMAIN, AssistSatelliteState.PROCESSING, support_duration=True
DOMAIN, AssistSatelliteState.PROCESSING
),
"is_responding": make_entity_state_condition(
DOMAIN, AssistSatelliteState.RESPONDING, support_duration=True
DOMAIN, AssistSatelliteState.RESPONDING
),
}
@@ -12,11 +12,6 @@
options:
- all
- any
for:
required: true
default: 00:00:00
selector:
duration:
is_idle: *condition_common
is_listening: *condition_common
@@ -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::assist_satellite::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::condition_for_name%]"
}
},
"name": "Satellite is idle"
@@ -23,9 +19,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::condition_for_name%]"
}
},
"name": "Satellite is listening"
@@ -35,9 +28,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::condition_for_name%]"
}
},
"name": "Satellite is processing"
@@ -47,9 +37,6 @@
"fields": {
"behavior": {
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::assist_satellite::common::condition_for_name%]"
}
},
"name": "Satellite is responding"
@@ -169,7 +169,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"cover",
"device_tracker",
"door",
"doorbell",
"event",
"fan",
"garage_door",
+11 -22
View File
@@ -1,12 +1,8 @@
"""Support for Amazon Web Services (AWS)."""
from __future__ import annotations
import asyncio
from collections import OrderedDict
from dataclasses import dataclass
import logging
from typing import Any
from aiobotocore.session import AioSession
import voluptuous as vol
@@ -34,22 +30,14 @@ from .const import (
CONF_REGION,
CONF_SECRET_ACCESS_KEY,
CONF_VALIDATE,
DATA_AWS,
DATA_CONFIG,
DATA_HASS_CONFIG,
DATA_SESSIONS,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
@dataclass
class AWSData:
"""Runtime data for the AWS integration."""
hass_config: ConfigType
config: dict[str, Any]
sessions: OrderedDict[str, AioSession]
AWS_CREDENTIAL_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
@@ -100,13 +88,14 @@ CONFIG_SCHEMA = vol.Schema(
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up AWS component."""
hass.data[DATA_HASS_CONFIG] = config
if (conf := config.get(DOMAIN)) is None:
# create a default conf using default profile
conf = CONFIG_SCHEMA({ATTR_CREDENTIALS: DEFAULT_CREDENTIAL})
hass.data[DATA_AWS] = AWSData(
hass_config=config, config=conf, sessions=OrderedDict()
)
hass.data[DATA_CONFIG] = conf
hass.data[DATA_SESSIONS] = OrderedDict()
hass.async_create_task(
hass.config_entries.flow.async_init(
@@ -122,8 +111,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
Validate and save sessions per aws credential.
"""
data = hass.data[DATA_AWS]
conf = data.config
config = hass.data[DATA_HASS_CONFIG]
conf = hass.data[DATA_CONFIG]
if entry.source == config_entries.SOURCE_IMPORT:
if conf is None:
@@ -154,14 +143,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
)
validation = False
else:
data.sessions[name] = result
hass.data[DATA_SESSIONS][name] = result
# set up notify platform, no entry support for notify component yet,
# have to use discovery to load platform.
for notify_config in conf[CONF_NOTIFY]:
hass.async_create_task(
discovery.async_load_platform(
hass, Platform.NOTIFY, DOMAIN, notify_config, data.hass_config
hass, Platform.NOTIFY, DOMAIN, notify_config, config
)
)
+3 -10
View File
@@ -1,17 +1,10 @@
"""Constant for AWS component."""
from __future__ import annotations
from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from . import AWSData
DOMAIN = "aws"
DATA_AWS: HassKey[AWSData] = HassKey(DOMAIN)
DATA_CONFIG = "aws_config"
DATA_HASS_CONFIG = "aws_hass_config"
DATA_SESSIONS = "aws_sessions"
CONF_ACCESS_KEY_ID = "aws_access_key_id"
CONF_CONTEXT = "context"
+4 -6
View File
@@ -27,7 +27,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.json import JSONEncoder
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_CONTEXT, CONF_CREDENTIAL_NAME, CONF_REGION, DATA_AWS
from .const import CONF_CONTEXT, CONF_CREDENTIAL_NAME, CONF_REGION, DATA_SESSIONS
_LOGGER = logging.getLogger(__name__)
@@ -76,12 +76,10 @@ async def async_get_service(
if CONF_CONTEXT in aws_config:
del aws_config[CONF_CONTEXT]
sessions = hass.data[DATA_AWS].sessions
if not aws_config:
# no platform config, use the first aws component credential instead
if sessions:
session = next(iter(sessions.values()))
if hass.data[DATA_SESSIONS]:
session = next(iter(hass.data[DATA_SESSIONS].values()))
else:
_LOGGER.error("Missing aws credential for %s", config[CONF_NAME])
return None
@@ -89,7 +87,7 @@ async def async_get_service(
if session is None:
credential_name = aws_config.get(CONF_CREDENTIAL_NAME)
if credential_name is not None:
session = sessions.get(credential_name)
session = hass.data[DATA_SESSIONS].get(credential_name)
if session is None:
_LOGGER.warning("No available aws session for %s", credential_name)
del aws_config[CONF_CREDENTIAL_NAME]
@@ -5,7 +5,10 @@ from __future__ import annotations
import dataclasses
from typing import Any
from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER
from homeassistant.components.backup import (
DATA_MANAGER as BACKUP_DATA_MANAGER,
BackupManager,
)
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
@@ -28,7 +31,7 @@ async def async_get_config_entry_diagnostics(
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
backup_manager = hass.data[BACKUP_DATA_MANAGER]
backup_manager: BackupManager = hass.data[BACKUP_DATA_MANAGER]
backups = await async_list_backups_from_s3(
coordinator.client,
bucket=entry.data[CONF_BUCKET],
+4 -10
View File
@@ -29,17 +29,11 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS = {
}
CONDITIONS: dict[str, type[Condition]] = {
"is_low": make_entity_state_condition(
BATTERY_DOMAIN_SPECS, STATE_ON, support_duration=True
),
"is_not_low": make_entity_state_condition(
BATTERY_DOMAIN_SPECS, STATE_OFF, support_duration=True
),
"is_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON, support_duration=True
),
"is_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_ON),
"is_not_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_OFF),
"is_charging": make_entity_state_condition(BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON),
"is_not_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, support_duration=True
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
),
"is_level": make_entity_numerical_condition(
BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE
@@ -13,11 +13,6 @@
options:
- all
- any
for: &condition_for
required: true
default: 00:00:00
selector:
duration:
.battery_threshold_entity: &battery_threshold_entity
- domain: input_number
@@ -44,7 +39,6 @@ is_charging:
device_class: battery_charging
fields:
behavior: *condition_behavior
for: *condition_for
is_not_charging:
target:
@@ -53,7 +47,6 @@ is_not_charging:
device_class: battery_charging
fields:
behavior: *condition_behavior
for: *condition_for
is_level:
target:
@@ -1,7 +1,6 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
@@ -13,9 +12,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
}
},
"name": "Battery is charging"
@@ -37,9 +33,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
}
},
"name": "Battery is low"
@@ -49,9 +42,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
}
},
"name": "Battery is not charging"
@@ -61,9 +51,6 @@
"fields": {
"behavior": {
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::battery::common::condition_for_name%]"
}
},
"name": "Battery is not low"
@@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["blebox_uniapi"],
"requirements": ["blebox-uniapi==2.5.1"],
"requirements": ["blebox-uniapi==2.5.0"],
"zeroconf": ["_bbxsrv._tcp.local."]
}
@@ -6,6 +6,7 @@ DOMAIN = "broadlink"
DOMAINS_AND_TYPES = {
Platform.CLIMATE: {"HYS"},
Platform.INFRARED: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"},
Platform.LIGHT: {"LB1", "LB2"},
Platform.REMOTE: {"RM4MINI", "RM4PRO", "RMMINI", "RMMINIB", "RMPRO"},
Platform.SELECT: {"HYS"},
@@ -44,3 +45,6 @@ DEVICE_TYPES = set.union(*DOMAINS_AND_TYPES.values())
DEFAULT_PORT = 80
DEFAULT_TIMEOUT = 5
# Broadlink IR packet format - repeat count byte offset
IR_PACKET_REPEAT_INDEX = 1
@@ -0,0 +1,184 @@
"""Infrared platform for Broadlink remotes."""
from __future__ import annotations
from typing import TYPE_CHECKING
from broadlink.exceptions import BroadlinkException
from broadlink.remote import pulses_to_data as _bl_pulses_to_data
import infrared_protocols
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, IR_PACKET_REPEAT_INDEX
from .entity import BroadlinkEntity
if TYPE_CHECKING:
from .device import BroadlinkDevice
PARALLEL_UPDATES = 1
class BroadlinkIRCommand(InfraredCommand):
"""Raw IR command with optional Broadlink hardware repeat count.
This class lets you send raw timing data through a Broadlink infrared
entity. The repeat_count maps directly to the Broadlink packet repeat
byte: the device will re-transmit the entire IR burst that many
additional times after the first transmission.
Use this when you have existing Broadlink-encoded IR data (e.g. from
IR code databases like SmartIR) and want to use it with the new
infrared platform.
Protocol-aware commands (infrared_protocols.NECCommand, LgTVCommand,
etc.) manage repeats *inside* get_raw_timings() and should use the
default repeat=0. Only BroadlinkIRCommand should set hardware repeat.
Example: Migrating IR code database base64 codes to the infrared platform:
import base64
from broadlink.remote import data_to_pulses
from homeassistant.components.broadlink.infrared import BroadlinkIRCommand
from homeassistant.components.broadlink.const import IR_PACKET_REPEAT_INDEX
# Decode base64 IR code (e.g. from IR code database)
packet_data = base64.b64decode(b64_code)
repeat_count = packet_data[IR_PACKET_REPEAT_INDEX]
# Parse Broadlink packet to microsecond timings
pulses = data_to_pulses(packet_data)
timings = list(zip(pulses[::2], pulses[1::2]))
if len(pulses) % 2:
timings.append((pulses[-1], 0))
# Create command
cmd = BroadlinkIRCommand(timings, repeat_count=repeat_count)
await infrared.async_send_command(hass, entity_id, cmd)
"""
# Standard IR carrier frequency. Broadlink hardware handles the carrier
# internally, so this value is informational only.
MODULATION = 38000
def __init__(
self,
timings: list[tuple[int, int]],
repeat_count: int = 0,
) -> None:
"""Initialize with timing pairs and optional repeat count.
Args:
timings: List of (mark_us, space_us) pairs in microseconds.
repeat_count: Broadlink hardware repeat count (0 = send once).
Must be 0255 (the hardware repeat byte is a single unsigned byte).
Raises:
ValueError: If repeat_count is outside 0255 range.
"""
if not 0 <= repeat_count <= 255:
raise ValueError(f"repeat_count must be 0255, got {repeat_count}")
super().__init__(modulation=self.MODULATION, repeat_count=repeat_count)
self._timings = [
infrared_protocols.Timing(high_us=high, low_us=low) for high, low in timings
]
def get_raw_timings(self) -> list[infrared_protocols.Timing]:
"""Return timing pairs for transmission."""
return self._timings
def timings_to_broadlink_packet(
timings: list[tuple[int, int]],
repeat: int = 0,
) -> bytes:
"""Convert raw timing pairs (high_us, low_us) to a Broadlink IR packet.
Args:
timings: List of (mark_us, space_us) pairs in microseconds.
repeat: Number of extra repeats (0 = send once).
Returns:
Binary packet ready for Broadlink send_data().
"""
if not 0 <= repeat <= 255:
raise ValueError(f"repeat must be 0255, got {repeat}")
# Flatten (mark, space) pairs into a pulse list, omitting any zero-length spaces
pulses: list[int] = []
for high_us, low_us in timings:
pulses.append(high_us)
if low_us:
pulses.append(low_us)
# Use broadlink library's encoder (tick=32.84 µs)
packet = bytearray(_bl_pulses_to_data(pulses))
packet[IR_PACKET_REPEAT_INDEX] = repeat
return bytes(packet)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Broadlink infrared entity."""
device = hass.data[DOMAIN].devices[config_entry.entry_id]
async_add_entities([BroadlinkInfraredEntity(device)])
class BroadlinkInfraredEntity(BroadlinkEntity, InfraredEntity):
"""Broadlink infrared transmitter entity."""
_attr_has_entity_name = True
_attr_translation_key = "infrared"
def __init__(self, device: BroadlinkDevice) -> None:
"""Initialize the entity."""
super().__init__(device)
self._attr_unique_id = f"{device.unique_id}-infrared"
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command via the Broadlink device.
Handles two types of repeat behavior:
1. Protocol-aware commands (NECCommand, etc.): These encode repeats
(like NEC repeat codes) inside their get_raw_timings() data. The
Broadlink packet is sent with repeat=0.
2. BroadlinkIRCommand: Carries Broadlink hardware repeat count,
which tells the device to re-transmit the entire burst N times.
This is used for protocols/commands that need multiple full frame
transmissions (e.g. legacy SmartIR data).
Using isinstance check ensures protocol-level repeats (already in
timing data) don't get conflated with hardware repeats.
"""
timings = [
(timing.high_us, timing.low_us) for timing in command.get_raw_timings()
]
# Only BroadlinkIRCommand uses Broadlink hardware repeat. Protocol-aware
# commands (NECCommand, etc.) encode repeats inside get_raw_timings()
# and must use hardware repeat=0 to avoid double-repeating.
if isinstance(command, BroadlinkIRCommand):
repeat = command.repeat_count
else:
repeat = 0
packet = timings_to_broadlink_packet(timings, repeat=repeat)
try:
await self._device.async_request(self._device.api.send_data, packet)
except (BroadlinkException, OSError) as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="send_command_failed",
translation_placeholders={"error": str(err)},
) from err
@@ -3,6 +3,7 @@
"name": "Broadlink",
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am", "@eifinger"],
"config_flow": true,
"dependencies": ["infrared"],
"dhcp": [
{
"registered_devices": true
@@ -49,6 +49,11 @@
}
},
"entity": {
"infrared": {
"infrared": {
"name": "IR transmitter"
}
},
"select": {
"day_of_week": {
"name": "Day of week",
@@ -77,5 +82,10 @@
"name": "Total consumption"
}
}
},
"exceptions": {
"send_command_failed": {
"message": "Failed to send IR command: {error}"
}
}
}
@@ -7,9 +7,7 @@ from homeassistant.helpers.condition import Condition, make_entity_state_conditi
from .const import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_event_active": make_entity_state_condition(
DOMAIN, STATE_ON, support_duration=True
),
"is_event_active": make_entity_state_condition(DOMAIN, STATE_ON),
}
@@ -12,8 +12,3 @@ is_event_active:
options:
- all
- any
for:
required: true
default: 00:00:00
selector:
duration:
@@ -1,7 +1,6 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least"
"condition_behavior_name": "Condition passes if"
},
"conditions": {
"is_event_active": {
@@ -9,9 +8,6 @@
"fields": {
"behavior": {
"name": "[%key:component::calendar::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::calendar::common::condition_for_name%]"
}
},
"name": "Calendar event is active"
@@ -67,7 +67,7 @@ class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
CONDITIONS: dict[str, type[Condition]] = {
"is_hvac_mode": ClimateHVACModeCondition,
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF, support_duration=True),
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
"is_on": make_entity_state_condition(
DOMAIN,
{
@@ -39,16 +39,7 @@
- domain: number
device_class: temperature
is_off:
target: *condition_climate_target
fields:
behavior: *condition_behavior
for:
required: true
default: 00:00:00
selector:
duration:
is_off: *condition_common
is_on: *condition_common
is_cooling: *condition_common
is_drying: *condition_common
@@ -1,7 +1,6 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
@@ -53,9 +52,6 @@
"fields": {
"behavior": {
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::climate::common::condition_for_name%]"
}
},
"name": "Climate-control device is off"
@@ -15,7 +15,7 @@ from homeassistant.helpers.selector import (
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
SerialPortSelector,
SerialSelector,
)
from .const import DOMAIN, LOGGER
@@ -110,7 +110,7 @@ class DenonRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
translation_key="model",
)
),
vol.Required(CONF_DEVICE): SerialPortSelector(),
vol.Required(CONF_DEVICE): SerialSelector(),
}
),
user_input or {},
@@ -1,15 +0,0 @@
"""Integration for doorbell triggers."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "doorbell"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True
@@ -1,7 +0,0 @@
{
"triggers": {
"rang": {
"trigger": "mdi:doorbell"
}
}
}
@@ -1,8 +0,0 @@
{
"domain": "doorbell",
"name": "Doorbell",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/doorbell",
"integration_type": "system",
"quality_scale": "internal"
}
@@ -1,9 +0,0 @@
{
"title": "Doorbell",
"triggers": {
"rang": {
"description": "Triggers after one or more doorbells rang.",
"name": "Doorbell rang"
}
}
}
@@ -1,50 +0,0 @@
"""Provides triggers for doorbells."""
from homeassistant.components.event import (
ATTR_EVENT_TYPE,
DOMAIN as EVENT_DOMAIN,
DoorbellEventType,
EventDeviceClass,
)
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
)
class DoorbellRangTrigger(EntityTriggerBase):
"""Trigger for doorbell event entity when a ring event is received."""
_domain_specs = {EVENT_DOMAIN: DomainSpec(device_class=EventDeviceClass.DOORBELL)}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_state(self, state: State) -> bool:
"""Check if the entity is available and the event type is ring."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) == DoorbellEventType.RING
)
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
TRIGGERS: dict[str, type[Trigger]] = {
"rang": DoorbellRangTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for doorbells."""
return TRIGGERS
@@ -1,5 +0,0 @@
rang:
target:
entity:
domain: event
device_class: doorbell
@@ -13,7 +13,6 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import DOMAIN
@@ -36,27 +35,6 @@ class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
_host: str
_box_name: str
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery."""
await self.async_set_unique_id(format_mac(discovery_info.macaddress))
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.ip})
try:
box_name, _ = await self._validate_input(discovery_info.ip)
except DucoConnectionError:
return self.async_abort(reason="cannot_connect")
except DucoError:
_LOGGER.exception("Unexpected error discovering Duco box via DHCP")
return self.async_abort(reason="unknown")
self._host = discovery_info.ip
self._box_name = box_name
self.context["title_placeholders"] = {"name": box_name}
return await self.async_step_discovery_confirm()
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
-1
View File
@@ -64,7 +64,6 @@ async def async_setup_entry(
"""Set up Duco fan entities."""
coordinator = entry.runtime_data
# BOX is always node 1 and is never dynamically added or removed, so no listener needed.
async_add_entities(
DucoVentilationFanEntity(coordinator, node)
for node in coordinator.data.nodes.values()
+2 -7
View File
@@ -3,17 +3,12 @@
"name": "Duco",
"codeowners": ["@ronaldvdmeer"],
"config_flow": true,
"dhcp": [
{
"hostname": "duco_[0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f]"
}
],
"documentation": "https://www.home-assistant.io/integrations/duco",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["duco"],
"quality_scale": "platinum",
"requirements": ["python-duco-client==0.3.4"],
"quality_scale": "silver",
"requirements": ["python-duco-client==0.3.2"],
"zeroconf": [
{
"name": "duco [[][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][0-9a-f][]].*",
@@ -55,7 +55,11 @@ rules:
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
dynamic-devices:
status: todo
comment: >-
Users can pair new modules (CO2 sensors, humidity sensors, zone valves)
to their Duco box. Dynamic device support to be added in a follow-up PR.
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -70,7 +74,11 @@ rules:
handled by the coordinator (unavailable entities) and resolve automatically.
There are no credentials to expire and no versioned API to become
incompatible with.
stale-devices: done
stale-devices:
status: todo
comment: >-
To be implemented together with dynamic device support in a follow-up PR.
# Platinum
async-dependency: done
inject-websession: done
+11 -43
View File
@@ -19,11 +19,9 @@ from homeassistant.const import (
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import DucoConfigEntry, DucoCoordinator
from .entity import DucoEntity
@@ -113,52 +111,22 @@ async def async_setup_entry(
"""Set up Duco sensor entities."""
coordinator = entry.runtime_data
# Track the node IDs for which entities have already been created, so we
# can detect both newly added and stale (deregistered) nodes on every
# coordinator update.
known_nodes: set[int] = set()
@callback
def _async_add_new_entities() -> None:
# Remove devices whose nodes have disappeared from the API.
# The firmware removes deregistered RF/wired nodes automatically.
# BSRH box sensors that are physically unplugged from the PCB are
# not deregistered by the firmware and will never appear here as stale.
stale_node_ids = known_nodes - coordinator.data.nodes.keys()
if stale_node_ids:
device_reg = dr.async_get(hass)
mac = entry.unique_id
for node_id in stale_node_ids:
device = device_reg.async_get_device(
identifiers={(DOMAIN, f"{mac}_{node_id}")}
)
if device:
device_reg.async_update_device(
device.id,
remove_config_entry_id=entry.entry_id,
)
known_nodes.difference_update(stale_node_ids)
new_entities: list[SensorEntity] = []
for node in coordinator.data.nodes.values():
if node.node_id in known_nodes:
continue
known_nodes.add(node.node_id)
new_entities.extend(
async_add_entities(
[
*[
DucoSensorEntity(coordinator, node, description)
for node in coordinator.data.nodes.values()
for description in SENSOR_DESCRIPTIONS
if node.general.node_type in description.node_types
)
new_entities.extend(
],
*[
DucoBoxSensorEntity(coordinator, node, description)
for node in coordinator.data.nodes.values()
for description in BOX_SENSOR_DESCRIPTIONS
if node.general.node_type == NodeType.BOX
)
if new_entities:
async_add_entities(new_entities)
entry.async_on_unload(coordinator.async_add_listener(_async_add_new_entities))
_async_add_new_entities()
],
]
)
class DucoSensorEntity(DucoEntity, SensorEntity):
+5 -1
View File
@@ -35,7 +35,11 @@ class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEn
@convert_api_error_ha_error
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command."""
timings = command.get_raw_timings()
timings = [
interval
for timing in command.get_raw_timings()
for interval in (timing.high_us, -timing.low_us)
]
_LOGGER.debug("Sending command: %s", timings)
self._client.infrared_rf_transmit_raw_timings(
@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==44.18.0",
"aioesphomeapi==44.13.3",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.7.3"
],
@@ -11,7 +11,6 @@ from aioesphomeapi import (
WaterHeaterInfo,
WaterHeaterMode,
WaterHeaterState,
WaterHeaterStateFlag,
)
from homeassistant.components.water_heater import (
@@ -73,8 +72,6 @@ class EsphomeWaterHeater(
self._attr_operation_list = None
if static_info.supported_features & WaterHeaterFeature.SUPPORTS_ON_OFF:
features |= WaterHeaterEntityFeature.ON_OFF
if static_info.supported_features & WaterHeaterFeature.SUPPORTS_AWAY_MODE:
features |= WaterHeaterEntityFeature.AWAY_MODE
self._attr_supported_features = features
@property
@@ -95,12 +92,6 @@ class EsphomeWaterHeater(
"""Return current operation mode."""
return _WATER_HEATER_MODES.from_esphome(self._state.mode)
@property
@esphome_state_property
def is_away_mode_on(self) -> bool | None:
"""Return true if away mode is on."""
return bool(self._state.state & WaterHeaterStateFlag.AWAY)
@convert_api_error_ha_error
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@@ -137,24 +128,6 @@ class EsphomeWaterHeater(
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
async def async_turn_away_mode_on(self) -> None:
"""Turn away mode on."""
self._client.water_heater_command(
key=self._key,
away=True,
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
async def async_turn_away_mode_off(self) -> None:
"""Turn away mode off."""
self._client.water_heater_command(
key=self._key,
away=False,
device_id=self._static_info.device_id,
)
async_setup_entry = partial(
platform_async_setup_entry,
+2 -2
View File
@@ -7,8 +7,8 @@ from homeassistant.helpers.condition import Condition, make_entity_state_conditi
from . import DOMAIN
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF, support_duration=True),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON, support_duration=True),
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
}
@@ -12,11 +12,6 @@
options:
- all
- any
for:
required: true
default: 00:00:00
selector:
duration:
is_off: *condition_common
is_on: *condition_common
@@ -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::fan::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::fan::common::condition_for_name%]"
}
},
"name": "Fan is off"
@@ -23,9 +19,6 @@
"fields": {
"behavior": {
"name": "[%key:component::fan::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::fan::common::condition_for_name%]"
}
},
"name": "Fan is on"
@@ -1,9 +1,4 @@
{
"common": {
"api_key": "Access token",
"api_key_description": "The access token for authenticating with Firefly III",
"verify_ssl_description": "Verify the SSL certificate of the Firefly III instance"
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
@@ -19,39 +14,39 @@
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:component::firefly_iii::common::api_key%]"
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::firefly_iii::common::api_key_description%]"
"api_key": "The new API access token for authenticating with Firefly III"
},
"description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Remote access and tokens**. Create a new **personal access token** and copy it (it will only display once)."
"description": "The access token for your Firefly III instance is invalid and needs to be updated. Go to **Options > Remote access and tokens**. Create a new personal access token and copy it (it will only display once)."
},
"reconfigure": {
"data": {
"api_key": "[%key:component::firefly_iii::common::api_key%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"api_key": "[%key:component::firefly_iii::common::api_key_description%]",
"api_key": "[%key:component::firefly_iii::config::step::user::data_description::api_key%]",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:component::firefly_iii::common::verify_ssl_description%]"
"verify_ssl": "[%key:component::firefly_iii::config::step::user::data_description::verify_ssl%]"
},
"description": "Use the following form to reconfigure your Firefly III instance.",
"title": "Reconfigure Firefly III Integration"
},
"user": {
"data": {
"api_key": "[%key:component::firefly_iii::common::api_key%]",
"api_key": "[%key:common::config_flow::data::api_key%]",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"api_key": "[%key:component::firefly_iii::common::api_key_description%]",
"api_key": "The API key for authenticating with Firefly III",
"url": "[%key:common::config_flow::data::url%]",
"verify_ssl": "[%key:component::firefly_iii::common::verify_ssl_description%]"
"verify_ssl": "Verify the SSL certificate of the Firefly III instance"
},
"description": "You can create an access token in the Firefly III UI. Go to **Options > Remote access and tokens**. Create a new **personal access token** and copy it (it will only display once)."
"description": "You can create an API key in the Firefly III UI. Go to **Options > Remote access and tokens**. Create a new personal access token and copy it (it will only display once)."
}
}
},
@@ -2,14 +2,11 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable, Coroutine
from functools import wraps
import logging
from typing import Any, Concatenate
from typing import Any
from afsapi import (
AFSAPI,
FSApiError,
FSConnectionError,
FSNotImplementedError,
PlayCaps,
@@ -27,7 +24,6 @@ from homeassistant.components.media_player import (
RepeatMode,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
@@ -39,37 +35,6 @@ from .const import DOMAIN, MEDIA_CONTENT_ID_PRESET
_LOGGER = logging.getLogger(__name__)
def fs_command_exception_wrap[
_AFSAPIDeviceT: AFSAPIDevice,
**_P,
_R,
](
func: Callable[Concatenate[_AFSAPIDeviceT, _P], Awaitable[_R]],
) -> Callable[Concatenate[_AFSAPIDeviceT, _P], Coroutine[Any, Any, _R]]:
"""Wrap command methods and map API exceptions to HA errors."""
@wraps(func)
async def _wrap(self: _AFSAPIDeviceT, *args: _P.args, **kwargs: _P.kwargs) -> _R:
try:
return await func(self, *args, **kwargs)
except FSConnectionError as err:
command = func.__name__.removeprefix("async_")
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="connection_error",
translation_placeholders={"command": command},
) from err
except FSApiError as err:
command = func.__name__.removeprefix("async_")
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="api_error",
translation_placeholders={"command": command, "message": str(err)},
) from err
return _wrap
async def async_setup_entry(
hass: HomeAssistant,
config_entry: FrontierSiliconConfigEntry,
@@ -307,74 +272,57 @@ class AFSAPIDevice(MediaPlayerEntity):
# Management actions
# power control
@fs_command_exception_wrap
async def async_turn_on(self) -> None:
"""Turn on the device."""
await self.fs_device.set_power(True)
@fs_command_exception_wrap
async def async_turn_off(self) -> None:
"""Turn off the device."""
await self.fs_device.set_power(False)
@fs_command_exception_wrap
async def async_media_play(self) -> None:
"""Send play command."""
if (await self.fs_device.get_play_state()) == PlayState.STOPPED:
# The 'play' command only seems to work when the current stream is paused.
# We need to send a 'stop' command instead to resume a stopped stream.
await self.fs_device.stop()
else:
await self.fs_device.play()
await self.fs_device.play()
@fs_command_exception_wrap
async def async_media_pause(self) -> None:
"""Send pause command."""
await self.fs_device.pause()
@fs_command_exception_wrap
async def async_media_stop(self) -> None:
"""Send stop command."""
await self.fs_device.stop()
@fs_command_exception_wrap
async def async_media_previous_track(self) -> None:
"""Send previous track command (results in rewind)."""
await self.fs_device.rewind()
@fs_command_exception_wrap
async def async_media_next_track(self) -> None:
"""Send next track command (results in fast-forward)."""
await self.fs_device.forward()
@fs_command_exception_wrap
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command."""
await self.fs_device.set_mute(mute)
# volume
@fs_command_exception_wrap
async def async_volume_up(self) -> None:
"""Send volume up command."""
volume = await self.fs_device.get_volume()
volume = int(volume or 0) + 1
await self.fs_device.set_volume(min(volume, self._max_volume or 1))
@fs_command_exception_wrap
async def async_volume_down(self) -> None:
"""Send volume down command."""
volume = await self.fs_device.get_volume()
volume = int(volume or 0) - 1
await self.fs_device.set_volume(max(volume, 0))
@fs_command_exception_wrap
async def async_set_volume_level(self, volume: float) -> None:
"""Set volume command."""
if self._max_volume: # Can't do anything sensible if not set
volume = int(volume * self._max_volume)
await self.fs_device.set_volume(volume)
@fs_command_exception_wrap
async def async_select_source(self, source: str) -> None:
"""Select input source."""
await self.fs_device.set_power(True)
@@ -384,7 +332,6 @@ class AFSAPIDevice(MediaPlayerEntity):
):
await self.fs_device.set_mode(mode)
@fs_command_exception_wrap
async def async_select_sound_mode(self, sound_mode: str) -> None:
"""Select EQ Preset."""
if (
@@ -393,7 +340,6 @@ class AFSAPIDevice(MediaPlayerEntity):
):
await self.fs_device.set_eq_preset(mode)
@fs_command_exception_wrap
async def async_set_repeat(self, repeat: RepeatMode) -> None:
"""Set repeat mode."""
await self.fs_device.play_repeat(
@@ -404,12 +350,10 @@ class AFSAPIDevice(MediaPlayerEntity):
}.get(repeat, PlayRepeatMode.OFF)
)
@fs_command_exception_wrap
async def async_set_shuffle(self, shuffle: bool) -> None:
"""Set shuffle mode."""
await self.fs_device.set_play_shuffle(shuffle)
@fs_command_exception_wrap
async def async_media_seek(self, position: float) -> None:
"""Seek to a position in seconds."""
await self.fs_device.set_play_position(int(position * 1000))
@@ -425,7 +369,6 @@ class AFSAPIDevice(MediaPlayerEntity):
return await browse_node(self.fs_device, media_content_type, media_content_id)
@fs_command_exception_wrap
async def async_play_media(
self, media_type: MediaType | str, media_id: str, **kwargs: Any
) -> None:
@@ -33,13 +33,5 @@
}
}
}
},
"exceptions": {
"api_error": {
"message": "Failed to execute {command}: {message}"
},
"connection_error": {
"message": "Failed to execute {command}: could not connect to device"
}
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
PLATFORMS = [Platform.CLIMATE]
async def async_setup_entry(hass: HomeAssistant, entry: FumisConfigEntry) -> bool:
@@ -2,7 +2,6 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from fumis import (
@@ -22,7 +21,6 @@ from homeassistant.helpers.selector import (
TextSelectorConfig,
TextSelectorType,
)
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import DOMAIN, LOGGER
@@ -30,64 +28,6 @@ from .const import DOMAIN, LOGGER
class FumisFlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a Fumis config flow."""
_discovered_mac: str
async def async_step_dhcp(
self, discovery_info: DhcpServiceInfo
) -> ConfigFlowResult:
"""Handle DHCP discovery of a Fumis WiRCU module."""
mac = discovery_info.macaddress.replace(":", "").replace("-", "").upper()
await self.async_set_unique_id(format_mac(mac))
self._abort_if_unique_id_configured()
self._discovered_mac = mac
return await self.async_step_dhcp_confirm()
async def async_step_dhcp_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle DHCP discovery confirmation."""
errors: dict[str, str] = {}
if user_input is not None:
fumis = Fumis(
mac=self._discovered_mac,
password=user_input[CONF_PIN],
session=async_get_clientsession(self.hass),
)
try:
info = await fumis.update_info()
except FumisAuthenticationError:
errors[CONF_PIN] = "invalid_auth"
except FumisStoveOfflineError:
errors["base"] = "device_offline"
except FumisConnectionError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=info.controller.model_name or "Fumis",
data={
CONF_MAC: self._discovered_mac,
CONF_PIN: user_input[CONF_PIN],
},
)
return self.async_show_form(
step_id="dhcp_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_PIN): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
),
errors=errors,
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -140,51 +80,3 @@ class FumisFlowHandler(ConfigFlow, domain=DOMAIN):
),
errors=errors,
)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication of a Fumis stove."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle re-authentication confirmation."""
errors: dict[str, str] = {}
if user_input is not None:
reauth_entry = self._get_reauth_entry()
fumis = Fumis(
mac=reauth_entry.data[CONF_MAC],
password=user_input[CONF_PIN],
session=async_get_clientsession(self.hass),
)
try:
await fumis.update_info()
except FumisAuthenticationError:
errors[CONF_PIN] = "invalid_auth"
except FumisStoveOfflineError:
errors["base"] = "device_offline"
except FumisConnectionError:
errors["base"] = "cannot_connect"
except Exception: # noqa: BLE001
LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={CONF_PIN: user_input[CONF_PIN]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_PIN): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
}
),
errors=errors,
)
@@ -14,7 +14,6 @@ from fumis import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MAC, CONF_PIN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -48,7 +47,7 @@ class FumisDataUpdateCoordinator(DataUpdateCoordinator[FumisInfo]):
try:
return await self.client.update_info()
except FumisAuthenticationError as err:
raise ConfigEntryAuthFailed(
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from err
-48
View File
@@ -1,48 +0,0 @@
{
"entity": {
"sensor": {
"combustion_chamber_temperature": {
"default": "mdi:thermometer-high"
},
"detailed_stove_status": {
"default": "mdi:fireplace"
},
"fan_1_speed": {
"default": "mdi:fan"
},
"fan_2_speed": {
"default": "mdi:fan"
},
"fuel_quantity": {
"default": "mdi:gauge"
},
"fuel_used": {
"default": "mdi:counter"
},
"igniter_starts": {
"default": "mdi:counter"
},
"misfires": {
"default": "mdi:alert-outline"
},
"overheatings": {
"default": "mdi:thermometer-alert"
},
"power_output": {
"default": "mdi:fire"
},
"pressure": {
"default": "mdi:gauge"
},
"stove_status": {
"default": "mdi:fireplace"
},
"time_to_service": {
"default": "mdi:wrench-clock"
},
"wifi_signal_strength": {
"default": "mdi:wifi"
}
}
}
}
@@ -3,11 +3,6 @@
"name": "Fumis",
"codeowners": ["@frenck"],
"config_flow": true,
"dhcp": [
{
"macaddress": "0016D0*"
}
],
"documentation": "https://www.home-assistant.io/integrations/fumis",
"integration_type": "device",
"iot_class": "cloud_polling",
@@ -36,16 +36,18 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery: done
discovery:
status: todo
comment: DHCP discovery can be added.
discovery-update-info:
status: exempt
comment: Cloud-only API, no local device information to update.
status: todo
comment: DHCP discovery based update can be added.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
-276
View File
@@ -1,276 +0,0 @@
"""Support for Fumis sensor entities."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from fumis import FumisInfo, StoveState, StoveStatus
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
REVOLUTIONS_PER_MINUTE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
EntityCategory,
UnitOfPower,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from homeassistant.util.variance import ignore_variance
from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator
from .entity import FumisEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class FumisSensorEntityDescription(SensorEntityDescription):
"""Describes a Fumis sensor entity."""
has_fn: Callable[[FumisInfo], bool] = lambda _: True
value_fn: Callable[[FumisInfo], datetime | float | int | str | None]
SENSORS: tuple[FumisSensorEntityDescription, ...] = (
FumisSensorEntityDescription(
key="combustion_chamber_temperature",
translation_key="combustion_chamber_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
has_fn=lambda data: data.controller.combustion_chamber_temperature is not None,
value_fn=lambda data: data.controller.combustion_chamber_temperature,
),
FumisSensorEntityDescription(
key="detailed_stove_status",
translation_key="detailed_stove_status",
device_class=SensorDeviceClass.ENUM,
entity_category=EntityCategory.DIAGNOSTIC,
options=[
status.name.lower()
for status in StoveStatus
if status != StoveStatus.UNKNOWN
],
value_fn=lambda data: (
None
if data.controller.stove_status is StoveStatus.UNKNOWN
else data.controller.stove_status.name.lower()
),
),
FumisSensorEntityDescription(
key="fan_1_speed",
translation_key="fan_1_speed",
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
has_fn=lambda data: data.controller.fan1_speed is not None,
value_fn=lambda data: data.controller.fan1_speed,
),
FumisSensorEntityDescription(
key="fan_2_speed",
translation_key="fan_2_speed",
native_unit_of_measurement=REVOLUTIONS_PER_MINUTE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
has_fn=lambda data: data.controller.fan2_speed is not None,
value_fn=lambda data: data.controller.fan2_speed,
),
FumisSensorEntityDescription(
key="fuel_quantity",
translation_key="fuel_quantity",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
has_fn=lambda data: (
len(data.controller.fuels) > 0
and data.controller.fuels[0].quantity_percentage is not None
),
value_fn=lambda data: (
data.controller.fuels[0].quantity_percentage
if data.controller.fuels
else None
),
),
FumisSensorEntityDescription(
key="fuel_used",
translation_key="fuel_used",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.controller.statistic.fuel_quantity_used,
),
FumisSensorEntityDescription(
key="heating_time",
translation_key="heating_time",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_unit_of_measurement=UnitOfTime.HOURS,
value_fn=lambda data: data.controller.statistic.heating_time.total_seconds(),
),
FumisSensorEntityDescription(
key="igniter_starts",
translation_key="igniter_starts",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.controller.statistic.igniter_starts,
),
FumisSensorEntityDescription(
key="misfires",
translation_key="misfires",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.controller.statistic.misfires,
),
FumisSensorEntityDescription(
key="module_temperature",
translation_key="module_temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
has_fn=lambda data: data.unit.temperature is not None,
value_fn=lambda data: data.unit.temperature,
),
FumisSensorEntityDescription(
key="overheatings",
translation_key="overheatings",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.controller.statistic.overheatings,
),
FumisSensorEntityDescription(
key="power_output",
translation_key="power_output",
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.KILO_WATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=1,
value_fn=lambda data: data.controller.power.kw,
),
FumisSensorEntityDescription(
key="pressure",
translation_key="pressure",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
has_fn=lambda data: data.controller.pressure is not None,
value_fn=lambda data: data.controller.pressure,
),
FumisSensorEntityDescription(
key="stove_status",
translation_key="stove_status",
device_class=SensorDeviceClass.ENUM,
options=[state.value for state in StoveState if state != StoveState.UNKNOWN],
value_fn=lambda data: (
None
if data.controller.state is StoveState.UNKNOWN
else data.controller.state.value
),
),
FumisSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
has_fn=lambda data: data.controller.main_temperature is not None,
value_fn=lambda data: (
data.controller.main_temperature.actual
if data.controller.main_temperature
else None
),
),
FumisSensorEntityDescription(
key="time_to_service",
translation_key="time_to_service",
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.HOURS,
entity_category=EntityCategory.DIAGNOSTIC,
has_fn=lambda data: (
data.controller.time_to_service is not None
and data.controller.time_to_service >= 0
),
value_fn=lambda data: data.controller.time_to_service,
),
FumisSensorEntityDescription(
key="uptime",
translation_key="uptime",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=ignore_variance(
lambda data: (
utcnow().replace(microsecond=0) - data.controller.statistic.uptime
),
timedelta(minutes=5),
),
),
FumisSensorEntityDescription(
key="wifi_rssi",
translation_key="wifi_rssi",
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
value_fn=lambda data: data.unit.rssi,
),
FumisSensorEntityDescription(
key="wifi_signal_strength",
translation_key="wifi_signal_strength",
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.unit.signal_strength,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FumisConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Fumis sensor entities based on a config entry."""
coordinator = entry.runtime_data
async_add_entities(
FumisSensorEntity(coordinator=coordinator, description=description)
for description in SENSORS
if description.has_fn(coordinator.data)
)
class FumisSensorEntity(FumisEntity, SensorEntity):
"""Defines a Fumis sensor entity."""
entity_description: FumisSensorEntityDescription
def __init__(
self,
coordinator: FumisDataUpdateCoordinator,
description: FumisSensorEntityDescription,
) -> None:
"""Initialize the Fumis sensor entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
@property
def native_value(self) -> datetime | float | int | str | None:
"""Return the sensor value."""
return self.entity_description.value_fn(self.coordinator.data)
+1 -102
View File
@@ -1,8 +1,7 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -11,24 +10,6 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"dhcp_confirm": {
"data": {
"pin": "[%key:component::fumis::config::step::user::data::pin%]"
},
"data_description": {
"pin": "[%key:component::fumis::config::step::user::data_description::pin%]"
},
"description": "A Fumis WiRCU Wi-Fi module was discovered on your network. Enter the PIN code from the label on the module to set up your pellet stove."
},
"reauth_confirm": {
"data": {
"pin": "[%key:component::fumis::config::step::user::data::pin%]"
},
"data_description": {
"pin": "[%key:component::fumis::config::step::user::data_description::pin%]"
},
"description": "The PIN code for your stove has changed. Please enter the new PIN code to re-authenticate."
},
"user": {
"data": {
"mac": "MAC address",
@@ -42,88 +23,6 @@
}
}
},
"entity": {
"sensor": {
"combustion_chamber_temperature": {
"name": "Combustion chamber"
},
"detailed_stove_status": {
"name": "Detailed stove status",
"state": {
"cold_start": "Cold start",
"cold_start_off": "Off (cold start)",
"combustion": "Combustion",
"cooling": "Cooling",
"eco": "Eco",
"hybrid_init": "Hybrid init",
"hybrid_start": "Hybrid start",
"ignition": "Ignition",
"off": "[%key:common::state::off%]",
"pre_combustion": "Pre-combustion",
"pre_heating": "Pre-heating",
"wood_burning_off": "Off (wood burning)",
"wood_combustion": "Wood combustion",
"wood_start": "Wood start"
}
},
"fan_1_speed": {
"name": "Fan 1 speed"
},
"fan_2_speed": {
"name": "Fan 2 speed"
},
"fuel_quantity": {
"name": "Fuel level"
},
"fuel_used": {
"name": "Fuel consumed"
},
"heating_time": {
"name": "Burning time"
},
"igniter_starts": {
"name": "Igniter starts"
},
"misfires": {
"name": "Misfires"
},
"module_temperature": {
"name": "WiRCU module"
},
"overheatings": {
"name": "Overheatings"
},
"power_output": {
"name": "Power output"
},
"pressure": {
"name": "Combustion chamber pressure"
},
"stove_status": {
"name": "Stove status",
"state": {
"burning": "Burning",
"cooling": "Cooling",
"eco": "Eco",
"heating_up": "Heating up",
"ignition": "Ignition",
"off": "[%key:common::state::off%]"
}
},
"time_to_service": {
"name": "Time to service"
},
"uptime": {
"name": "Uptime"
},
"wifi_rssi": {
"name": "Wi-Fi RSSI"
},
"wifi_signal_strength": {
"name": "Wi-Fi signal strength"
}
}
},
"exceptions": {
"authentication_error": {
"message": "Authentication with the Fumis online service failed. Check your MAC address and PIN code."
@@ -1076,16 +1076,14 @@ class TemperatureControlTrait(_Trait):
float(attrs[water_heater.ATTR_MIN_TEMP]),
unit,
UnitOfTemperature.CELSIUS,
),
1,
)
)
max_temp = round(
TemperatureConverter.convert(
float(attrs[water_heater.ATTR_MAX_TEMP]),
unit,
UnitOfTemperature.CELSIUS,
),
1,
)
)
response["temperatureRange"] = {
"minThresholdCelsius": min_temp,
@@ -1238,16 +1236,14 @@ class TemperatureSettingTrait(_Trait):
float(attrs[climate.ATTR_MIN_TEMP]),
unit,
UnitOfTemperature.CELSIUS,
),
1,
)
)
max_temp = round(
TemperatureConverter.convert(
float(attrs[climate.ATTR_MAX_TEMP]),
unit,
UnitOfTemperature.CELSIUS,
),
1,
)
)
response["thermostatTemperatureRange"] = {
"minThresholdCelsius": min_temp,
@@ -8,7 +8,7 @@
"integration_type": "service",
"iot_class": "cloud_push",
"requirements": [
"google-cloud-texttospeech==2.36.0",
"google-cloud-speech==2.38.0"
"google-cloud-texttospeech==2.25.1",
"google-cloud-speech==2.31.1"
]
}
@@ -5,7 +5,10 @@ from __future__ import annotations
import dataclasses
from typing import Any
from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER
from homeassistant.components.backup import (
DATA_MANAGER as BACKUP_DATA_MANAGER,
BackupManager,
)
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
@@ -23,7 +26,7 @@ async def async_get_config_entry_diagnostics(
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
backup_manager = hass.data[BACKUP_DATA_MANAGER]
backup_manager: BackupManager = hass.data[BACKUP_DATA_MANAGER]
backups = await coordinator.client.async_list_backups()
@@ -49,7 +49,7 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]:
integers if found, otherwise None.
"""
if not mime_type.lower().startswith("audio/l"):
if not mime_type.startswith("audio/L"):
LOGGER.warning("Received unexpected MIME type %s", mime_type)
raise HomeAssistantError(f"Unsupported audio MIME type: {mime_type}")
@@ -65,9 +65,9 @@ def _parse_audio_mime_type(mime_type: str) -> dict[str, int]:
with suppress(ValueError, IndexError):
rate_str = param.split("=", 1)[1]
rate = int(rate_str)
elif param.lower().startswith("audio/l"):
elif param.startswith("audio/L"):
# Keep bits_per_sample as default if conversion fails
with suppress(ValueError, IndexError):
bits_per_sample = int(param.upper().split("L", 1)[1])
bits_per_sample = int(param.split("L", 1)[1])
return {"bits_per_sample": bits_per_sample, "rate": rate}
@@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/google_pubsub",
"iot_class": "cloud_push",
"quality_scale": "legacy",
"requirements": ["google-cloud-pubsub==2.37.0"]
"requirements": ["google-cloud-pubsub==2.29.0"]
}
@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["google", "homeassistant.helpers.location"],
"requirements": ["google-maps-routing==0.10.0"]
"requirements": ["google-maps-routing==0.6.15"]
}
+16 -8
View File
@@ -76,6 +76,7 @@ from .auth import async_setup_auth_view
from .config import HassioConfig
from .const import (
ADDONS_COORDINATOR,
ATTR_REPOSITORIES,
DATA_ADDONS_LIST,
DATA_COMPONENT,
DATA_CONFIG_STORE,
@@ -342,14 +343,21 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
except SupervisorError as err:
_LOGGER.warning("Can't read Supervisor data: %s", err)
else:
hass.data[DATA_INFO] = root_info
hass.data[DATA_HOST_INFO] = host_info
hass.data[DATA_STORE] = store_info
hass.data[DATA_CORE_INFO] = homeassistant_info
hass.data[DATA_SUPERVISOR_INFO] = supervisor_info
hass.data[DATA_OS_INFO] = os_info
hass.data[DATA_NETWORK_INFO] = network_info
hass.data[DATA_ADDONS_LIST] = addons_list
hass.data[DATA_INFO] = root_info.to_dict()
hass.data[DATA_HOST_INFO] = host_info.to_dict()
hass.data[DATA_STORE] = store_info.to_dict()
hass.data[DATA_CORE_INFO] = homeassistant_info.to_dict()
hass.data[DATA_SUPERVISOR_INFO] = supervisor_info.to_dict()
hass.data[DATA_OS_INFO] = os_info.to_dict()
hass.data[DATA_NETWORK_INFO] = network_info.to_dict()
hass.data[DATA_ADDONS_LIST] = [addon.to_dict() for addon in addons_list]
# Deprecated 2026.4.0: Folding repositories and addons.list results into supervisor_info for compatibility
# Can drop this after removal period
hass.data[DATA_SUPERVISOR_INFO]["repositories"] = hass.data[DATA_STORE][
ATTR_REPOSITORIES
]
hass.data[DATA_SUPERVISOR_INFO]["addons"] = hass.data[DATA_ADDONS_LIST]
# Fetch data
update_info_task = hass.async_create_task(update_info_data(), eager_start=True)
+12 -35
View File
@@ -9,25 +9,8 @@ from typing import TYPE_CHECKING
from homeassistant.util.hass_dict import HassKey
if TYPE_CHECKING:
from aiohasupervisor.models import (
HomeAssistantInfo,
HostInfo,
InstalledAddon,
NetworkInfo,
OSInfo,
RootInfo,
StoreInfo,
SupervisorInfo,
)
from .config import HassioConfig
from .coordinator import (
HassioAddOnDataUpdateCoordinator,
HassioMainDataUpdateCoordinator,
HassioStatsDataUpdateCoordinator,
)
from .handler import HassIO
from .issues import SupervisorIssues
DOMAIN = "hassio"
@@ -94,31 +77,25 @@ EVENT_JOB = "job"
UPDATE_KEY_SUPERVISOR = "supervisor"
STARTUP_COMPLETE = "complete"
MAIN_COORDINATOR: HassKey[HassioMainDataUpdateCoordinator] = HassKey(
"hassio_main_coordinator"
)
ADDONS_COORDINATOR: HassKey[HassioAddOnDataUpdateCoordinator] = HassKey(
"hassio_addons_coordinator"
)
STATS_COORDINATOR: HassKey[HassioStatsDataUpdateCoordinator] = HassKey(
"hassio_stats_coordinator"
)
MAIN_COORDINATOR = "hassio_main_coordinator"
ADDONS_COORDINATOR = "hassio_addons_coordinator"
STATS_COORDINATOR = "hassio_stats_coordinator"
DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN)
DATA_CONFIG_STORE: HassKey[HassioConfig] = HassKey("hassio_config_store")
DATA_CORE_INFO: HassKey[HomeAssistantInfo] = HassKey("hassio_core_info")
DATA_CORE_INFO = "hassio_core_info"
DATA_CORE_STATS = "hassio_core_stats"
DATA_HOST_INFO: HassKey[HostInfo] = HassKey("hassio_host_info")
DATA_STORE: HassKey[StoreInfo] = HassKey("hassio_store")
DATA_INFO: HassKey[RootInfo] = HassKey("hassio_info")
DATA_OS_INFO: HassKey[OSInfo] = HassKey("hassio_os_info")
DATA_NETWORK_INFO: HassKey[NetworkInfo] = HassKey("hassio_network_info")
DATA_SUPERVISOR_INFO: HassKey[SupervisorInfo] = HassKey("hassio_supervisor_info")
DATA_HOST_INFO = "hassio_host_info"
DATA_STORE = "hassio_store"
DATA_INFO = "hassio_info"
DATA_OS_INFO = "hassio_os_info"
DATA_NETWORK_INFO = "hassio_network_info"
DATA_SUPERVISOR_INFO = "hassio_supervisor_info"
DATA_SUPERVISOR_STATS = "hassio_supervisor_stats"
DATA_ADDONS_INFO = "hassio_addons_info"
DATA_ADDONS_STATS = "hassio_addons_stats"
DATA_ADDONS_LIST: HassKey[list[InstalledAddon]] = HassKey("hassio_addons_list")
DATA_ADDONS_LIST = "hassio_addons_list"
HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5)
HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15)
HASSIO_STATS_UPDATE_INTERVAL = timedelta(seconds=60)
@@ -141,7 +118,7 @@ DATA_KEY_OS = "os"
DATA_KEY_SUPERVISOR = "supervisor"
DATA_KEY_CORE = "core"
DATA_KEY_HOST = "host"
DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssues] = HassKey("supervisor_issues")
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"
DATA_KEY_MOUNTS = "mounts"
PLACEHOLDER_KEY_ADDON = "addon"
+53 -100
View File
@@ -7,22 +7,16 @@ from collections import defaultdict
from collections.abc import Awaitable
from copy import deepcopy
import logging
from typing import TYPE_CHECKING, Any, cast
from typing import TYPE_CHECKING, Any
from aiohasupervisor import SupervisorError, SupervisorNotFoundError
from aiohasupervisor.models import (
AddonState,
CIFSMountResponse,
HomeAssistantInfo,
HostInfo,
InstalledAddon,
NetworkInfo,
NFSMountResponse,
OSInfo,
ResponseData,
RootInfo,
StoreInfo,
SupervisorInfo,
)
from homeassistant.config_entries import ConfigEntry
@@ -31,21 +25,15 @@ from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
ATTR_ADDONS,
ATTR_AUTO_UPDATE,
ATTR_DATA,
ATTR_REPOSITORIES,
ATTR_REPOSITORY,
ATTR_SLUG,
ATTR_STARTUP,
ATTR_UPDATE_KEY,
ATTR_URL,
ATTR_VERSION,
ATTR_WS_EVENT,
CONTAINER_STATS,
CORE_CONTAINER,
DATA_ADDONS_INFO,
@@ -68,15 +56,11 @@ from .const import (
DATA_SUPERVISOR_INFO,
DATA_SUPERVISOR_STATS,
DOMAIN,
EVENT_SUPERVISOR_EVENT,
EVENT_SUPERVISOR_UPDATE,
HASSIO_ADDON_UPDATE_INTERVAL,
HASSIO_MAIN_UPDATE_INTERVAL,
HASSIO_STATS_UPDATE_INTERVAL,
REQUEST_REFRESH_DELAY,
STARTUP_COMPLETE,
SUPERVISOR_CONTAINER,
UPDATE_KEY_SUPERVISOR,
SupervisorEntityModel,
)
from .handler import get_supervisor_client
@@ -94,8 +78,7 @@ def get_info(hass: HomeAssistant) -> dict[str, Any] | None:
Async friendly.
"""
info = hass.data.get(DATA_INFO)
return info.to_dict() if info is not None else None
return hass.data.get(DATA_INFO)
@callback
@@ -104,8 +87,7 @@ def get_host_info(hass: HomeAssistant) -> dict[str, Any] | None:
Async friendly.
"""
info = hass.data.get(DATA_HOST_INFO)
return info.to_dict() if info is not None else None
return hass.data.get(DATA_HOST_INFO)
@callback
@@ -114,8 +96,7 @@ def get_store(hass: HomeAssistant) -> dict[str, Any] | None:
Async friendly.
"""
info = hass.data.get(DATA_STORE)
return info.to_dict() if info is not None else None
return hass.data.get(DATA_STORE)
@callback
@@ -124,17 +105,7 @@ def get_supervisor_info(hass: HomeAssistant) -> dict[str, Any] | None:
Async friendly.
"""
info = hass.data.get(DATA_SUPERVISOR_INFO)
if info is None:
return None
result = info.to_dict()
# Deprecated 2026.4.0: Folding repositories and addons into supervisor_info
# for backwards compatibility. Can be removed after deprecation period.
if (store := hass.data.get(DATA_STORE)) is not None:
result[ATTR_REPOSITORIES] = [repo.to_dict() for repo in store.repositories]
if (addons_list := hass.data.get(DATA_ADDONS_LIST)) is not None:
result[ATTR_ADDONS] = [addon.to_dict() for addon in addons_list]
return result
return hass.data.get(DATA_SUPERVISOR_INFO)
@callback
@@ -143,8 +114,7 @@ def get_network_info(hass: HomeAssistant) -> dict[str, Any] | None:
Async friendly.
"""
info = hass.data.get(DATA_NETWORK_INFO)
return info.to_dict() if info is not None else None
return hass.data.get(DATA_NETWORK_INFO)
@callback
@@ -162,8 +132,7 @@ def get_addons_list(hass: HomeAssistant) -> list[dict[str, Any]] | None:
Async friendly.
"""
addons = hass.data.get(DATA_ADDONS_LIST)
return [addon.to_dict() for addon in addons] if addons is not None else None
return hass.data.get(DATA_ADDONS_LIST)
@callback
@@ -199,8 +168,7 @@ def get_os_info(hass: HomeAssistant) -> dict[str, Any] | None:
Async friendly.
"""
info = hass.data.get(DATA_OS_INFO)
return info.to_dict() if info is not None else None
return hass.data.get(DATA_OS_INFO)
@callback
@@ -209,8 +177,7 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None:
Async friendly.
"""
info = hass.data.get(DATA_CORE_INFO)
return info.to_dict() if info is not None else None
return hass.data.get(DATA_CORE_INFO)
@callback
@@ -392,11 +359,11 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
data[key] = result.to_dict()
# Fetch addon stats
addons_list: list[InstalledAddon] = self.hass.data.get(DATA_ADDONS_LIST) or []
addons_list = get_addons_list(self.hass) or []
started_addons = {
addon.slug
addon[ATTR_SLUG]
for addon in addons_list
if addon.state in {AddonState.STARTED, AddonState.STARTUP}
if addon.get("state") in {AddonState.STARTED, AddonState.STARTUP}
}
addons_stats: dict[str, Any] = data.setdefault(DATA_ADDONS_STATS, {})
@@ -502,24 +469,31 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
# Update hass.data for legacy accessor functions
self.hass.data[DATA_ADDONS_LIST] = installed_addons
data = self.hass.data
addons_list_dicts = [addon.to_dict() for addon in installed_addons]
data[DATA_ADDONS_LIST] = addons_list_dicts
# Update addon info cache in hass.data
addon_info_cache: dict[str, Any] = self.hass.data.setdefault(
DATA_ADDONS_INFO, {}
)
addon_info_cache: dict[str, Any] = data.setdefault(DATA_ADDONS_INFO, {})
for slug in addon_info_cache.keys() - all_addons:
del addon_info_cache[slug]
addon_info_cache.update(addon_info_results)
# Deprecated 2026.4.0: Folding addons.list results into supervisor_info
# for compatibility. Written to hass.data only, not coordinator data.
if DATA_SUPERVISOR_INFO in data:
data[DATA_SUPERVISOR_INFO]["addons"] = addons_list_dicts
# Build clean coordinator data
store = self.hass.data.get(DATA_STORE)
if store:
repositories = {repo.slug: repo.name for repo in store.repositories}
store_data = get_store(self.hass)
if store_data:
repositories = {
repo.slug: repo.name
for repo in StoreInfo.from_dict(store_data).repositories
}
else:
repositories = {}
addons_list_dicts = [addon.to_dict() for addon in installed_addons]
new_data: dict[str, Any] = {}
new_data[DATA_KEY_ADDONS] = {
(slug := addon[ATTR_SLUG]): {
@@ -661,25 +635,9 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
)
self.entry_id = config_entry.entry_id
self.dev_reg = dev_reg
if info := self.hass.data.get(DATA_INFO):
self.is_hass_os = info.hassos is not None
else:
self.is_hass_os = False
self.is_hass_os = (get_info(self.hass) or {}).get("hassos") is not None
self.supervisor_client = get_supervisor_client(hass)
self.jobs = SupervisorJobs(hass)
self._dispatcher_disconnect = async_dispatcher_connect(
hass, EVENT_SUPERVISOR_EVENT, self._supervisor_event
)
@callback
def _supervisor_event(self, event: dict[str, Any]) -> None:
"""Refresh coordinator data when Supervisor restarts after an update."""
if (
event.get(ATTR_WS_EVENT) == EVENT_SUPERVISOR_UPDATE
and event.get(ATTR_UPDATE_KEY) == UPDATE_KEY_SUPERVISOR
and event.get(ATTR_DATA, {}).get(ATTR_STARTUP) == STARTUP_COMPLETE
):
self.config_entry.async_create_task(self.hass, self.async_request_refresh())
async def _async_update_data(self) -> dict[str, Any]:
"""Update data via library."""
@@ -687,9 +645,6 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
client = self.supervisor_client
try:
# Cast is required here because asyncio.gather only has overloads to
# maintain typing for 6 arguments. It falls back to list[<common parent>]
# after that which is what mypy sees here since we have 7 API calls.
(
info,
core_info,
@@ -698,25 +653,14 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
host_info,
store_info,
network_info,
) = cast(
tuple[
RootInfo,
HomeAssistantInfo,
SupervisorInfo,
OSInfo,
HostInfo,
StoreInfo,
NetworkInfo,
],
await asyncio.gather(
client.info(),
client.homeassistant.info(),
client.supervisor.info(),
client.os.info(),
client.host.info(),
client.store.info(),
client.network.info(),
),
) = await asyncio.gather(
client.info(),
client.homeassistant.info(),
client.supervisor.info(),
client.os.info(),
client.host.info(),
client.store.info(),
client.network.info(),
)
mounts_info = await client.mounts.info()
await self.jobs.refresh_data(is_first_update)
@@ -733,13 +677,23 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
new_data[DATA_KEY_OS] = os_info.to_dict()
# Update hass.data for legacy accessor functions
self.hass.data[DATA_INFO] = info
self.hass.data[DATA_CORE_INFO] = core_info
self.hass.data[DATA_OS_INFO] = os_info
self.hass.data[DATA_HOST_INFO] = host_info
self.hass.data[DATA_STORE] = store_info
self.hass.data[DATA_NETWORK_INFO] = network_info
self.hass.data[DATA_SUPERVISOR_INFO] = supervisor_info
data = self.hass.data
data[DATA_INFO] = info.to_dict()
data[DATA_CORE_INFO] = new_data[DATA_KEY_CORE]
data[DATA_OS_INFO] = new_data.get(DATA_KEY_OS, os_info.to_dict())
data[DATA_HOST_INFO] = new_data[DATA_KEY_HOST]
data[DATA_STORE] = store_info.to_dict()
data[DATA_NETWORK_INFO] = network_info.to_dict()
# Separate dict for hass.data supervisor info since we add deprecated
# compat keys that should not be in coordinator data
supervisor_info_dict = supervisor_info.to_dict()
# Deprecated 2026.4.0: Folding repositories and addons into
# supervisor_info for compatibility. Written to hass.data only, not
# coordinator data. Preserve the addons key from the addon coordinator.
supervisor_info_dict["repositories"] = data[DATA_STORE][ATTR_REPOSITORIES]
if (prev := data.get(DATA_SUPERVISOR_INFO)) and "addons" in prev:
supervisor_info_dict["addons"] = prev["addons"]
data[DATA_SUPERVISOR_INFO] = supervisor_info_dict
# If this is the initial refresh, register all main components
if is_first_update:
@@ -819,5 +773,4 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
@callback
def unload(self) -> None:
"""Clean up when config entry unloaded."""
self._dispatcher_disconnect()
self.jobs.unload()
+2 -66
View File
@@ -229,29 +229,10 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity):
"""Update entity to handle updates for the Home Assistant Supervisor.
"""Update entity to handle updates for the Home Assistant Supervisor."""
The Supervisor update API blocks for the entire container download, then
Supervisor restarts itself. The base UpdateEntity always resets
``_attr_in_progress`` after ``async_install`` returns, but at that point the
restart is still ongoing. ``_update_ongoing`` survives that reset so the UI
keeps showing the installing state until the coordinator refreshes with the
new version after Supervisor comes back.
"""
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
)
_attr_supported_features = UpdateEntityFeature.INSTALL
_attr_title = "Home Assistant Supervisor"
_update_ongoing: bool = False
_version_before_update: str | None = None
@property
def in_progress(self) -> bool | None:
"""Return combined progress from the update job and restart phase."""
if self._update_ongoing:
return True
return self._attr_in_progress
@property
def latest_version(self) -> str:
@@ -285,58 +266,13 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity):
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
self._version_before_update = self.installed_version
self._update_ongoing = True
self._attr_in_progress = True
self.async_write_ha_state()
try:
await self.coordinator.supervisor_client.supervisor.update()
except SupervisorError as err:
self._update_ongoing = False
self._version_before_update = None
self._attr_in_progress = False
self.async_write_ha_state()
raise HomeAssistantError(
f"Error updating Home Assistant Supervisor: {err}"
) from err
@callback
def _handle_coordinator_update(self) -> None:
"""Clear the ongoing flag once the installed version has changed."""
if (
self._update_ongoing
and self.installed_version != self._version_before_update
):
self._update_ongoing = False
self._version_before_update = None
super()._handle_coordinator_update()
@callback
def _update_job_changed(self, job: Job) -> None:
"""Process update for this entity's update job."""
if job.done is False:
# Also covers updates not initiated via async_install (CLI,
# Supervisor self-update): capture the baseline so the installing
# state survives the Supervisor restart phase.
if not self._update_ongoing:
self._version_before_update = self.installed_version
self._update_ongoing = True
self._attr_in_progress = True
self._attr_update_percentage = job.progress
else:
self._attr_in_progress = False
self._attr_update_percentage = None
self.async_write_ha_state()
async def async_added_to_hass(self) -> None:
"""Subscribe to progress updates."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.jobs.subscribe(
JobSubscription(self._update_job_changed, name="supervisor_update")
)
)
class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
"""Update entity to handle updates for Home Assistant Core."""
@@ -16,13 +16,8 @@ from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
guess_firmware_info,
)
from homeassistant.components.usb import (
SerialDevice,
USBDevice,
async_register_serial_port_scanner,
)
from homeassistant.config_entries import SOURCE_HARDWARE, ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
from homeassistant.helpers import discovery_flow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -31,7 +26,6 @@ from homeassistant.helpers.hassio import is_hassio
from .const import (
FIRMWARE,
FIRMWARE_VERSION,
MANUFACTURER,
NABU_CASA_FIRMWARE_RELEASES_URL,
RADIO_DEVICE,
ZHA_HW_DISCOVERY_DATA,
@@ -86,20 +80,6 @@ async def async_setup_entry(
data=ZHA_HW_DISCOVERY_DATA,
)
@callback
def _scan_serial_ports(hass: HomeAssistant) -> list[USBDevice | SerialDevice]:
"""Contribute the Yellow's built-in Zigbee radio port."""
return [
SerialDevice(
device=RADIO_DEVICE,
serial_number=None,
manufacturer=MANUFACTURER,
description="Yellow Zigbee Radio",
)
]
entry.async_on_unload(async_register_serial_port_scanner(hass, _scan_serial_ports))
# Create and store the firmware update coordinator in runtime_data
session = async_get_clientsession(hass)
coordinator = FirmwareUpdateCoordinator(
@@ -4,7 +4,7 @@
"after_dependencies": ["hassio"],
"codeowners": ["@home-assistant/core"],
"config_flow": false,
"dependencies": ["hardware", "homeassistant_hardware", "usb"],
"dependencies": ["hardware", "homeassistant_hardware"],
"documentation": "https://www.home-assistant.io/integrations/homeassistant_yellow",
"integration_type": "hardware",
"loggers": [
@@ -70,8 +70,8 @@ class IsModeCondition(EntityStateConditionBase):
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF, support_duration=True),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON, support_duration=True),
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF),
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
"is_drying": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
),
@@ -13,16 +13,6 @@
- all
- any
.condition_common_for: &condition_common_for
target: *condition_humidifier_target
fields:
behavior: *condition_behavior
for:
required: true
default: 00:00:00
selector:
duration:
.humidity_threshold_entity: &humidity_threshold_entity
- domain: input_number
unit_of_measurement: "%"
@@ -37,8 +27,8 @@
mode: box
unit_of_measurement: "%"
is_off: *condition_common_for
is_on: *condition_common_for
is_off: *condition_common
is_on: *condition_common
is_drying: *condition_common
is_humidifying: *condition_common
@@ -1,7 +1,6 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least"
@@ -43,9 +42,6 @@
"fields": {
"behavior": {
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::condition_for_name%]"
}
},
"name": "Humidifier is off"
@@ -55,9 +51,6 @@
"fields": {
"behavior": {
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::humidifier::common::condition_for_name%]"
}
},
"name": "Humidifier is on"
@@ -83,7 +83,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
except AqualinkServiceUnauthorizedException as auth_exception:
await aqualink.close()
raise ConfigEntryAuthFailed(
"Invalid credentials for iAquaLink"
"Invalid credentials for iAqualink"
) from auth_exception
except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as aio_exception:
await aqualink.close()
@@ -96,7 +96,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
except AqualinkServiceUnauthorizedException as auth_exception:
await aqualink.close()
raise ConfigEntryAuthFailed(
"Invalid credentials for iAquaLink"
"Invalid credentials for iAqualink"
) from auth_exception
except AqualinkServiceException as svc_exception:
await aqualink.close()
@@ -132,7 +132,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
except AqualinkServiceUnauthorizedException as auth_exception:
await aqualink.close()
raise ConfigEntryAuthFailed(
"Invalid credentials for iAquaLink"
"Invalid credentials for iAqualink"
) from auth_exception
except AqualinkServiceException as svc_exception:
await aqualink.close()
@@ -36,7 +36,7 @@ class AqualinkFlowHandler(ConfigFlow, domain=DOMAIN):
async def _async_test_credentials(
self, user_input: dict[str, Any]
) -> dict[str, str]:
"""Validate credentials against iAquaLink."""
"""Validate credentials against iAqualink."""
try:
async with AqualinkClient(
user_input[CONF_USERNAME],
@@ -42,10 +42,10 @@ class AqualinkDataUpdateCoordinator(DataUpdateCoordinator[None]):
try:
await self.system.update()
except AqualinkServiceUnauthorizedException as err:
raise ConfigEntryAuthFailed("Invalid credentials for iAquaLink") from err
raise ConfigEntryAuthFailed("Invalid credentials for iAqualink") from err
except (AqualinkServiceException, httpx.HTTPError) as err:
raise UpdateFailed(
f"Unable to update iAquaLink system {self.system.serial}: {err}"
f"Unable to update iAqualink system {self.system.serial}: {err}"
) from err
if self.system.online is not True:
raise UpdateFailed(f"iAquaLink system {self.system.serial} is offline")
raise UpdateFailed(f"iAqualink system {self.system.serial} is offline")
@@ -1,14 +1,12 @@
{
"domain": "iaqualink",
"name": "Jandy iAquaLink",
"name": "Jandy iAqualink",
"codeowners": ["@flz"],
"config_flow": true,
"dhcp": [{ "hostname": "iaqualink-*" }],
"documentation": "https://www.home-assistant.io/integrations/iaqualink",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["iaqualink"],
"quality_scale": "bronze",
"requirements": ["iaqualink==0.6.0", "h2==4.3.0"],
"single_config_entry": true
}
@@ -1,68 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not register integration actions.
appropriate-polling: done
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 integration 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: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: This integration does not provide an options flow.
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: This integration uses a cloud account.
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: todo
entity-category: todo
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo
@@ -1,8 +1,6 @@
{
"config": {
"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%]"
},
"error": {
@@ -15,24 +13,16 @@
"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": "Please enter the username and password for your iAquaLink account.",
"title": "Reauthenticate iAquaLink"
"description": "Please enter the username and password for your iAqualink account.",
"title": "Reauthenticate iAqualink"
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password associated with your account.",
"username": "The email address used to sign in to your account using the iAquaLink app or website."
},
"description": "Please enter the username and password for your iAquaLink account.",
"title": "Connect to iAquaLink"
"description": "Please enter the username and password for your iAqualink account.",
"title": "Connect to iAqualink"
}
}
}
@@ -25,10 +25,10 @@ ILLUMINANCE_VALUE_DOMAIN_SPECS = {
CONDITIONS: dict[str, type[Condition]] = {
"is_detected": make_entity_state_condition(
ILLUMINANCE_DETECTED_DOMAIN_SPECS, STATE_ON, support_duration=True
ILLUMINANCE_DETECTED_DOMAIN_SPECS, STATE_ON
),
"is_not_detected": make_entity_state_condition(
ILLUMINANCE_DETECTED_DOMAIN_SPECS, STATE_OFF, support_duration=True
ILLUMINANCE_DETECTED_DOMAIN_SPECS, STATE_OFF
),
"is_value": make_entity_numerical_condition(
ILLUMINANCE_VALUE_DOMAIN_SPECS, LIGHT_LUX
@@ -13,11 +13,6 @@
options:
- all
- any
for:
required: true
default: 00:00:00
selector:
duration:
is_detected: *detected_condition_common
@@ -1,7 +1,6 @@
{
"common": {
"condition_behavior_name": "Condition passes if",
"condition_for_name": "For at least",
"condition_threshold_name": "Threshold type",
"trigger_behavior_name": "Trigger when",
"trigger_for_name": "For at least",
@@ -13,9 +12,6 @@
"fields": {
"behavior": {
"name": "[%key:component::illuminance::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::illuminance::common::condition_for_name%]"
}
},
"name": "Light is detected"
@@ -25,9 +21,6 @@
"fields": {
"behavior": {
"name": "[%key:component::illuminance::common::condition_behavior_name%]"
},
"for": {
"name": "[%key:component::illuminance::common::condition_for_name%]"
}
},
"name": "Light is not detected"

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