mirror of
https://github.com/home-assistant/core.git
synced 2026-05-06 16:47:03 +02:00
Compare commits
93 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 30f8f0517f | |||
| 3f31be37f5 | |||
| cf0a14f92b | |||
| 2fcbd50784 | |||
| c08743f907 | |||
| a67ea6d4f7 | |||
| d17f6a1509 | |||
| f3932f2342 | |||
| 598be31daf | |||
| 9b2a81614f | |||
| f53c89d3bc | |||
| ac6991072f | |||
| 018e8e06fa | |||
| 0ffc9694a7 | |||
| 8d8b30a41e | |||
| 9b7f61d862 | |||
| 368f2f44be | |||
| ad6a910244 | |||
| 840b44039d | |||
| 1943675a64 | |||
| 161e05b075 | |||
| f2d5ca3582 | |||
| 551af8caef | |||
| 201c575316 | |||
| 703860ee6e | |||
| cb021f0b6b | |||
| 50dbff31b0 | |||
| 800299077e | |||
| f40b269752 | |||
| f2105c07de | |||
| d23dbfb214 | |||
| de6586684a | |||
| 9a08b941bb | |||
| 51b9f004e9 | |||
| fe443f4ce9 | |||
| b0ba7ec6ec | |||
| 156901c290 | |||
| b6271e59fa | |||
| 17cd0aa474 | |||
| 79f12f658a | |||
| e13b63342e | |||
| 3500f0a195 | |||
| 4a93dcb936 | |||
| 27ddb5b6a4 | |||
| 0ff38cdc7f | |||
| 1a8adea358 | |||
| 2a85046584 | |||
| fc85d35d4c | |||
| 608b92be40 | |||
| af01b41e52 | |||
| f257d54d1e | |||
| 7c7c075df4 | |||
| 5a487d452d | |||
| a4138fa4cd | |||
| a6b4609313 | |||
| 95e9405cd0 | |||
| d990ec1b65 | |||
| 52d7dcbcc8 | |||
| 8e1346fd1f | |||
| a2485960d8 | |||
| a06ffe6379 | |||
| 966e8aeca4 | |||
| d7f666a661 | |||
| 671b3e01ad | |||
| a85c82ae24 | |||
| d9af83a03f | |||
| c489980551 | |||
| 06400ab688 | |||
| 9d7d56c5bf | |||
| b1fcc0ebde | |||
| 12af4bd0f4 | |||
| 6bb083ee61 | |||
| a6f9246c2f | |||
| 3222472f10 | |||
| e620426002 | |||
| 6e61a60eba | |||
| 6942066930 | |||
| 7c1fd1a237 | |||
| 3fd77b0d7a | |||
| f73f1df5a2 | |||
| fb89d94957 | |||
| a9c3854d69 | |||
| ef1a5ea2df | |||
| 514d5e570a | |||
| 9de658b918 | |||
| ac4e746977 | |||
| e10f59c936 | |||
| fb171809ec | |||
| 137122ebb5 | |||
| 502dc5075d | |||
| 42232cfe3f | |||
| 0ae1236acb | |||
| 63f84af4ff |
@@ -28,11 +28,11 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
languages: python
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@c10b8064de6f491fea524254123dbe5e09572f13 # v4.35.1
|
||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
||||
with:
|
||||
category: "/language:python"
|
||||
|
||||
@@ -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.0
|
||||
rev: v1.24.1
|
||||
hooks:
|
||||
- id: zizmor
|
||||
args:
|
||||
|
||||
Generated
+4
@@ -400,6 +400,8 @@ 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
|
||||
@@ -1253,6 +1255,8 @@ 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
|
||||
|
||||
@@ -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, DOMAIN_DATA, LOGGER
|
||||
from .const import CONF_POLLING, DOMAIN, LOGGER
|
||||
from .services import async_setup_services
|
||||
|
||||
ATTR_DEVICE_NAME = "device_name"
|
||||
@@ -67,13 +67,16 @@ 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: ConfigEntry) -> bool:
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> bool:
|
||||
"""Set up Abode integration from a config entry."""
|
||||
username = entry.data[CONF_USERNAME]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
@@ -99,50 +102,54 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except (AbodeException, ConnectTimeout, HTTPError) as ex:
|
||||
raise ConfigEntryNotReady(f"Unable to connect to Abode: {ex}") from ex
|
||||
|
||||
hass.data[DOMAIN_DATA] = AbodeSystem(abode, polling)
|
||||
entry.runtime_data = AbodeSystem(abode, polling)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
await setup_hass_events(hass)
|
||||
await hass.async_add_executor_job(setup_abode_events, hass)
|
||||
await setup_hass_events(hass, entry)
|
||||
await hass.async_add_executor_job(setup_abode_events, hass, entry)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
def _shutdown_client(abode: Abode) -> None:
|
||||
"""Shutdown client."""
|
||||
abode.events.stop()
|
||||
abode.logout()
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: AbodeConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
|
||||
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)
|
||||
await hass.async_add_executor_job(_shutdown_client, entry.runtime_data.abode)
|
||||
|
||||
if logout_listener := hass.data[DOMAIN_DATA].logout_listener:
|
||||
if logout_listener := entry.runtime_data.logout_listener:
|
||||
logout_listener()
|
||||
hass.data.pop(DOMAIN_DATA)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def setup_hass_events(hass: HomeAssistant) -> None:
|
||||
async def setup_hass_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None:
|
||||
"""Home Assistant start and stop callbacks."""
|
||||
|
||||
def logout(event: Event) -> None:
|
||||
"""Logout of Abode."""
|
||||
if not hass.data[DOMAIN_DATA].polling:
|
||||
hass.data[DOMAIN_DATA].abode.events.stop()
|
||||
if not entry.runtime_data.polling:
|
||||
entry.runtime_data.abode.events.stop()
|
||||
|
||||
hass.data[DOMAIN_DATA].abode.logout()
|
||||
entry.runtime_data.abode.logout()
|
||||
LOGGER.info("Logged out of Abode")
|
||||
|
||||
if not hass.data[DOMAIN_DATA].polling:
|
||||
await hass.async_add_executor_job(hass.data[DOMAIN_DATA].abode.events.start)
|
||||
if not entry.runtime_data.polling:
|
||||
await hass.async_add_executor_job(entry.runtime_data.abode.events.start)
|
||||
|
||||
hass.data[DOMAIN_DATA].logout_listener = hass.bus.async_listen_once(
|
||||
entry.runtime_data.logout_listener = hass.bus.async_listen_once(
|
||||
EVENT_HOMEASSISTANT_STOP, logout
|
||||
)
|
||||
|
||||
|
||||
def setup_abode_events(hass: HomeAssistant) -> None:
|
||||
def setup_abode_events(hass: HomeAssistant, entry: AbodeConfigEntry) -> None:
|
||||
"""Event callbacks."""
|
||||
|
||||
def event_callback(event: str, event_json: dict[str, str]) -> None:
|
||||
@@ -179,6 +186,6 @@ def setup_abode_events(hass: HomeAssistant) -> None:
|
||||
]
|
||||
|
||||
for event in events:
|
||||
hass.data[DOMAIN_DATA].abode.events.add_event_callback(
|
||||
entry.runtime_data.abode.events.add_event_callback(
|
||||
event, partial(event_callback, event)
|
||||
)
|
||||
|
||||
@@ -9,21 +9,20 @@ 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 .const import DOMAIN_DATA
|
||||
from . import AbodeConfigEntry
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: AbodeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode alarm control panel device."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data = entry.runtime_data
|
||||
async_add_entities(
|
||||
[AbodeAlarm(data, await hass.async_add_executor_job(data.abode.get_alarm))]
|
||||
)
|
||||
|
||||
@@ -10,22 +10,21 @@ 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 .const import DOMAIN_DATA
|
||||
from . import AbodeConfigEntry
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: AbodeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode binary sensor devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data = entry.runtime_data
|
||||
|
||||
device_types = [
|
||||
"connectivity",
|
||||
|
||||
@@ -12,14 +12,13 @@ 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 AbodeSystem
|
||||
from .const import DOMAIN_DATA, LOGGER
|
||||
from . import AbodeConfigEntry, AbodeSystem
|
||||
from .const import LOGGER
|
||||
from .entity import AbodeDevice
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
|
||||
@@ -27,11 +26,11 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=90)
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: AbodeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode camera devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AbodeCamera(data, device, timeline.CAPTURE_IMAGE)
|
||||
|
||||
@@ -3,17 +3,10 @@
|
||||
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"
|
||||
|
||||
@@ -5,21 +5,20 @@ 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 .const import DOMAIN_DATA
|
||||
from . import AbodeConfigEntry
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: AbodeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode cover devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AbodeCover(data, device)
|
||||
|
||||
@@ -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, DOMAIN_DATA
|
||||
from .const import ATTRIBUTION, DOMAIN
|
||||
|
||||
|
||||
class AbodeEntity(Entity):
|
||||
@@ -29,7 +29,7 @@ class AbodeEntity(Entity):
|
||||
self._update_connection_status,
|
||||
)
|
||||
|
||||
self.hass.data[DOMAIN_DATA].entity_ids.add(self.entity_id)
|
||||
self._data.entity_ids.add(self.entity_id)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Unsubscribe from Abode connection status updates."""
|
||||
|
||||
@@ -16,21 +16,20 @@ 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 .const import DOMAIN_DATA
|
||||
from . import AbodeConfigEntry
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: AbodeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode light devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AbodeLight(data, device)
|
||||
|
||||
@@ -5,21 +5,20 @@ 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 .const import DOMAIN_DATA
|
||||
from . import AbodeConfigEntry
|
||||
from .entity import AbodeDevice
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: AbodeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode lock devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AbodeLock(data, device)
|
||||
|
||||
@@ -14,13 +14,11 @@ 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 AbodeSystem
|
||||
from .const import DOMAIN_DATA
|
||||
from . import AbodeConfigEntry, AbodeSystem
|
||||
from .entity import AbodeDevice
|
||||
|
||||
ABODE_TEMPERATURE_UNIT_HA_UNIT = {
|
||||
@@ -66,11 +64,11 @@ SENSOR_TYPES: tuple[AbodeSensorDescription, ...] = (
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: AbodeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode sensor devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
AbodeSensor(data, device, description)
|
||||
|
||||
@@ -2,15 +2,21 @@
|
||||
|
||||
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, DOMAIN_DATA, LOGGER
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AbodeConfigEntry, AbodeSystem
|
||||
|
||||
ATTR_SETTING = "setting"
|
||||
ATTR_VALUE = "value"
|
||||
@@ -25,13 +31,21 @@ 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:
|
||||
call.hass.data[DOMAIN_DATA].abode.set_setting(setting, value)
|
||||
_get_abode_system(call.hass).abode.set_setting(setting, value)
|
||||
except AbodeException as ex:
|
||||
LOGGER.warning(ex)
|
||||
|
||||
@@ -42,7 +56,7 @@ def _capture_image(call: ServiceCall) -> None:
|
||||
|
||||
target_entities = [
|
||||
entity_id
|
||||
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
|
||||
for entity_id in _get_abode_system(call.hass).entity_ids
|
||||
if entity_id in entity_ids
|
||||
]
|
||||
|
||||
@@ -57,7 +71,7 @@ def _trigger_automation(call: ServiceCall) -> None:
|
||||
|
||||
target_entities = [
|
||||
entity_id
|
||||
for entity_id in call.hass.data[DOMAIN_DATA].entity_ids
|
||||
for entity_id in _get_abode_system(call.hass).entity_ids
|
||||
if entity_id in entity_ids
|
||||
]
|
||||
|
||||
|
||||
@@ -7,12 +7,11 @@ 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 .const import DOMAIN_DATA
|
||||
from . import AbodeConfigEntry
|
||||
from .entity import AbodeAutomation, AbodeDevice
|
||||
|
||||
DEVICE_TYPES = ["switch", "valve"]
|
||||
@@ -20,11 +19,11 @@ DEVICE_TYPES = ["switch", "valve"]
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
entry: AbodeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Abode switch devices."""
|
||||
data = hass.data[DOMAIN_DATA]
|
||||
data = entry.runtime_data
|
||||
|
||||
entities: list[SwitchEntity] = [
|
||||
AbodeSwitch(data, device)
|
||||
|
||||
@@ -36,7 +36,9 @@ 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
|
||||
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)},
|
||||
STATE_ON,
|
||||
support_duration=True,
|
||||
)
|
||||
|
||||
|
||||
@@ -45,7 +47,9 @@ 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
|
||||
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)},
|
||||
STATE_OFF,
|
||||
support_duration=True,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -249,6 +249,11 @@
|
||||
.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,6 +1,7 @@
|
||||
{
|
||||
"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",
|
||||
@@ -24,6 +25,9 @@
|
||||
"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"
|
||||
@@ -33,6 +37,9 @@
|
||||
"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"
|
||||
@@ -54,6 +61,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::air_quality::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gas cleared"
|
||||
@@ -63,6 +73,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::air_quality::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Gas detected"
|
||||
@@ -168,6 +181,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::air_quality::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::air_quality::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Smoke cleared"
|
||||
@@ -177,6 +193,9 @@
|
||||
"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,6 +4,7 @@ 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,
|
||||
@@ -25,6 +26,7 @@ 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."""
|
||||
@@ -82,9 +84,11 @@ CONDITIONS: dict[str, type[Condition]] = {
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
AlarmControlPanelEntityFeature.ARM_VACATION,
|
||||
),
|
||||
"is_disarmed": make_entity_state_condition(DOMAIN, AlarmControlPanelState.DISARMED),
|
||||
"is_disarmed": make_entity_state_condition(
|
||||
DOMAIN, AlarmControlPanelState.DISARMED, support_duration=True
|
||||
),
|
||||
"is_triggered": make_entity_state_condition(
|
||||
DOMAIN, AlarmControlPanelState.TRIGGERED
|
||||
DOMAIN, AlarmControlPanelState.TRIGGERED, support_duration=True
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
target: &condition_common_target
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
fields: &condition_common_fields
|
||||
behavior:
|
||||
behavior: &condition_common_behavior
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
@@ -13,10 +13,20 @@
|
||||
- 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_fields
|
||||
fields: *condition_common_for_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
@@ -24,7 +34,7 @@ is_armed_away:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
|
||||
is_armed_home:
|
||||
fields: *condition_common_fields
|
||||
fields: *condition_common_for_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
@@ -32,7 +42,7 @@ is_armed_home:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
|
||||
|
||||
is_armed_night:
|
||||
fields: *condition_common_fields
|
||||
fields: *condition_common_for_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
@@ -40,13 +50,13 @@ is_armed_night:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
|
||||
is_armed_vacation:
|
||||
fields: *condition_common_fields
|
||||
fields: *condition_common_for_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
|
||||
is_disarmed: *condition_common
|
||||
is_disarmed: *condition_common_for
|
||||
|
||||
is_triggered: *condition_common
|
||||
is_triggered: *condition_common_for
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -19,6 +20,9 @@
|
||||
"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"
|
||||
@@ -28,6 +32,9 @@
|
||||
"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"
|
||||
@@ -37,6 +44,9 @@
|
||||
"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"
|
||||
@@ -46,6 +56,9 @@
|
||||
"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"
|
||||
@@ -55,6 +68,9 @@
|
||||
"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"
|
||||
@@ -64,6 +80,9 @@
|
||||
"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,7 +43,6 @@ 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,
|
||||
@@ -66,7 +65,6 @@ from .const import (
|
||||
DOMAIN,
|
||||
MIN_THINKING_BUDGET,
|
||||
TOOL_SEARCH_UNSUPPORTED_MODELS,
|
||||
WEB_SEARCH_UNSUPPORTED_MODELS,
|
||||
PromptCaching,
|
||||
)
|
||||
from .coordinator import model_alias
|
||||
@@ -389,8 +387,6 @@ 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
|
||||
@@ -445,43 +441,34 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
else:
|
||||
self.options.pop(CONF_THINKING_EFFORT, None)
|
||||
|
||||
if not model.startswith(tuple(CODE_EXECUTION_UNSUPPORTED_MODELS)):
|
||||
step_schema[
|
||||
step_schema.update(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_CODE_EXECUTION,
|
||||
default=DEFAULT[CONF_CODE_EXECUTION],
|
||||
)
|
||||
] = 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)
|
||||
): 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,
|
||||
}
|
||||
)
|
||||
|
||||
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,15 +50,6 @@ 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,9 +28,7 @@ _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 == "claude-3-haiku-20240307" or model_id.endswith("-preview"):
|
||||
return model_id
|
||||
if model_id[-2:-1] != "-":
|
||||
if model_id[-2:-1] != "-" and not model_id.endswith("-preview"):
|
||||
model_id = model_id[:-9]
|
||||
if _model_short_form.search(model_id):
|
||||
return model_id + "-0"
|
||||
|
||||
@@ -124,10 +124,14 @@ 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=convert(tool.parameters, custom_serializer=custom_serializer),
|
||||
input_schema=schema,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -7,13 +7,17 @@ from .const import DOMAIN
|
||||
from .entity import AssistSatelliteState
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_idle": make_entity_state_condition(DOMAIN, AssistSatelliteState.IDLE),
|
||||
"is_listening": make_entity_state_condition(DOMAIN, AssistSatelliteState.LISTENING),
|
||||
"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_processing": make_entity_state_condition(
|
||||
DOMAIN, AssistSatelliteState.PROCESSING
|
||||
DOMAIN, AssistSatelliteState.PROCESSING, support_duration=True
|
||||
),
|
||||
"is_responding": make_entity_state_condition(
|
||||
DOMAIN, AssistSatelliteState.RESPONDING
|
||||
DOMAIN, AssistSatelliteState.RESPONDING, support_duration=True
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_idle: *condition_common
|
||||
is_listening: *condition_common
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -10,6 +11,9 @@
|
||||
"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"
|
||||
@@ -19,6 +23,9 @@
|
||||
"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"
|
||||
@@ -28,6 +35,9 @@
|
||||
"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"
|
||||
@@ -37,6 +47,9 @@
|
||||
"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,6 +169,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
|
||||
"cover",
|
||||
"device_tracker",
|
||||
"door",
|
||||
"doorbell",
|
||||
"event",
|
||||
"fan",
|
||||
"garage_door",
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
"""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
|
||||
@@ -30,14 +34,22 @@ from .const import (
|
||||
CONF_REGION,
|
||||
CONF_SECRET_ACCESS_KEY,
|
||||
CONF_VALIDATE,
|
||||
DATA_CONFIG,
|
||||
DATA_HASS_CONFIG,
|
||||
DATA_SESSIONS,
|
||||
DATA_AWS,
|
||||
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,
|
||||
@@ -88,14 +100,13 @@ 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_CONFIG] = conf
|
||||
hass.data[DATA_SESSIONS] = OrderedDict()
|
||||
hass.data[DATA_AWS] = AWSData(
|
||||
hass_config=config, config=conf, sessions=OrderedDict()
|
||||
)
|
||||
|
||||
hass.async_create_task(
|
||||
hass.config_entries.flow.async_init(
|
||||
@@ -111,8 +122,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
Validate and save sessions per aws credential.
|
||||
"""
|
||||
config = hass.data[DATA_HASS_CONFIG]
|
||||
conf = hass.data[DATA_CONFIG]
|
||||
data = hass.data[DATA_AWS]
|
||||
conf = data.config
|
||||
|
||||
if entry.source == config_entries.SOURCE_IMPORT:
|
||||
if conf is None:
|
||||
@@ -143,14 +154,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
validation = False
|
||||
else:
|
||||
hass.data[DATA_SESSIONS][name] = result
|
||||
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, config
|
||||
hass, Platform.NOTIFY, DOMAIN, notify_config, data.hass_config
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -1,10 +1,17 @@
|
||||
"""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_CONFIG = "aws_config"
|
||||
DATA_HASS_CONFIG = "aws_hass_config"
|
||||
DATA_SESSIONS = "aws_sessions"
|
||||
DATA_AWS: HassKey[AWSData] = HassKey(DOMAIN)
|
||||
|
||||
CONF_ACCESS_KEY_ID = "aws_access_key_id"
|
||||
CONF_CONTEXT = "context"
|
||||
|
||||
@@ -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_SESSIONS
|
||||
from .const import CONF_CONTEXT, CONF_CREDENTIAL_NAME, CONF_REGION, DATA_AWS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -76,10 +76,12 @@ 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 hass.data[DATA_SESSIONS]:
|
||||
session = next(iter(hass.data[DATA_SESSIONS].values()))
|
||||
if sessions:
|
||||
session = next(iter(sessions.values()))
|
||||
else:
|
||||
_LOGGER.error("Missing aws credential for %s", config[CONF_NAME])
|
||||
return None
|
||||
@@ -87,7 +89,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 = hass.data[DATA_SESSIONS].get(credential_name)
|
||||
session = 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,10 +5,7 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
DATA_MANAGER as BACKUP_DATA_MANAGER,
|
||||
BackupManager,
|
||||
)
|
||||
from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -31,7 +28,7 @@ async def async_get_config_entry_diagnostics(
|
||||
) -> dict[str, Any]:
|
||||
"""Return diagnostics for a config entry."""
|
||||
coordinator = entry.runtime_data
|
||||
backup_manager: BackupManager = hass.data[BACKUP_DATA_MANAGER]
|
||||
backup_manager = hass.data[BACKUP_DATA_MANAGER]
|
||||
backups = await async_list_backups_from_s3(
|
||||
coordinator.client,
|
||||
bucket=entry.data[CONF_BUCKET],
|
||||
|
||||
@@ -29,11 +29,17 @@ BATTERY_PERCENTAGE_DOMAIN_SPECS = {
|
||||
}
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"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_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_not_charging": make_entity_state_condition(
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
|
||||
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF, support_duration=True
|
||||
),
|
||||
"is_level": make_entity_numerical_condition(
|
||||
BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for: &condition_for
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
.battery_threshold_entity: &battery_threshold_entity
|
||||
- domain: input_number
|
||||
@@ -39,6 +44,7 @@ is_charging:
|
||||
device_class: battery_charging
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
for: *condition_for
|
||||
|
||||
is_not_charging:
|
||||
target:
|
||||
@@ -47,6 +53,7 @@ is_not_charging:
|
||||
device_class: battery_charging
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
for: *condition_for
|
||||
|
||||
is_level:
|
||||
target:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"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",
|
||||
@@ -12,6 +13,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::battery::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::battery::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery is charging"
|
||||
@@ -33,6 +37,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::battery::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::battery::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery is low"
|
||||
@@ -42,6 +49,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::battery::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::battery::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Battery is not charging"
|
||||
@@ -51,6 +61,9 @@
|
||||
"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.0"],
|
||||
"requirements": ["blebox-uniapi==2.5.1"],
|
||||
"zeroconf": ["_bbxsrv._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ 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"},
|
||||
@@ -45,6 +44,3 @@ 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
|
||||
|
||||
@@ -1,184 +0,0 @@
|
||||
"""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 0–255 (the hardware repeat byte is a single unsigned byte).
|
||||
|
||||
Raises:
|
||||
ValueError: If repeat_count is outside 0–255 range.
|
||||
"""
|
||||
if not 0 <= repeat_count <= 255:
|
||||
raise ValueError(f"repeat_count must be 0–255, 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 0–255, 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,7 +3,6 @@
|
||||
"name": "Broadlink",
|
||||
"codeowners": ["@danielhiversen", "@felipediel", "@L-I-Am", "@eifinger"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["infrared"],
|
||||
"dhcp": [
|
||||
{
|
||||
"registered_devices": true
|
||||
|
||||
@@ -49,11 +49,6 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"infrared": {
|
||||
"infrared": {
|
||||
"name": "IR transmitter"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"day_of_week": {
|
||||
"name": "Day of week",
|
||||
@@ -82,10 +77,5 @@
|
||||
"name": "Total consumption"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"send_command_failed": {
|
||||
"message": "Failed to send IR command: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,9 @@ 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),
|
||||
"is_event_active": make_entity_state_condition(
|
||||
DOMAIN, STATE_ON, support_duration=True
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,3 +12,8 @@ is_event_active:
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if"
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least"
|
||||
},
|
||||
"conditions": {
|
||||
"is_event_active": {
|
||||
@@ -8,6 +9,9 @@
|
||||
"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),
|
||||
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF, support_duration=True),
|
||||
"is_on": make_entity_state_condition(
|
||||
DOMAIN,
|
||||
{
|
||||
|
||||
@@ -39,7 +39,16 @@
|
||||
- domain: number
|
||||
device_class: temperature
|
||||
|
||||
is_off: *condition_common
|
||||
is_off:
|
||||
target: *condition_climate_target
|
||||
fields:
|
||||
behavior: *condition_behavior
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_on: *condition_common
|
||||
is_cooling: *condition_common
|
||||
is_drying: *condition_common
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"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",
|
||||
@@ -52,6 +53,9 @@
|
||||
"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,
|
||||
SerialSelector,
|
||||
SerialPortSelector,
|
||||
)
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
@@ -110,7 +110,7 @@ class DenonRS232ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
translation_key="model",
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_DEVICE): SerialSelector(),
|
||||
vol.Required(CONF_DEVICE): SerialPortSelector(),
|
||||
}
|
||||
),
|
||||
user_input or {},
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
"""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
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"triggers": {
|
||||
"rang": {
|
||||
"trigger": "mdi:doorbell"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "doorbell",
|
||||
"name": "Doorbell",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/doorbell",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"title": "Doorbell",
|
||||
"triggers": {
|
||||
"rang": {
|
||||
"description": "Triggers after one or more doorbells rang.",
|
||||
"name": "Doorbell rang"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
"""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
|
||||
@@ -0,0 +1,5 @@
|
||||
rang:
|
||||
target:
|
||||
entity:
|
||||
domain: event
|
||||
device_class: doorbell
|
||||
@@ -13,6 +13,7 @@ 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
|
||||
@@ -35,6 +36,27 @@ 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:
|
||||
|
||||
@@ -64,6 +64,7 @@ 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()
|
||||
|
||||
@@ -3,12 +3,17 @@
|
||||
"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": "silver",
|
||||
"requirements": ["python-duco-client==0.3.2"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["python-duco-client==0.3.4"],
|
||||
"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,11 +55,7 @@ rules:
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: 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.
|
||||
dynamic-devices: done
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
entity-disabled-by-default: done
|
||||
@@ -74,11 +70,7 @@ 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:
|
||||
status: todo
|
||||
comment: >-
|
||||
To be implemented together with dynamic device support in a follow-up PR.
|
||||
|
||||
stale-devices: done
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
|
||||
@@ -19,9 +19,11 @@ from homeassistant.const import (
|
||||
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
|
||||
EntityCategory,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
from .entity import DucoEntity
|
||||
|
||||
@@ -111,22 +113,52 @@ async def async_setup_entry(
|
||||
"""Set up Duco sensor entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
[
|
||||
*[
|
||||
# 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(
|
||||
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):
|
||||
|
||||
@@ -35,11 +35,7 @@ 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 = [
|
||||
interval
|
||||
for timing in command.get_raw_timings()
|
||||
for interval in (timing.high_us, -timing.low_us)
|
||||
]
|
||||
timings = command.get_raw_timings()
|
||||
_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.13.3",
|
||||
"aioesphomeapi==44.18.0",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.3"
|
||||
],
|
||||
|
||||
@@ -11,6 +11,7 @@ from aioesphomeapi import (
|
||||
WaterHeaterInfo,
|
||||
WaterHeaterMode,
|
||||
WaterHeaterState,
|
||||
WaterHeaterStateFlag,
|
||||
)
|
||||
|
||||
from homeassistant.components.water_heater import (
|
||||
@@ -72,6 +73,8 @@ 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
|
||||
@@ -92,6 +95,12 @@ 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."""
|
||||
@@ -128,6 +137,24 @@ 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,
|
||||
|
||||
@@ -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),
|
||||
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
|
||||
"is_off": make_entity_state_condition(DOMAIN, STATE_OFF, support_duration=True),
|
||||
"is_on": make_entity_state_condition(DOMAIN, STATE_ON, support_duration=True),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -12,6 +12,11 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_off: *condition_common
|
||||
is_on: *condition_common
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_name": "Condition passes if",
|
||||
"condition_for_name": "For at least",
|
||||
"trigger_behavior_name": "Trigger when",
|
||||
"trigger_for_name": "For at least"
|
||||
},
|
||||
@@ -10,6 +11,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::fan::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::fan::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Fan is off"
|
||||
@@ -19,6 +23,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::fan::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::fan::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Fan is on"
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
{
|
||||
"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%]",
|
||||
@@ -14,39 +19,39 @@
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
"api_key": "[%key:component::firefly_iii::common::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "The new API access token for authenticating with Firefly III"
|
||||
"api_key": "[%key:component::firefly_iii::common::api_key_description%]"
|
||||
},
|
||||
"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:common::config_flow::data::api_key%]",
|
||||
"api_key": "[%key:component::firefly_iii::common::api_key%]",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "[%key:component::firefly_iii::config::step::user::data_description::api_key%]",
|
||||
"api_key": "[%key:component::firefly_iii::common::api_key_description%]",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"verify_ssl": "[%key:component::firefly_iii::config::step::user::data_description::verify_ssl%]"
|
||||
"verify_ssl": "[%key:component::firefly_iii::common::verify_ssl_description%]"
|
||||
},
|
||||
"description": "Use the following form to reconfigure your Firefly III instance.",
|
||||
"title": "Reconfigure Firefly III Integration"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]",
|
||||
"api_key": "[%key:component::firefly_iii::common::api_key%]",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "The API key for authenticating with Firefly III",
|
||||
"api_key": "[%key:component::firefly_iii::common::api_key_description%]",
|
||||
"url": "[%key:common::config_flow::data::url%]",
|
||||
"verify_ssl": "Verify the SSL certificate of the Firefly III instance"
|
||||
"verify_ssl": "[%key:component::firefly_iii::common::verify_ssl_description%]"
|
||||
},
|
||||
"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)."
|
||||
"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)."
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Awaitable, Callable, Coroutine
|
||||
from functools import wraps
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Any, Concatenate
|
||||
|
||||
from afsapi import (
|
||||
AFSAPI,
|
||||
FSApiError,
|
||||
FSConnectionError,
|
||||
FSNotImplementedError,
|
||||
PlayCaps,
|
||||
@@ -24,6 +27,7 @@ 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
|
||||
@@ -35,6 +39,37 @@ 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,
|
||||
@@ -272,57 +307,74 @@ 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."""
|
||||
await self.fs_device.play()
|
||||
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()
|
||||
|
||||
@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)
|
||||
@@ -332,6 +384,7 @@ 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 (
|
||||
@@ -340,6 +393,7 @@ 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(
|
||||
@@ -350,10 +404,12 @@ 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))
|
||||
@@ -369,6 +425,7 @@ 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,5 +33,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_error": {
|
||||
"message": "Failed to execute {command}: {message}"
|
||||
},
|
||||
"connection_error": {
|
||||
"message": "Failed to execute {command}: could not connect to device"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
|
||||
|
||||
from .coordinator import FumisConfigEntry, FumisDataUpdateCoordinator
|
||||
|
||||
PLATFORMS = [Platform.CLIMATE]
|
||||
PLATFORMS = [Platform.CLIMATE, Platform.SENSOR]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: FumisConfigEntry) -> bool:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from fumis import (
|
||||
@@ -21,6 +22,7 @@ from homeassistant.helpers.selector import (
|
||||
TextSelectorConfig,
|
||||
TextSelectorType,
|
||||
)
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
@@ -28,6 +30,64 @@ 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:
|
||||
@@ -80,3 +140,51 @@ 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,6 +14,7 @@ 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
|
||||
|
||||
@@ -47,7 +48,7 @@ class FumisDataUpdateCoordinator(DataUpdateCoordinator[FumisInfo]):
|
||||
try:
|
||||
return await self.client.update_info()
|
||||
except FumisAuthenticationError as err:
|
||||
raise UpdateFailed(
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="authentication_error",
|
||||
) from err
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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,6 +3,11 @@
|
||||
"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,18 +36,16 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
parallel-updates: done
|
||||
reauthentication-flow: todo
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery:
|
||||
status: todo
|
||||
comment: DHCP discovery can be added.
|
||||
discovery: done
|
||||
discovery-update-info:
|
||||
status: todo
|
||||
comment: DHCP discovery based update can be added.
|
||||
status: exempt
|
||||
comment: Cloud-only API, no local device information to update.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
|
||||
@@ -0,0 +1,276 @@
|
||||
"""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,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -10,6 +11,24 @@
|
||||
"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",
|
||||
@@ -23,6 +42,88 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"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,14 +1076,16 @@ 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,
|
||||
@@ -1236,14 +1238,16 @@ 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.25.1",
|
||||
"google-cloud-speech==2.31.1"
|
||||
"google-cloud-texttospeech==2.36.0",
|
||||
"google-cloud-speech==2.38.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -5,10 +5,7 @@ from __future__ import annotations
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.backup import (
|
||||
DATA_MANAGER as BACKUP_DATA_MANAGER,
|
||||
BackupManager,
|
||||
)
|
||||
from homeassistant.components.backup import DATA_MANAGER as BACKUP_DATA_MANAGER
|
||||
from homeassistant.components.diagnostics import async_redact_data
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -26,7 +23,7 @@ async def async_get_config_entry_diagnostics(
|
||||
"""Return diagnostics for a config entry."""
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
backup_manager: BackupManager = hass.data[BACKUP_DATA_MANAGER]
|
||||
backup_manager = 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.startswith("audio/L"):
|
||||
if not mime_type.lower().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.startswith("audio/L"):
|
||||
elif param.lower().startswith("audio/l"):
|
||||
# Keep bits_per_sample as default if conversion fails
|
||||
with suppress(ValueError, IndexError):
|
||||
bits_per_sample = int(param.split("L", 1)[1])
|
||||
bits_per_sample = int(param.upper().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.29.0"]
|
||||
"requirements": ["google-cloud-pubsub==2.37.0"]
|
||||
}
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google", "homeassistant.helpers.location"],
|
||||
"requirements": ["google-maps-routing==0.6.15"]
|
||||
"requirements": ["google-maps-routing==0.10.0"]
|
||||
}
|
||||
|
||||
@@ -76,7 +76,6 @@ 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,
|
||||
@@ -343,21 +342,14 @@ 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.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]
|
||||
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
|
||||
|
||||
# Fetch data
|
||||
update_info_task = hass.async_create_task(update_info_data(), eager_start=True)
|
||||
|
||||
@@ -9,8 +9,25 @@ 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"
|
||||
@@ -77,25 +94,31 @@ EVENT_JOB = "job"
|
||||
UPDATE_KEY_SUPERVISOR = "supervisor"
|
||||
STARTUP_COMPLETE = "complete"
|
||||
|
||||
MAIN_COORDINATOR = "hassio_main_coordinator"
|
||||
ADDONS_COORDINATOR = "hassio_addons_coordinator"
|
||||
STATS_COORDINATOR = "hassio_stats_coordinator"
|
||||
MAIN_COORDINATOR: HassKey[HassioMainDataUpdateCoordinator] = HassKey(
|
||||
"hassio_main_coordinator"
|
||||
)
|
||||
ADDONS_COORDINATOR: HassKey[HassioAddOnDataUpdateCoordinator] = HassKey(
|
||||
"hassio_addons_coordinator"
|
||||
)
|
||||
STATS_COORDINATOR: HassKey[HassioStatsDataUpdateCoordinator] = HassKey(
|
||||
"hassio_stats_coordinator"
|
||||
)
|
||||
|
||||
|
||||
DATA_COMPONENT: HassKey[HassIO] = HassKey(DOMAIN)
|
||||
DATA_CONFIG_STORE: HassKey[HassioConfig] = HassKey("hassio_config_store")
|
||||
DATA_CORE_INFO = "hassio_core_info"
|
||||
DATA_CORE_INFO: HassKey[HomeAssistantInfo] = HassKey("hassio_core_info")
|
||||
DATA_CORE_STATS = "hassio_core_stats"
|
||||
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_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_SUPERVISOR_STATS = "hassio_supervisor_stats"
|
||||
DATA_ADDONS_INFO = "hassio_addons_info"
|
||||
DATA_ADDONS_STATS = "hassio_addons_stats"
|
||||
DATA_ADDONS_LIST = "hassio_addons_list"
|
||||
DATA_ADDONS_LIST: HassKey[list[InstalledAddon]] = HassKey("hassio_addons_list")
|
||||
HASSIO_MAIN_UPDATE_INTERVAL = timedelta(minutes=5)
|
||||
HASSIO_ADDON_UPDATE_INTERVAL = timedelta(minutes=15)
|
||||
HASSIO_STATS_UPDATE_INTERVAL = timedelta(seconds=60)
|
||||
@@ -118,7 +141,7 @@ DATA_KEY_OS = "os"
|
||||
DATA_KEY_SUPERVISOR = "supervisor"
|
||||
DATA_KEY_CORE = "core"
|
||||
DATA_KEY_HOST = "host"
|
||||
DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues"
|
||||
DATA_KEY_SUPERVISOR_ISSUES: HassKey[SupervisorIssues] = HassKey("supervisor_issues")
|
||||
DATA_KEY_MOUNTS = "mounts"
|
||||
|
||||
PLACEHOLDER_KEY_ADDON = "addon"
|
||||
|
||||
@@ -7,16 +7,22 @@ from collections import defaultdict
|
||||
from collections.abc import Awaitable
|
||||
from copy import deepcopy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
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
|
||||
@@ -25,15 +31,21 @@ 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,
|
||||
@@ -56,11 +68,15 @@ 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
|
||||
@@ -78,7 +94,8 @@ def get_info(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data.get(DATA_INFO)
|
||||
info = hass.data.get(DATA_INFO)
|
||||
return info.to_dict() if info is not None else None
|
||||
|
||||
|
||||
@callback
|
||||
@@ -87,7 +104,8 @@ def get_host_info(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data.get(DATA_HOST_INFO)
|
||||
info = hass.data.get(DATA_HOST_INFO)
|
||||
return info.to_dict() if info is not None else None
|
||||
|
||||
|
||||
@callback
|
||||
@@ -96,7 +114,8 @@ def get_store(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data.get(DATA_STORE)
|
||||
info = hass.data.get(DATA_STORE)
|
||||
return info.to_dict() if info is not None else None
|
||||
|
||||
|
||||
@callback
|
||||
@@ -105,7 +124,17 @@ def get_supervisor_info(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data.get(DATA_SUPERVISOR_INFO)
|
||||
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
|
||||
|
||||
|
||||
@callback
|
||||
@@ -114,7 +143,8 @@ def get_network_info(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data.get(DATA_NETWORK_INFO)
|
||||
info = hass.data.get(DATA_NETWORK_INFO)
|
||||
return info.to_dict() if info is not None else None
|
||||
|
||||
|
||||
@callback
|
||||
@@ -132,7 +162,8 @@ def get_addons_list(hass: HomeAssistant) -> list[dict[str, Any]] | None:
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data.get(DATA_ADDONS_LIST)
|
||||
addons = hass.data.get(DATA_ADDONS_LIST)
|
||||
return [addon.to_dict() for addon in addons] if addons is not None else None
|
||||
|
||||
|
||||
@callback
|
||||
@@ -168,7 +199,8 @@ def get_os_info(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data.get(DATA_OS_INFO)
|
||||
info = hass.data.get(DATA_OS_INFO)
|
||||
return info.to_dict() if info is not None else None
|
||||
|
||||
|
||||
@callback
|
||||
@@ -177,7 +209,8 @@ def get_core_info(hass: HomeAssistant) -> dict[str, Any] | None:
|
||||
|
||||
Async friendly.
|
||||
"""
|
||||
return hass.data.get(DATA_CORE_INFO)
|
||||
info = hass.data.get(DATA_CORE_INFO)
|
||||
return info.to_dict() if info is not None else None
|
||||
|
||||
|
||||
@callback
|
||||
@@ -359,11 +392,11 @@ class HassioStatsDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
data[key] = result.to_dict()
|
||||
|
||||
# Fetch addon stats
|
||||
addons_list = get_addons_list(self.hass) or []
|
||||
addons_list: list[InstalledAddon] = self.hass.data.get(DATA_ADDONS_LIST) or []
|
||||
started_addons = {
|
||||
addon[ATTR_SLUG]
|
||||
addon.slug
|
||||
for addon in addons_list
|
||||
if addon.get("state") in {AddonState.STARTED, AddonState.STARTUP}
|
||||
if addon.state in {AddonState.STARTED, AddonState.STARTUP}
|
||||
}
|
||||
|
||||
addons_stats: dict[str, Any] = data.setdefault(DATA_ADDONS_STATS, {})
|
||||
@@ -469,31 +502,24 @@ class HassioAddOnDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
raise UpdateFailed(f"Error on Supervisor API: {err}") from err
|
||||
|
||||
# Update hass.data for legacy accessor functions
|
||||
data = self.hass.data
|
||||
addons_list_dicts = [addon.to_dict() for addon in installed_addons]
|
||||
data[DATA_ADDONS_LIST] = addons_list_dicts
|
||||
self.hass.data[DATA_ADDONS_LIST] = installed_addons
|
||||
|
||||
# Update addon info cache in hass.data
|
||||
addon_info_cache: dict[str, Any] = data.setdefault(DATA_ADDONS_INFO, {})
|
||||
addon_info_cache: dict[str, Any] = self.hass.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_data = get_store(self.hass)
|
||||
if store_data:
|
||||
repositories = {
|
||||
repo.slug: repo.name
|
||||
for repo in StoreInfo.from_dict(store_data).repositories
|
||||
}
|
||||
store = self.hass.data.get(DATA_STORE)
|
||||
if store:
|
||||
repositories = {repo.slug: repo.name for repo in store.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]): {
|
||||
@@ -635,9 +661,25 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
)
|
||||
self.entry_id = config_entry.entry_id
|
||||
self.dev_reg = dev_reg
|
||||
self.is_hass_os = (get_info(self.hass) or {}).get("hassos") is not None
|
||||
if info := self.hass.data.get(DATA_INFO):
|
||||
self.is_hass_os = info.hassos is not None
|
||||
else:
|
||||
self.is_hass_os = False
|
||||
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."""
|
||||
@@ -645,6 +687,9 @@ 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,
|
||||
@@ -653,14 +698,25 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
host_info,
|
||||
store_info,
|
||||
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(),
|
||||
) = 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(),
|
||||
),
|
||||
)
|
||||
mounts_info = await client.mounts.info()
|
||||
await self.jobs.refresh_data(is_first_update)
|
||||
@@ -677,23 +733,13 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
new_data[DATA_KEY_OS] = os_info.to_dict()
|
||||
|
||||
# Update hass.data for legacy accessor functions
|
||||
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
|
||||
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
|
||||
|
||||
# If this is the initial refresh, register all main components
|
||||
if is_first_update:
|
||||
@@ -773,4 +819,5 @@ class HassioMainDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
@callback
|
||||
def unload(self) -> None:
|
||||
"""Clean up when config entry unloaded."""
|
||||
self._dispatcher_disconnect()
|
||||
self.jobs.unload()
|
||||
|
||||
@@ -229,10 +229,29 @@ 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.
|
||||
|
||||
_attr_supported_features = UpdateEntityFeature.INSTALL
|
||||
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_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:
|
||||
@@ -266,13 +285,58 @@ 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,8 +16,13 @@ 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
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.helpers import discovery_flow
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
@@ -26,6 +31,7 @@ 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,
|
||||
@@ -80,6 +86,20 @@ 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"],
|
||||
"dependencies": ["hardware", "homeassistant_hardware", "usb"],
|
||||
"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),
|
||||
"is_on": make_entity_state_condition(DOMAIN, STATE_ON),
|
||||
"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_drying": make_entity_state_condition(
|
||||
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
|
||||
),
|
||||
|
||||
@@ -13,6 +13,16 @@
|
||||
- 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: "%"
|
||||
@@ -27,8 +37,8 @@
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
|
||||
is_off: *condition_common
|
||||
is_on: *condition_common
|
||||
is_off: *condition_common_for
|
||||
is_on: *condition_common_for
|
||||
is_drying: *condition_common
|
||||
is_humidifying: *condition_common
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"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"
|
||||
@@ -42,6 +43,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::humidifier::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::humidifier::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Humidifier is off"
|
||||
@@ -51,6 +55,9 @@
|
||||
"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,12 +1,14 @@
|
||||
{
|
||||
"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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
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,6 +1,8 @@
|
||||
{
|
||||
"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": {
|
||||
@@ -13,16 +15,24 @@
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Please enter the username and password for your iAqualink account.",
|
||||
"title": "Reauthenticate iAqualink"
|
||||
"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"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"description": "Please enter the username and password for your iAqualink account.",
|
||||
"title": "Connect to iAqualink"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
ILLUMINANCE_DETECTED_DOMAIN_SPECS, STATE_ON, support_duration=True
|
||||
),
|
||||
"is_not_detected": make_entity_state_condition(
|
||||
ILLUMINANCE_DETECTED_DOMAIN_SPECS, STATE_OFF
|
||||
ILLUMINANCE_DETECTED_DOMAIN_SPECS, STATE_OFF, support_duration=True
|
||||
),
|
||||
"is_value": make_entity_numerical_condition(
|
||||
ILLUMINANCE_VALUE_DOMAIN_SPECS, LIGHT_LUX
|
||||
|
||||
@@ -13,6 +13,11 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
for:
|
||||
required: true
|
||||
default: 00:00:00
|
||||
selector:
|
||||
duration:
|
||||
|
||||
is_detected: *detected_condition_common
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"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",
|
||||
@@ -12,6 +13,9 @@
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"name": "[%key:component::illuminance::common::condition_behavior_name%]"
|
||||
},
|
||||
"for": {
|
||||
"name": "[%key:component::illuminance::common::condition_for_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Light is detected"
|
||||
@@ -21,6 +25,9 @@
|
||||
"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
Reference in New Issue
Block a user