Compare commits

..

89 Commits

Author SHA1 Message Date
Michael
08dabb8f6d Merge branch 'dev' into number/add-number.changed-trigger 2026-02-25 15:42:51 +01:00
Joost Lekkerkerker
70f5f2c1ee Add binary sensor platform to Zinvolt (#164050) 2026-02-25 15:38:53 +01:00
Robin Lintermann
c5b31d6782 Add Update Platform to Smarla Integration (#163255) 2026-02-25 15:36:48 +01:00
Joost Lekkerkerker
925bcea1c0 Add number platform to Zinvolt (#164058) 2026-02-25 15:30:45 +01:00
Manu
01f0e4fe48 Add update platform to ntfy integration (#164018)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 15:28:47 +01:00
mettolen
f9a61e5412 Mark docs-examples done for Liebherr integration (#163034) 2026-02-25 15:26:08 +01:00
Andreas Jakl
caf40f9d25 Add diagnostics to NRGkick integration (#164047) 2026-02-25 15:20:34 +01:00
Manu
89c5511558 Improve configuration url in Uptime Kuma (#164057)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-25 15:02:05 +01:00
Joost Lekkerkerker
fc79e0cbfa Bump zinvolt to 0.3.0 (#164046) 2026-02-25 14:56:21 +01:00
Thomas D
317f95ff0f Add a service to retrieve images for the Volvo integration (#159603)
Co-authored-by: Josef Zweck <josef@zweck.dev>
2026-02-25 14:41:03 +01:00
Manu
0cb34d2888 Categorize update entity as diagnostic in Uptime Kuma (#164022) 2026-02-25 14:14:03 +01:00
Manu
b8df61fc5f Categorize update entity as diagnostic in IronOS integration (#164023) 2026-02-25 14:13:40 +01:00
epenet
44a4be012d Use constants in counter tests (#164020) 2026-02-25 14:13:24 +01:00
Joost Lekkerkerker
8dcaed62b5 Add base entity to Zinvolt (#164051) 2026-02-25 14:12:32 +01:00
epenet
195e55097b Drop single-use service name constants in Renault (#164043) 2026-02-25 13:16:20 +01:00
Tom Quist
910f501194 Fix ingress compression breaking SSE and streaming responses (#160704) 2026-02-25 12:58:12 +01:00
kang
f0edfbf053 Enrich DeviceInfo with meter metadata in route_b_smart_meter (#164006)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-02-25 11:49:52 +01:00
epenet
834227a762 Use constants in calendar test (#164021) 2026-02-25 10:51:58 +01:00
Ludovic BOUÉ
3426846361 Add CLEAN_AREA feature to Matter vacuum entity (#163570)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
Co-authored-by: Artur Pragacz <artur@pragacz.com>
2026-02-25 10:47:47 +01:00
Artur Pragacz
50f39621e9 Add vacuum area mapping not configured issue (#163965) 2026-02-25 10:45:44 +01:00
epenet
dc133bf7cc Move Tuya helpers to external library (#158791) 2026-02-25 10:35:12 +01:00
TheJulianJES
3219417a7d Bump ZHA to 1.0.0 (#164013) 2026-02-25 09:51:30 +01:00
Joost Lekkerkerker
9a23a518ed Add integration_type device to ws66i (#163987) 2026-02-25 09:50:16 +01:00
Joost Lekkerkerker
7e62852723 Add integration_type hub to watts (#163973) 2026-02-25 09:45:33 +01:00
Zhephyr
0a1027391f Add pet last seen flap device id and user id sensors to Sure Petcare (#160215) 2026-02-25 08:59:22 +01:00
Allen Porter
7644fc4325 Update MCP client integration to use new OAuth spec (#161611)
Co-authored-by: Robert Resch <robert@resch.dev>
2026-02-24 23:18:25 -08:00
Yangqian Yan
2f80720730 Add Full support for roborock Zeo washing/drying machines (#159575)
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-24 23:17:56 -08:00
Joost Lekkerkerker
644c74f311 Add integration_type hub to zwave_me (#164000) 2026-02-25 07:29:36 +01:00
Joost Lekkerkerker
29370add66 Add integration_type service to zamg (#163997) 2026-02-25 07:28:32 +01:00
Joost Lekkerkerker
fc4680ad86 Add integration_type device to youless (#163996) 2026-02-25 07:28:11 +01:00
Joost Lekkerkerker
174076ba76 Add integration_type hub to yolink (#163995) 2026-02-25 07:27:37 +01:00
Joost Lekkerkerker
f3590bd9cf Add integration_type device to yeelight (#163994) 2026-02-25 07:27:08 +01:00
Joost Lekkerkerker
ae7f71219f Add integration_type device to yardian (#163993) 2026-02-25 07:26:27 +01:00
Joost Lekkerkerker
e1529620db Add integration_type hub to yale (#163989) 2026-02-25 07:25:20 +01:00
Joost Lekkerkerker
9a56d30924 Add integration_type hub to yale_smart_alarm (#163990) 2026-02-25 07:24:54 +01:00
Joost Lekkerkerker
d6df2b3c4c Add integration_type device to yamaha_musiccast (#163992) 2026-02-25 07:24:28 +01:00
Joost Lekkerkerker
9740dc65aa Add integration_type device to yalexs_ble (#163991) 2026-02-25 07:23:47 +01:00
Joost Lekkerkerker
b914971531 Add integration_type device to wolflink (#163982) 2026-02-25 07:23:14 +01:00
Joost Lekkerkerker
9007c65b50 Add integration_type hub to wilight (#163979) 2026-02-25 07:22:35 +01:00
Joost Lekkerkerker
a4a2847b03 Add integration_type hub to weheat (#163977) 2026-02-25 07:21:04 +01:00
Joost Lekkerkerker
9a11db2ad5 Add integration_type service to weatherkit (#163976) 2026-02-25 07:20:32 +01:00
Joost Lekkerkerker
2d445f8f53 Add integration_type hub to weatherflow_cloud (#163975) 2026-02-25 07:20:06 +01:00
Joost Lekkerkerker
f07c386529 Add integration_type device to watergate (#163972) 2026-02-25 07:18:54 +01:00
Joost Lekkerkerker
3cd79581dc Add integration_type hub to zimi (#163999) 2026-02-25 07:07:50 +01:00
Joost Lekkerkerker
e82df86dda Add integration_type hub to xiaomi_aqara (#163988) 2026-02-25 07:07:25 +01:00
Joost Lekkerkerker
1629d2b204 Add integration_type service to worldclock (#163986) 2026-02-25 07:07:05 +01:00
Joost Lekkerkerker
a6e60d8b73 Add integration_type hub to withings (#163980) 2026-02-25 07:06:45 +01:00
Joost Lekkerkerker
ef6650548e Add integration_type service to waze_travel_time (#163974) 2026-02-25 07:06:14 +01:00
Manu
52a2e94fc4 Bump aiontfy to 0.8.1 (#164010) 2026-02-25 07:05:36 +01:00
Klaas Schoute
6bba7e7583 Bump powerfox to v2.1.1 (#164004) 2026-02-25 02:14:27 +01:00
MizterB
58e8a8d398 Ecobee username/password authentication (#161716)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-25 01:41:36 +01:00
Klaas Schoute
6b0303a1ef Set quality scale to platinum for Powerfox Local integration (#164003) 2026-02-25 01:22:27 +01:00
Klaas Schoute
249e6c2f3d Add reconfiguration flow for Powerfox Local integration (#164002) 2026-02-25 01:05:30 +01:00
Simone Chemelli
7ae0380b33 Update IQS to gold for UptimeRobot (#162926) 2026-02-25 01:05:17 +01:00
Tom
889faa5a5c Add v6 firmware support to airOS (#163889)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-25 01:02:26 +01:00
Klaas Schoute
9b810c64d9 Add diagnostics support for Powerfox Local integration (#163985) 2026-02-25 00:24:33 +01:00
mib1185
6608db6098 fix test case naming 2026-02-24 23:10:48 +00:00
Joost Lekkerkerker
1e3bed9864 Add integration_type device to wiz (#163981) 2026-02-25 00:04:34 +01:00
mib1185
bfce7a6893 add number.changed trigger 2026-02-24 23:00:13 +00:00
Tom
eac3fb651e Update airOS quality_scale (#163895)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-24 23:47:18 +01:00
Karl Beecken
8b285239f0 Update Teltonika IQS to silver (#163943) 2026-02-24 23:37:46 +01:00
Andreas Jakl
d0a74ad539 Update quality scale to silver for nrgkick integration (#163964) 2026-02-24 23:35:06 +01:00
Andreas Jakl
0f071c1ae5 Fix accessing optional username and password for nrgkick integration (#163963) 2026-02-24 23:33:40 +01:00
mettolen
e671e4408b Implement dynamic devices for Liebherr integration (#163951)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-24 23:32:51 +01:00
Klaas Schoute
697441969b Add reauthentication flow for Powerfox Local integration (#163966) 2026-02-24 23:29:19 +01:00
nic
bc324a1a6e Add ZoneMinder integration test suite (#163115)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-24 23:27:13 +01:00
Robin Lintermann
e505ad9003 Update availability of entities when connection changes (#163252) 2026-02-24 23:25:57 +01:00
Jan Čermák
6a91771f04 Use native ARM runner for builder action, update to builder 2026.02.1 (#163942) 2026-02-24 23:14:50 +01:00
Christian Lackas
e7df4356f4 Fix HmIP-RGBW monochrome mode FEATURE_NOT_SUPPORTED error (#161917) 2026-02-24 22:38:47 +01:00
Luke Lashley
a41207d369 Implement changes for Clean area for Roborock. (#163956) 2026-02-24 22:34:56 +01:00
cdheiser
28e8d7c3eb Add tests to lutron (#162055)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-24 22:30:31 +01:00
mettolen
e514faf0bc Fix Saunum session parameters to use timedelta (#163962) 2026-02-24 22:14:09 +01:00
Erwin Douna
7894a80728 Proxmox separate errors and patch tests (#163922) 2026-02-24 22:08:50 +01:00
Przemko92
6751f6f4a2 Add sensor for compit integration (#161527) 2026-02-24 21:49:47 +01:00
Erwin Douna
ce0dd0eb7b Fix small typo in Portainer containers (#163957) 2026-02-24 21:45:34 +01:00
Erwin Douna
7cb595f768 Add sensor platform to Proxmox (#163404) 2026-02-24 21:45:07 +01:00
Kamil Breguła
dfbd4ffb2d Add diagnostics to met (#157805)
Co-authored-by: mik-laj <12058428+mik-laj@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-24 21:34:54 +01:00
Willem-Jan van Rootselaar
6abefc852d Add quality scale to bsblan integration (#146323)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-24 21:30:41 +01:00
konsulten
9ba28150e9 Add light platform to systemnexa2 (#163710)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-24 21:29:46 +01:00
Erwin Douna
adfe4f2b62 Add stack management to Portainer (#163612)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-02-24 21:28:16 +01:00
Christian Lackas
dc3dc116d2 Handle 403 authentication errors in HomematicIP Cloud (#162579) 2026-02-24 21:21:29 +01:00
Willem-Jan van Rootselaar
f16e7aaec4 bugfix tests to use model_validate_json for device time (#163950) 2026-02-24 21:20:03 +01:00
A. Gideonse
ea68152f32 Add select platform to Indevolt integration (#163955) 2026-02-24 21:18:43 +01:00
wollew
c75c9d9dd8 Add diagnostics to Velux integration (#163896) 2026-02-24 21:17:56 +01:00
rlippmann
4760f9b8eb Restart SimpliSafe websocket after request failures (#160974)
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-24 21:11:12 +01:00
Denis Shulyaka
9bb879e061 Fix API key check during config flow for openai_conversation (#163025) 2026-02-24 20:53:19 +01:00
Blake Messer
f2c87f96a2 Bump pyrainbird to 6.1.0 (#163919) 2026-02-24 20:48:33 +01:00
Denis Shulyaka
30fffafceb Add STT support for OpenAI (#162931)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-02-24 19:32:13 +01:00
Thomas55555
ff916a783b Disable seconds in Husqvarna Automower services (#163948) 2026-02-24 19:24:10 +01:00
338 changed files with 21396 additions and 3294 deletions

View File

@@ -272,7 +272,7 @@ jobs:
name: Build ${{ matrix.machine }} machine core image
if: github.repository_owner == 'home-assistant'
needs: ["init", "build_base"]
runs-on: ubuntu-latest
runs-on: ${{ matrix.runs-on }}
permissions:
contents: read # To check out the repository
packages: write # To push to GHCR
@@ -294,6 +294,21 @@ jobs:
- raspberrypi5-64
- yellow
- green
include:
# Default: aarch64 on native ARM runner
- arch: aarch64
runs-on: ubuntu-24.04-arm
# Overrides for amd64 machines
- machine: generic-x86-64
arch: amd64
runs-on: ubuntu-24.04
- machine: qemux86-64
arch: amd64
runs-on: ubuntu-24.04
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
- machine: intel-nuc
arch: amd64
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -321,8 +336,9 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build base image
uses: home-assistant/builder@2025.11.0 # zizmor: ignore[unpinned-uses]
uses: home-assistant/builder@6cb4fd3d1338b6e22d0958a4bcb53e0965ea63b4 # 2026.02.1
with:
image: ${{ matrix.arch }}
args: |
$BUILD_ARGS \
--target /data/machine \

1
CODEOWNERS generated
View File

@@ -1966,6 +1966,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/zone/ @home-assistant/core
/tests/components/zone/ @home-assistant/core
/homeassistant/components/zoneminder/ @rohankapoorcom @nabbi
/tests/components/zoneminder/ @rohankapoorcom @nabbi
/homeassistant/components/zwave_js/ @home-assistant/z-wave
/tests/components/zwave_js/ @home-assistant/z-wave
/homeassistant/components/zwave_me/ @lawfulchaos @Z-Wave-Me @PoltoS

View File

@@ -4,7 +4,16 @@ from __future__ import annotations
import logging
from airos.airos6 import AirOS6
from airos.airos8 import AirOS8
from airos.exceptions import (
AirOSConnectionAuthenticationError,
AirOSConnectionSetupError,
AirOSDataMissingError,
AirOSDeviceConnectionError,
AirOSKeyDataMissingError,
)
from airos.helpers import DetectDeviceData, async_get_firmware_data
from homeassistant.const import (
CONF_HOST,
@@ -15,6 +24,11 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
ConfigEntryNotReady,
)
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -39,15 +53,40 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL]
)
airos_device = AirOS8(
host=entry.data[CONF_HOST],
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
session=session,
use_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
conn_data = {
CONF_HOST: entry.data[CONF_HOST],
CONF_USERNAME: entry.data[CONF_USERNAME],
CONF_PASSWORD: entry.data[CONF_PASSWORD],
"use_ssl": entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
"session": session,
}
# Determine firmware version before creating the device instance
try:
device_data: DetectDeviceData = await async_get_firmware_data(**conn_data)
except (
AirOSConnectionSetupError,
AirOSDeviceConnectionError,
TimeoutError,
) as err:
raise ConfigEntryNotReady from err
except (
AirOSConnectionAuthenticationError,
AirOSDataMissingError,
) as err:
raise ConfigEntryAuthFailed from err
except AirOSKeyDataMissingError as err:
raise ConfigEntryError("key_data_missing") from err
except Exception as err:
raise ConfigEntryError("unknown") from err
airos_class: type[AirOS8 | AirOS6] = (
AirOS8 if device_data["fw_major"] == 8 else AirOS6
)
coordinator = AirOSDataUpdateCoordinator(hass, entry, airos_device)
airos_device = airos_class(**conn_data)
coordinator = AirOSDataUpdateCoordinator(hass, entry, device_data, airos_device)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

View File

@@ -4,7 +4,9 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Generic, TypeVar
from airos.data import AirOSDataBaseClass
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -18,25 +20,24 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import AirOS8Data, AirOSConfigEntry, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
@dataclass(frozen=True, kw_only=True)
class AirOSBinarySensorEntityDescription(BinarySensorEntityDescription):
class AirOSBinarySensorEntityDescription(
BinarySensorEntityDescription,
Generic[AirOSDataModel],
):
"""Describe an AirOS binary sensor."""
value_fn: Callable[[AirOS8Data], bool]
value_fn: Callable[[AirOSDataModel], bool]
BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
AirOSBinarySensorEntityDescription(
key="portfw",
translation_key="port_forwarding",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.portfw,
),
AirOS8BinarySensorEntityDescription = AirOSBinarySensorEntityDescription[AirOS8Data]
COMMON_BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
AirOSBinarySensorEntityDescription(
key="dhcp_client",
translation_key="dhcp_client",
@@ -52,14 +53,6 @@ BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
value_fn=lambda data: data.services.dhcpd,
entity_registry_enabled_default=False,
),
AirOSBinarySensorEntityDescription(
key="dhcp6_server",
translation_key="dhcp6_server",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.dhcp6d_stateful,
entity_registry_enabled_default=False,
),
AirOSBinarySensorEntityDescription(
key="pppoe",
translation_key="pppoe",
@@ -70,6 +63,23 @@ BINARY_SENSORS: tuple[AirOSBinarySensorEntityDescription, ...] = (
),
)
AIROS8_BINARY_SENSORS: tuple[AirOS8BinarySensorEntityDescription, ...] = (
AirOS8BinarySensorEntityDescription(
key="portfw",
translation_key="port_forwarding",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.portfw,
),
AirOS8BinarySensorEntityDescription(
key="dhcp6_server",
translation_key="dhcp6_server",
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.services.dhcp6d_stateful,
entity_registry_enabled_default=False,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -79,10 +89,20 @@ async def async_setup_entry(
"""Set up the AirOS binary sensors from a config entry."""
coordinator = config_entry.runtime_data
async_add_entities(
AirOSBinarySensor(coordinator, description) for description in BINARY_SENSORS
entities: list[BinarySensorEntity] = []
entities.extend(
AirOSBinarySensor(coordinator, description)
for description in COMMON_BINARY_SENSORS
)
if coordinator.device_data["fw_major"] == 8:
entities.extend(
AirOSBinarySensor(coordinator, description)
for description in AIROS8_BINARY_SENSORS
)
async_add_entities(entities)
class AirOSBinarySensor(AirOSEntity, BinarySensorEntity):
"""Representation of a binary sensor."""

View File

@@ -2,8 +2,6 @@
from __future__ import annotations
import logging
from airos.exceptions import AirOSException
from homeassistant.components.button import (
@@ -18,8 +16,6 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import DOMAIN, AirOSConfigEntry, AirOSDataUpdateCoordinator
from .entity import AirOSEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
REBOOT_BUTTON = ButtonEntityDescription(

View File

@@ -7,6 +7,8 @@ from collections.abc import Mapping
import logging
from typing import Any
from airos.airos6 import AirOS6
from airos.airos8 import AirOS8
from airos.discovery import airos_discover_devices
from airos.exceptions import (
AirOSConnectionAuthenticationError,
@@ -17,6 +19,7 @@ from airos.exceptions import (
AirOSKeyDataMissingError,
AirOSListenerError,
)
from airos.helpers import DetectDeviceData, async_get_firmware_data
import voluptuous as vol
from homeassistant.config_entries import (
@@ -53,10 +56,11 @@ from .const import (
MAC_ADDRESS,
SECTION_ADVANCED_SETTINGS,
)
from .coordinator import AirOS8
_LOGGER = logging.getLogger(__name__)
AirOSDeviceDetect = AirOS8 | AirOS6
# Discovery duration in seconds, airOS announces every 20 seconds
DISCOVER_INTERVAL: int = 30
@@ -92,7 +96,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self.airos_device: AirOS8
self.airos_device: AirOSDeviceDetect
self.errors: dict[str, str] = {}
self.discovered_devices: dict[str, dict[str, Any]] = {}
self.discovery_abort_reason: str | None = None
@@ -135,16 +139,14 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
)
airos_device = AirOS8(
host=config_data[CONF_HOST],
username=config_data[CONF_USERNAME],
password=config_data[CONF_PASSWORD],
session=session,
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
)
try:
await airos_device.login()
airos_data = await airos_device.status()
device_data: DetectDeviceData = await async_get_firmware_data(
host=config_data[CONF_HOST],
username=config_data[CONF_USERNAME],
password=config_data[CONF_PASSWORD],
session=session,
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
)
except (
AirOSConnectionSetupError,
@@ -159,14 +161,14 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.exception("Unexpected exception during credential validation")
self.errors["base"] = "unknown"
else:
await self.async_set_unique_id(airos_data.derived.mac)
await self.async_set_unique_id(device_data["mac"])
if self.source in [SOURCE_REAUTH, SOURCE_RECONFIGURE]:
self._abort_if_unique_id_mismatch()
else:
self._abort_if_unique_id_configured()
return {"title": airos_data.host.hostname, "data": config_data}
return {"title": device_data["hostname"], "data": config_data}
return None

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import logging
from airos.airos6 import AirOS6, AirOS6Data
from airos.airos8 import AirOS8, AirOS8Data
from airos.exceptions import (
AirOSConnectionAuthenticationError,
@@ -11,6 +12,7 @@ from airos.exceptions import (
AirOSDataMissingError,
AirOSDeviceConnectionError,
)
from airos.helpers import DetectDeviceData
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -21,19 +23,28 @@ from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
AirOSDeviceDetect = AirOS8 | AirOS6
AirOSDataDetect = AirOS8Data | AirOS6Data
type AirOSConfigEntry = ConfigEntry[AirOSDataUpdateCoordinator]
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOSDataDetect]):
"""Class to manage fetching AirOS data from single endpoint."""
airos_device: AirOSDeviceDetect
config_entry: AirOSConfigEntry
def __init__(
self, hass: HomeAssistant, config_entry: AirOSConfigEntry, airos_device: AirOS8
self,
hass: HomeAssistant,
config_entry: AirOSConfigEntry,
device_data: DetectDeviceData,
airos_device: AirOSDeviceDetect,
) -> None:
"""Initialize the coordinator."""
self.airos_device = airos_device
self.device_data = device_data
super().__init__(
hass,
_LOGGER,
@@ -42,7 +53,7 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
update_interval=SCAN_INTERVAL,
)
async def _async_update_data(self) -> AirOS8Data:
async def _async_update_data(self) -> AirOSDataDetect:
"""Fetch data from AirOS."""
try:
await self.airos_device.login()
@@ -62,7 +73,7 @@ class AirOSDataUpdateCoordinator(DataUpdateCoordinator[AirOS8Data]):
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
except (AirOSDataMissingError,) as err:
except AirOSDataMissingError as err:
_LOGGER.error("Expected data not returned by airOS device: %s", err)
raise UpdateFailed(
translation_domain=DOMAIN,

View File

@@ -7,6 +7,6 @@
"documentation": "https://www.home-assistant.io/integrations/airos",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "silver",
"quality_scale": "platinum",
"requirements": ["airos==0.6.4"]
}

View File

@@ -42,16 +42,20 @@ rules:
# Gold
devices: done
diagnostics: done
discovery-update-info: todo
discovery: todo
discovery-update-info: done
discovery:
status: exempt
comment: No way to detect device on the network
docs-data-update: done
docs-examples: todo
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
dynamic-devices:
status: exempt
comment: single airOS device per config entry; peer/remote endpoints are not modeled as child devices/entities at this time
entity-category: done
entity-device-class: done
entity-disabled-by-default: done
@@ -61,8 +65,10 @@ rules:
status: exempt
comment: no (custom) icons used or envisioned
reconfiguration-flow: done
repair-issues: todo
stale-devices: todo
repair-issues: done
stale-devices:
status: exempt
comment: single airOS device per config entry; peer/remote endpoints are not modeled as child devices/entities at this time
# Platinum
async-dependency: done

View File

@@ -5,8 +5,14 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Generic, TypeVar
from airos.data import DerivedWirelessMode, DerivedWirelessRole, NetRole
from airos.data import (
AirOSDataBaseClass,
DerivedWirelessMode,
DerivedWirelessRole,
NetRole,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -37,15 +43,19 @@ WIRELESS_ROLE_OPTIONS = [mode.value for mode in DerivedWirelessRole]
PARALLEL_UPDATES = 0
AirOSDataModel = TypeVar("AirOSDataModel", bound=AirOSDataBaseClass)
@dataclass(frozen=True, kw_only=True)
class AirOSSensorEntityDescription(SensorEntityDescription):
class AirOSSensorEntityDescription(SensorEntityDescription, Generic[AirOSDataModel]):
"""Describe an AirOS sensor."""
value_fn: Callable[[AirOS8Data], StateType]
value_fn: Callable[[AirOSDataModel], StateType]
SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
AirOS8SensorEntityDescription = AirOSSensorEntityDescription[AirOS8Data]
COMMON_SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
AirOSSensorEntityDescription(
key="host_cpuload",
translation_key="host_cpuload",
@@ -75,54 +85,6 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
translation_key="wireless_essid",
value_fn=lambda data: data.wireless.essid,
),
AirOSSensorEntityDescription(
key="wireless_antenna_gain",
translation_key="wireless_antenna_gain",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.antenna_gain,
),
AirOSSensorEntityDescription(
key="wireless_throughput_tx",
translation_key="wireless_throughput_tx",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.throughput.tx,
),
AirOSSensorEntityDescription(
key="wireless_throughput_rx",
translation_key="wireless_throughput_rx",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.throughput.rx,
),
AirOSSensorEntityDescription(
key="wireless_polling_dl_capacity",
translation_key="wireless_polling_dl_capacity",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.dl_capacity,
),
AirOSSensorEntityDescription(
key="wireless_polling_ul_capacity",
translation_key="wireless_polling_ul_capacity",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.ul_capacity,
),
AirOSSensorEntityDescription(
key="host_uptime",
translation_key="host_uptime",
@@ -158,6 +120,57 @@ SENSORS: tuple[AirOSSensorEntityDescription, ...] = (
options=WIRELESS_ROLE_OPTIONS,
entity_registry_enabled_default=False,
),
AirOSSensorEntityDescription(
key="wireless_antenna_gain",
translation_key="wireless_antenna_gain",
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda data: data.wireless.antenna_gain,
),
AirOSSensorEntityDescription(
key="wireless_polling_dl_capacity",
translation_key="wireless_polling_dl_capacity",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.dl_capacity,
),
AirOSSensorEntityDescription(
key="wireless_polling_ul_capacity",
translation_key="wireless_polling_ul_capacity",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.polling.ul_capacity,
),
)
AIROS8_SENSORS: tuple[AirOS8SensorEntityDescription, ...] = (
AirOS8SensorEntityDescription(
key="wireless_throughput_tx",
translation_key="wireless_throughput_tx",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.throughput.tx,
),
AirOS8SensorEntityDescription(
key="wireless_throughput_rx",
translation_key="wireless_throughput_rx",
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
device_class=SensorDeviceClass.DATA_RATE,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=0,
suggested_unit_of_measurement=UnitOfDataRate.MEGABITS_PER_SECOND,
value_fn=lambda data: data.wireless.throughput.rx,
),
)
@@ -169,7 +182,14 @@ async def async_setup_entry(
"""Set up the AirOS sensors from a config entry."""
coordinator = config_entry.runtime_data
async_add_entities(AirOSSensor(coordinator, description) for description in SENSORS)
async_add_entities(
AirOSSensor(coordinator, description) for description in COMMON_SENSORS
)
if coordinator.device_data["fw_major"] == 8:
async_add_entities(
AirOSSensor(coordinator, description) for description in AIROS8_SENSORS
)
class AirOSSensor(AirOSEntity, SensorEntity):

View File

@@ -148,6 +148,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"light",
"lock",
"media_player",
"number",
"person",
"scene",
"siren",

View File

@@ -7,6 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bsblan"],
"quality_scale": "silver",
"requirements": ["python-bsblan==5.0.1"],
"zeroconf": [
{

View File

@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
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: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
This integration has a fixed single device.
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
This integration provides a limited number of entities, all of which are useful to users.
entity-translations: done
exception-translations: done
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration doesn't have any cases where raising an issue is needed.
stale-devices:
status: exempt
comment: |
This integration has a fixed single device.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -14,6 +14,7 @@ PLATFORMS = [
Platform.CLIMATE,
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.WATER_HEATER,
]

View File

@@ -158,6 +158,119 @@
"winter": "mdi:snowflake"
}
}
},
"sensor": {
"alarm_code": {
"default": "mdi:alert-circle",
"state": {
"no_alarm": "mdi:check-circle"
}
},
"battery_level": {
"default": "mdi:battery"
},
"boiler_temperature": {
"default": "mdi:thermometer"
},
"calculated_heating_temperature": {
"default": "mdi:thermometer"
},
"calculated_target_temperature": {
"default": "mdi:thermometer"
},
"charging_power": {
"default": "mdi:flash"
},
"circuit_target_temperature": {
"default": "mdi:thermometer"
},
"co2_percent": {
"default": "mdi:molecule-co2"
},
"collector_power": {
"default": "mdi:solar-power"
},
"collector_temperature": {
"default": "mdi:thermometer"
},
"dhw_measured_temperature": {
"default": "mdi:thermometer"
},
"energy_consumption": {
"default": "mdi:lightning-bolt"
},
"energy_smart_grid_yesterday": {
"default": "mdi:lightning-bolt"
},
"energy_today": {
"default": "mdi:lightning-bolt"
},
"energy_total": {
"default": "mdi:lightning-bolt"
},
"energy_yesterday": {
"default": "mdi:lightning-bolt"
},
"fuel_level": {
"default": "mdi:gauge"
},
"humidity": {
"default": "mdi:water-percent"
},
"mixer_temperature": {
"default": "mdi:thermometer"
},
"outdoor_temperature": {
"default": "mdi:thermometer"
},
"pk1_function": {
"default": "mdi:cog",
"state": {
"cooling": "mdi:snowflake-thermometer",
"off": "mdi:cog-off",
"summer": "mdi:weather-sunny",
"winter": "mdi:snowflake"
}
},
"pm10_level": {
"default": "mdi:air-filter",
"state": {
"exceeded": "mdi:alert",
"no_sensor": "mdi:cancel",
"normal": "mdi:air-filter",
"warning": "mdi:alert-circle-outline"
}
},
"pm25_level": {
"default": "mdi:air-filter",
"state": {
"exceeded": "mdi:alert",
"no_sensor": "mdi:cancel",
"normal": "mdi:air-filter",
"warning": "mdi:alert-circle-outline"
}
},
"return_circuit_temperature": {
"default": "mdi:thermometer"
},
"tank_temperature_t2": {
"default": "mdi:thermometer"
},
"tank_temperature_t3": {
"default": "mdi:thermometer"
},
"tank_temperature_t4": {
"default": "mdi:thermometer"
},
"target_heating_temperature": {
"default": "mdi:thermometer"
},
"ventilation_alarm": {
"default": "mdi:alert",
"state": {
"no_alarm": "mdi:check-circle"
}
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -203,6 +203,219 @@
"winter": "Winter"
}
}
},
"sensor": {
"actual_buffer_temp": {
"name": "Actual buffer temperature"
},
"actual_dhw_temp": {
"name": "Actual DHW temperature"
},
"actual_hc_temperature_zone": {
"name": "Actual heating circuit {zone} temperature"
},
"actual_upper_source_temp": {
"name": "Actual upper source temperature"
},
"alarm_code": {
"name": "Alarm code",
"state": {
"battery_fault": "Battery fault",
"damaged_outdoor_temp": "Damaged outdoor temperature sensor",
"damaged_return_temp": "Damaged return temperature sensor",
"discharged_battery": "Discharged battery",
"internal_af": "Internal fault",
"low_battery_level": "Low battery level",
"no_alarm": "No alarm",
"no_battery": "No battery",
"no_power": "No power",
"no_pump": "No pump",
"pump_fault": "Pump fault"
}
},
"battery_level": {
"name": "Battery level"
},
"boiler_temperature": {
"name": "Boiler temperature"
},
"buffer_return_temperature": {
"name": "Buffer return temperature"
},
"buffer_set_temperature": {
"name": "Buffer set temperature"
},
"calculated_buffer_temp": {
"name": "Calculated buffer temperature"
},
"calculated_dhw_temp": {
"name": "Calculated DHW temperature"
},
"calculated_heating_temperature": {
"name": "Calculated heating temperature"
},
"calculated_target_temperature": {
"name": "Calculated target temperature"
},
"calculated_upper_source_temp": {
"name": "Calculated upper source temperature"
},
"charging_power": {
"name": "Charging power"
},
"circuit_target_temperature": {
"name": "Circuit target temperature"
},
"co2_percent": {
"name": "CO2 percent"
},
"collector_power": {
"name": "Collector power"
},
"collector_temperature": {
"name": "Collector temperature"
},
"dhw_measured_temperature": {
"name": "DHW measured temperature"
},
"dhw_temperature": {
"name": "DHW temperature"
},
"energy_consumption": {
"name": "Energy consumption"
},
"energy_smart_grid_yesterday": {
"name": "Energy smart grid yesterday"
},
"energy_today": {
"name": "Energy today"
},
"energy_total": {
"name": "Energy total"
},
"energy_yesterday": {
"name": "Energy yesterday"
},
"fuel_level": {
"name": "Fuel level"
},
"heating_target_temperature_zone": {
"name": "Heating circuit {zone} target temperature"
},
"lower_source_temperature": {
"name": "Lower source temperature"
},
"mixer_temperature": {
"name": "Mixer temperature"
},
"mixer_temperature_zone": {
"name": "Mixer {zone} temperature"
},
"outdoor_temperature": {
"name": "Outdoor temperature"
},
"pk1_function": {
"name": "PK1 function",
"state": {
"cooling": "Cooling",
"holiday": "Holiday",
"nano_nr_1": "Nano 1",
"nano_nr_2": "Nano 2",
"nano_nr_3": "Nano 3",
"nano_nr_4": "Nano 4",
"nano_nr_5": "Nano 5",
"off": "Off",
"on": "On",
"summer": "Summer",
"winter": "Winter"
}
},
"pm10_level": {
"name": "PM10 level",
"state": {
"exceeded": "Exceeded",
"no_sensor": "No sensor",
"normal": "Normal",
"warning": "Warning"
}
},
"pm1_level": {
"name": "PM1 level"
},
"pm25_level": {
"name": "PM2.5 level",
"state": {
"exceeded": "Exceeded",
"no_sensor": "No sensor",
"normal": "Normal",
"warning": "Warning"
}
},
"pm4_level": {
"name": "PM4 level"
},
"preset_mode": {
"name": "Preset mode"
},
"protection_temperature": {
"name": "Protection temperature"
},
"pump_status": {
"name": "Pump status",
"state": {
"off": "Off",
"on": "On"
}
},
"return_circuit_temperature": {
"name": "Return circuit temperature"
},
"set_target_temperature": {
"name": "Set target temperature"
},
"tank_temperature_t2": {
"name": "Tank T2 bottom temperature"
},
"tank_temperature_t3": {
"name": "Tank T3 top temperature"
},
"tank_temperature_t4": {
"name": "Tank T4 temperature"
},
"target_heating_temperature": {
"name": "Target heating temperature"
},
"target_temperature": {
"name": "Target temperature"
},
"temperature_alert": {
"name": "Temperature alert",
"state": {
"alert": "Alert",
"no_alert": "No alert"
}
},
"upper_source_temperature": {
"name": "Upper source temperature"
},
"ventilation_alarm": {
"name": "Ventilation alarm",
"state": {
"ahu_alarm": "AHU alarm",
"bot_alarm": "BOT alarm",
"damaged_exhaust_sensor": "Damaged exhaust sensor",
"damaged_preheater_sensor": "Damaged preheater sensor",
"damaged_supply_and_exhaust_sensors": "Damaged supply and exhaust sensors",
"damaged_supply_sensor": "Damaged supply sensor",
"no_alarm": "No alarm"
}
},
"ventilation_gear": {
"name": "Ventilation gear"
},
"weather_curve": {
"name": "Weather curve"
}
}
}
}

View File

@@ -2,10 +2,17 @@
from datetime import timedelta
from pyecobee import ECOBEE_API_KEY, ECOBEE_REFRESH_TOKEN, Ecobee, ExpiredTokenError
from pyecobee import (
ECOBEE_API_KEY,
ECOBEE_PASSWORD,
ECOBEE_REFRESH_TOKEN,
ECOBEE_USERNAME,
Ecobee,
ExpiredTokenError,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.util import Throttle
@@ -18,10 +25,19 @@ type EcobeeConfigEntry = ConfigEntry[EcobeeData]
async def async_setup_entry(hass: HomeAssistant, entry: EcobeeConfigEntry) -> bool:
"""Set up ecobee via a config entry."""
api_key = entry.data[CONF_API_KEY]
api_key = entry.data.get(CONF_API_KEY)
username = entry.data.get(CONF_USERNAME)
password = entry.data.get(CONF_PASSWORD)
refresh_token = entry.data[CONF_REFRESH_TOKEN]
runtime_data = EcobeeData(hass, entry, api_key=api_key, refresh_token=refresh_token)
runtime_data = EcobeeData(
hass,
entry,
api_key=api_key,
username=username,
password=password,
refresh_token=refresh_token,
)
if not await runtime_data.refresh():
return False
@@ -46,14 +62,32 @@ class EcobeeData:
"""
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, api_key: str, refresh_token: str
self,
hass: HomeAssistant,
entry: ConfigEntry,
api_key: str | None = None,
username: str | None = None,
password: str | None = None,
refresh_token: str | None = None,
) -> None:
"""Initialize the Ecobee data object."""
self._hass = hass
self.entry = entry
self.ecobee = Ecobee(
config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token}
)
if api_key:
self.ecobee = Ecobee(
config={ECOBEE_API_KEY: api_key, ECOBEE_REFRESH_TOKEN: refresh_token}
)
elif username and password:
self.ecobee = Ecobee(
config={
ECOBEE_USERNAME: username,
ECOBEE_PASSWORD: password,
ECOBEE_REFRESH_TOKEN: refresh_token,
}
)
else:
raise ValueError("No ecobee credentials provided")
@Throttle(MIN_TIME_BETWEEN_UPDATES)
async def update(self):
@@ -69,12 +103,23 @@ class EcobeeData:
"""Refresh ecobee tokens and update config entry."""
_LOGGER.debug("Refreshing ecobee tokens and updating config entry")
if await self._hass.async_add_executor_job(self.ecobee.refresh_tokens):
self._hass.config_entries.async_update_entry(
self.entry,
data={
data = {}
if self.ecobee.config.get(ECOBEE_API_KEY):
data = {
CONF_API_KEY: self.ecobee.config[ECOBEE_API_KEY],
CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN],
},
}
elif self.ecobee.config.get(ECOBEE_USERNAME) and self.ecobee.config.get(
ECOBEE_PASSWORD
):
data = {
CONF_USERNAME: self.ecobee.config[ECOBEE_USERNAME],
CONF_PASSWORD: self.ecobee.config[ECOBEE_PASSWORD],
CONF_REFRESH_TOKEN: self.ecobee.config[ECOBEE_REFRESH_TOKEN],
}
self._hass.config_entries.async_update_entry(
self.entry,
data=data,
)
return True
_LOGGER.error("Error refreshing ecobee tokens")

View File

@@ -2,15 +2,21 @@
from typing import Any
from pyecobee import ECOBEE_API_KEY, Ecobee
from pyecobee import ECOBEE_API_KEY, ECOBEE_PASSWORD, ECOBEE_USERNAME, Ecobee
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_KEY
from homeassistant.const import CONF_API_KEY, CONF_PASSWORD, CONF_USERNAME
from .const import CONF_REFRESH_TOKEN, DOMAIN
_USER_SCHEMA = vol.Schema({vol.Required(CONF_API_KEY): str})
_USER_SCHEMA = vol.Schema(
{
vol.Optional(CONF_API_KEY): str,
vol.Optional(CONF_USERNAME): str,
vol.Optional(CONF_PASSWORD): str,
}
)
class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
@@ -27,13 +33,34 @@ class EcobeeFlowHandler(ConfigFlow, domain=DOMAIN):
errors = {}
if user_input is not None:
# Use the user-supplied API key to attempt to obtain a PIN from ecobee.
self._ecobee = Ecobee(config={ECOBEE_API_KEY: user_input[CONF_API_KEY]})
api_key = user_input.get(CONF_API_KEY)
username = user_input.get(CONF_USERNAME)
password = user_input.get(CONF_PASSWORD)
if await self.hass.async_add_executor_job(self._ecobee.request_pin):
# We have a PIN; move to the next step of the flow.
return await self.async_step_authorize()
errors["base"] = "pin_request_failed"
if api_key and not (username or password):
# Use the user-supplied API key to attempt to obtain a PIN from ecobee.
self._ecobee = Ecobee(config={ECOBEE_API_KEY: api_key})
if await self.hass.async_add_executor_job(self._ecobee.request_pin):
# We have a PIN; move to the next step of the flow.
return await self.async_step_authorize()
errors["base"] = "pin_request_failed"
elif username and password and not api_key:
self._ecobee = Ecobee(
config={
ECOBEE_USERNAME: username,
ECOBEE_PASSWORD: password,
}
)
if await self.hass.async_add_executor_job(self._ecobee.refresh_tokens):
config = {
CONF_USERNAME: username,
CONF_PASSWORD: password,
CONF_REFRESH_TOKEN: self._ecobee.refresh_token,
}
return self.async_create_entry(title=DOMAIN, data=config)
errors["base"] = "login_failed"
else:
errors["base"] = "invalid_auth"
return self.async_show_form(
step_id="user",

View File

@@ -4,6 +4,8 @@
"single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]"
},
"error": {
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"login_failed": "Error authenticating with ecobee; please verify your credentials are correct.",
"pin_request_failed": "Error requesting PIN from ecobee; please verify API key is correct.",
"token_request_failed": "Error requesting tokens from ecobee; please try again."
},

View File

@@ -266,6 +266,8 @@ def should_compress(content_type: str, path: str | None = None) -> bool:
"""Return if we should compress a response."""
if path is not None and NO_COMPRESS.match(path):
return False
if content_type.startswith("text/event-stream"):
return False
if content_type.startswith("image/"):
return "svg" in content_type
if content_type.startswith("application/"):

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
import voluptuous as vol
@@ -70,6 +71,11 @@ class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN):
authtoken = await self.auth.async_register()
if authtoken:
_LOGGER.debug("Write config entry for HomematicIP Cloud")
if self.source == "reauth":
return self.async_update_reload_and_abort(
self._get_reauth_entry(),
data_updates={HMIPC_AUTHTOKEN: authtoken},
)
return self.async_create_entry(
title=self.auth.config[HMIPC_HAPID],
data={
@@ -78,11 +84,50 @@ class HomematicipCloudFlowHandler(ConfigFlow, domain=DOMAIN):
HMIPC_NAME: self.auth.config.get(HMIPC_NAME),
},
)
return self.async_abort(reason="connection_aborted")
errors["base"] = "press_the_button"
if self.source == "reauth":
errors["base"] = "connection_aborted"
else:
return self.async_abort(reason="connection_aborted")
else:
errors["base"] = "press_the_button"
return self.async_show_form(step_id="link", errors=errors)
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reauthentication when the auth token becomes invalid."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirmation and start link process."""
errors = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
config = {
HMIPC_HAPID: reauth_entry.data[HMIPC_HAPID],
HMIPC_PIN: user_input.get(HMIPC_PIN),
HMIPC_NAME: reauth_entry.data.get(HMIPC_NAME),
}
self.auth = HomematicipAuth(self.hass, config)
connected = await self.auth.async_setup()
if connected:
return await self.async_step_link()
errors["base"] = "invalid_sgtin_or_pin"
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Optional(HMIPC_PIN): str,
}
),
errors=errors,
)
async def async_step_import(self, import_data: dict[str, str]) -> ConfigFlowResult:
"""Import a new access point as a config entry."""
hapid = import_data[HMIPC_HAPID].replace("-", "").upper()

View File

@@ -12,7 +12,10 @@ from homematicip.auth import Auth
from homematicip.base.enums import EventType
from homematicip.connection.connection_context import ConnectionContextBuilder
from homematicip.connection.rest_connection import RestConnection
from homematicip.exceptions.connection_exceptions import HmipConnectionError
from homematicip.exceptions.connection_exceptions import (
HmipAuthenticationError,
HmipConnectionError,
)
import homeassistant
from homeassistant.config_entries import ConfigEntry
@@ -192,6 +195,12 @@ class HomematicipHAP:
try:
await self.get_state()
break
except HmipAuthenticationError:
_LOGGER.error(
"Authentication error from HomematicIP Cloud, triggering reauth"
)
self.config_entry.async_start_reauth(self.hass)
break
except HmipConnectionError as err:
_LOGGER.warning(
"Get_state failed, retrying in %s seconds: %s", delay, err

View File

@@ -55,7 +55,7 @@ async def async_setup_entry(
entities: list[HomematicipGenericEntity] = []
entities.extend(
HomematicipLightHS(hap, d, ch.index)
HomematicipColorLight(hap, d, ch.index)
for d in hap.home.devices
for ch in d.functionalChannels
if ch.functionalChannelType == FunctionalChannelType.UNIVERSAL_LIGHT_CHANNEL
@@ -136,16 +136,32 @@ class HomematicipLight(HomematicipGenericEntity, LightEntity):
await self._device.turn_off_async()
class HomematicipLightHS(HomematicipGenericEntity, LightEntity):
"""Representation of the HomematicIP light with HS color mode."""
_attr_color_mode = ColorMode.HS
_attr_supported_color_modes = {ColorMode.HS}
class HomematicipColorLight(HomematicipGenericEntity, LightEntity):
"""Representation of the HomematicIP color light."""
def __init__(self, hap: HomematicipHAP, device: Device, channel_index: int) -> None:
"""Initialize the light entity."""
super().__init__(hap, device, channel=channel_index, is_multi_channel=True)
def _supports_color(self) -> bool:
"""Return true if device supports hue/saturation color control."""
channel = self.get_channel_or_raise()
return channel.hue is not None and channel.saturationLevel is not None
@property
def color_mode(self) -> ColorMode:
"""Return the color mode of the light."""
if self._supports_color():
return ColorMode.HS
return ColorMode.BRIGHTNESS
@property
def supported_color_modes(self) -> set[ColorMode]:
"""Return the supported color modes."""
if self._supports_color():
return {ColorMode.HS}
return {ColorMode.BRIGHTNESS}
@property
def is_on(self) -> bool:
"""Return true if light is on."""
@@ -172,18 +188,26 @@ class HomematicipLightHS(HomematicipGenericEntity, LightEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
channel = self.get_channel_or_raise()
hs_color = kwargs.get(ATTR_HS_COLOR, (0.0, 0.0))
hue = hs_color[0] % 360.0
saturation = hs_color[1] / 100.0
dim_level = round(kwargs.get(ATTR_BRIGHTNESS, 255) / 255.0, 2)
if ATTR_HS_COLOR not in kwargs:
hue = channel.hue
saturation = channel.saturationLevel
if ATTR_BRIGHTNESS not in kwargs:
# If no brightness is set, use the current brightness
dim_level = channel.dimLevel or 1.0
# Use dim-only method for monochrome mode (hue/saturation not supported)
if not self._supports_color():
await channel.set_dim_level_async(dim_level=dim_level)
return
# Full color mode with hue/saturation
if ATTR_HS_COLOR in kwargs:
hs_color = kwargs[ATTR_HS_COLOR]
hue = hs_color[0] % 360.0
saturation = hs_color[1] / 100.0
else:
hue = channel.hue
saturation = channel.saturationLevel
await channel.set_hue_saturation_dim_level_async(
hue=hue, saturation_level=saturation, dim_level=dim_level
)

View File

@@ -3,9 +3,11 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"connection_aborted": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"connection_aborted": "Registration failed, please try again.",
"invalid_sgtin_or_pin": "Invalid SGTIN or PIN code, please try again.",
"press_the_button": "Please press the blue button.",
"register_failed": "Failed to register, please try again.",
@@ -24,6 +26,13 @@
"link": {
"description": "Press the blue button on the access point and the **Submit** button to register Homematic IP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)",
"title": "Link access point"
},
"reauth_confirm": {
"data": {
"pin": "[%key:common::config_flow::data::pin%]"
},
"description": "The authentication token for your HomematicIP access point is no longer valid. Press **Submit** and then press the blue button on your access point to re-register.",
"title": "Re-authenticate HomematicIP access point"
}
}
},

View File

@@ -10,12 +10,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
PLATFORMS: list[Platform] = [
Platform.SENSOR,
Platform.SWITCH,
Platform.SELECT,
Platform.NUMBER,
]
PLATFORMS: list[Platform] = [Platform.SENSOR, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: HomevoltConfigEntry) -> bool:

View File

@@ -1,132 +0,0 @@
"""Support for Homevolt number entities."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from homeassistant.components.number import NumberEntity, NumberEntityDescription
from homeassistant.const import PERCENTAGE, EntityCategory, UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
@dataclass(frozen=True, kw_only=True)
class HomevoltNumberEntityDescription(NumberEntityDescription):
"""Custom entity description for Homevolt numbers."""
set_value_fn: Any = None
value_fn: Any = None
NUMBER_DESCRIPTIONS: tuple[HomevoltNumberEntityDescription, ...] = (
HomevoltNumberEntityDescription(
key="setpoint",
translation_key="setpoint",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="max_charge",
translation_key="max_charge",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="max_discharge",
translation_key="max_discharge",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="min_soc",
translation_key="min_soc",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="max_soc",
translation_key="max_soc",
native_min_value=0,
native_max_value=100,
native_step=1,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="grid_import_limit",
translation_key="grid_import_limit",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
HomevoltNumberEntityDescription(
key="grid_export_limit",
translation_key="grid_export_limit",
native_min_value=0,
native_max_value=20000,
native_step=100,
native_unit_of_measurement=UnitOfPower.WATT,
entity_category=EntityCategory.CONFIG,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt number entities."""
coordinator = entry.runtime_data
entities: list[HomevoltNumberEntity] = []
for description in NUMBER_DESCRIPTIONS:
entities.append(HomevoltNumberEntity(coordinator, description))
async_add_entities(entities)
class HomevoltNumberEntity(HomevoltEntity, NumberEntity):
"""Representation of a Homevolt number entity."""
entity_description: HomevoltNumberEntityDescription
def __init__(
self,
coordinator: HomevoltDataUpdateCoordinator,
description: HomevoltNumberEntityDescription,
) -> None:
"""Initialize the number entity."""
self.entity_description = description
self._attr_unique_id = f"{coordinator.data.unique_id}_{description.key}"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def native_value(self) -> float | None:
"""Return the current value."""
value = self.coordinator.client.schedule.get(self.entity_description.key)
return float(value) if value is not None else None
@homevolt_exception_handler
async def async_set_native_value(self, value: float) -> None:
"""Set the value."""
key = self.entity_description.key
await self.coordinator.client.set_battery_parameters(**{key: int(value)})
await self.coordinator.async_request_refresh()

View File

@@ -1,51 +0,0 @@
"""Support for Homevolt select entities."""
from __future__ import annotations
from homevolt.const import SCHEDULE_TYPE
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import HomevoltConfigEntry, HomevoltDataUpdateCoordinator
from .entity import HomevoltEntity, homevolt_exception_handler
PARALLEL_UPDATES = 0 # Coordinator-based updates
async def async_setup_entry(
hass: HomeAssistant,
entry: HomevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Homevolt select entities."""
coordinator = entry.runtime_data
async_add_entities([HomevoltModeSelect(coordinator)])
class HomevoltModeSelect(HomevoltEntity, SelectEntity):
"""Select entity for battery operational mode."""
_attr_entity_category = EntityCategory.CONFIG
_attr_translation_key = "battery_mode"
_attr_options = list(SCHEDULE_TYPE.values())
def __init__(self, coordinator: HomevoltDataUpdateCoordinator) -> None:
"""Initialize the select entity."""
self._attr_unique_id = f"{coordinator.data.unique_id}_battery_mode"
device_id = coordinator.data.unique_id
super().__init__(coordinator, f"ems_{device_id}")
@property
def current_option(self) -> str | None:
"""Return the current selected mode."""
mode_int = self.coordinator.client.schedule_mode
return SCHEDULE_TYPE.get(mode_int)
@homevolt_exception_handler
async def async_select_option(self, option: str) -> None:
"""Change the selected mode."""
await self.coordinator.client.set_battery_mode(mode=option)
await self.coordinator.async_request_refresh()

View File

@@ -54,46 +54,6 @@
}
},
"entity": {
"number": {
"grid_export_limit": {
"name": "Grid export limit"
},
"grid_import_limit": {
"name": "Grid import limit"
},
"max_charge": {
"name": "Maximum charge power"
},
"max_discharge": {
"name": "Maximum discharge power"
},
"max_soc": {
"name": "Maximum state of charge"
},
"min_soc": {
"name": "Minimum state of charge"
},
"setpoint": {
"name": "Power setpoint"
}
},
"select": {
"battery_mode": {
"name": "Battery mode",
"state": {
"frequency_reserve": "Frequency reserve",
"full_solar_export": "Full solar export",
"grid_charge": "Grid charge",
"grid_charge_discharge": "Grid charge/discharge",
"grid_discharge": "Grid discharge",
"idle": "Idle",
"inverter_charge": "Inverter charge",
"inverter_discharge": "Inverter discharge",
"solar_charge": "Solar charge",
"solar_charge_discharge": "Solar charge/discharge"
}
}
},
"sensor": {
"available_charging_energy": {
"name": "Available charging energy"

View File

@@ -10,6 +10,7 @@ override_schedule:
selector:
duration:
enable_day: true
enable_second: false
override_mode:
required: true
example: "mow"
@@ -32,6 +33,7 @@ override_schedule_work_area:
selector:
duration:
enable_day: true
enable_second: false
work_area_id:
required: true
example: "123"

View File

@@ -511,7 +511,7 @@
"description": "Lets the mower either mow or park for a given duration, overriding all schedules.",
"fields": {
"duration": {
"description": "Minimum: 1 minute, maximum: 42 days, seconds will be ignored.",
"description": "Minimum: 1 minute, maximum: 42 days.",
"name": "Duration"
},
"override_mode": {

View File

@@ -7,7 +7,12 @@ from homeassistant.core import HomeAssistant
from .coordinator import IndevoltConfigEntry, IndevoltCoordinator
PLATFORMS: list[Platform] = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
PLATFORMS: list[Platform] = [
Platform.NUMBER,
Platform.SELECT,
Platform.SENSOR,
Platform.SWITCH,
]
async def async_setup_entry(hass: HomeAssistant, entry: IndevoltConfigEntry) -> bool:

View File

@@ -0,0 +1,111 @@
"""Select platform for Indevolt integration."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Final
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import IndevoltConfigEntry
from .coordinator import IndevoltCoordinator
from .entity import IndevoltEntity
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class IndevoltSelectEntityDescription(SelectEntityDescription):
"""Custom entity description class for Indevolt select entities."""
read_key: str
write_key: str
value_to_option: dict[int, str]
unavailable_values: list[int] = field(default_factory=list)
generation: list[int] = field(default_factory=lambda: [1, 2])
SELECTS: Final = (
IndevoltSelectEntityDescription(
key="energy_mode",
translation_key="energy_mode",
read_key="7101",
write_key="47005",
value_to_option={
1: "self_consumed_prioritized",
4: "real_time_control",
5: "charge_discharge_schedule",
},
unavailable_values=[0],
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: IndevoltConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the select platform for Indevolt."""
coordinator = entry.runtime_data
device_gen = coordinator.generation
# Select initialization
async_add_entities(
IndevoltSelectEntity(coordinator=coordinator, description=description)
for description in SELECTS
if device_gen in description.generation
)
class IndevoltSelectEntity(IndevoltEntity, SelectEntity):
"""Represents a select entity for Indevolt devices."""
entity_description: IndevoltSelectEntityDescription
def __init__(
self,
coordinator: IndevoltCoordinator,
description: IndevoltSelectEntityDescription,
) -> None:
"""Initialize the Indevolt select entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{self.serial_number}_{description.key}"
self._attr_options = list(description.value_to_option.values())
self._option_to_value = {v: k for k, v in description.value_to_option.items()}
@property
def current_option(self) -> str | None:
"""Return the currently selected option."""
raw_value = self.coordinator.data.get(self.entity_description.read_key)
if raw_value is None:
return None
return self.entity_description.value_to_option.get(raw_value)
@property
def available(self) -> bool:
"""Return False when the device is in a mode that cannot be selected."""
if not super().available:
return False
raw_value = self.coordinator.data.get(self.entity_description.read_key)
return raw_value not in self.entity_description.unavailable_values
async def async_select_option(self, option: str) -> None:
"""Select a new option."""
value = self._option_to_value[option]
success = await self.coordinator.async_push_data(
self.entity_description.write_key, value
)
if success:
await self.coordinator.async_request_refresh()
else:
raise HomeAssistantError(f"Failed to set option {option} for {self.name}")

View File

@@ -37,6 +37,16 @@
"name": "Max AC output power"
}
},
"select": {
"energy_mode": {
"name": "[%key:component::indevolt::entity::sensor::energy_mode::name%]",
"state": {
"charge_discharge_schedule": "[%key:component::indevolt::entity::sensor::energy_mode::state::charge_discharge_schedule%]",
"real_time_control": "[%key:component::indevolt::entity::sensor::energy_mode::state::real_time_control%]",
"self_consumed_prioritized": "[%key:component::indevolt::entity::sensor::energy_mode::state::self_consumed_prioritized%]"
}
}
},
"sensor": {
"ac_input_power": {
"name": "AC input power"

View File

@@ -9,6 +9,7 @@ from homeassistant.components.update import (
UpdateEntityDescription,
UpdateEntityFeature,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.restore_state import RestoreEntity
@@ -22,6 +23,7 @@ PARALLEL_UPDATES = 0
UPDATE_DESCRIPTION = UpdateEntityDescription(
key="firmware",
device_class=UpdateDeviceClass.FIRMWARE,
entity_category=EntityCategory.DIAGNOSTIC,
)

View File

@@ -3,6 +3,8 @@
from __future__ import annotations
import asyncio
from datetime import datetime
import logging
from pyliebherrhomeapi import LiebherrClient
from pyliebherrhomeapi.exceptions import (
@@ -14,8 +16,13 @@ from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .const import DEVICE_SCAN_INTERVAL, DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator, LiebherrData
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [
Platform.NUMBER,
@@ -42,7 +49,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
raise ConfigEntryNotReady(f"Failed to connect to Liebherr API: {err}") from err
# Create a coordinator for each device (may be empty if no devices)
coordinators: dict[str, LiebherrCoordinator] = {}
data = LiebherrData(client=client)
for device in devices:
coordinator = LiebherrCoordinator(
hass=hass,
@@ -50,20 +57,61 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
client=client,
device_id=device.device_id,
)
coordinators[device.device_id] = coordinator
data.coordinators[device.device_id] = coordinator
await asyncio.gather(
*(
coordinator.async_config_entry_first_refresh()
for coordinator in coordinators.values()
for coordinator in data.coordinators.values()
)
)
# Store coordinators in runtime data
entry.runtime_data = coordinators
# Store runtime data
entry.runtime_data = data
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
# Schedule periodic scan for new devices
async def _async_scan_for_new_devices(_now: datetime) -> None:
"""Scan for new devices added to the account."""
try:
devices = await client.get_devices()
except LiebherrAuthenticationError, LiebherrConnectionError:
_LOGGER.debug("Failed to scan for new devices")
return
except Exception:
_LOGGER.exception("Unexpected error scanning for new devices")
return
new_coordinators: list[LiebherrCoordinator] = []
for device in devices:
if device.device_id not in data.coordinators:
coordinator = LiebherrCoordinator(
hass=hass,
config_entry=entry,
client=client,
device_id=device.device_id,
)
await coordinator.async_refresh()
if not coordinator.last_update_success:
_LOGGER.debug("Failed to set up new device %s", device.device_id)
continue
data.coordinators[device.device_id] = coordinator
new_coordinators.append(coordinator)
if new_coordinators:
async_dispatcher_send(
hass,
f"{DOMAIN}_new_device_{entry.entry_id}",
new_coordinators,
)
entry.async_on_unload(
async_track_time_interval(
hass, _async_scan_for_new_devices, DEVICE_SCAN_INTERVAL
)
)
return True

View File

@@ -6,4 +6,6 @@ from typing import Final
DOMAIN: Final = "liebherr"
MANUFACTURER: Final = "Liebherr"
SCAN_INTERVAL: Final = timedelta(seconds=60)
DEVICE_SCAN_INTERVAL: Final = timedelta(minutes=5)
REFRESH_DELAY: Final = timedelta(seconds=5)

View File

@@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import timedelta
from dataclasses import dataclass, field
import logging
from pyliebherrhomeapi import (
@@ -18,13 +18,20 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
type LiebherrConfigEntry = ConfigEntry[dict[str, LiebherrCoordinator]]
from .const import DOMAIN, SCAN_INTERVAL
_LOGGER = logging.getLogger(__name__)
SCAN_INTERVAL = timedelta(seconds=60)
@dataclass
class LiebherrData:
"""Runtime data for the Liebherr integration."""
client: LiebherrClient
coordinators: dict[str, LiebherrCoordinator] = field(default_factory=dict)
type LiebherrConfigEntry = ConfigEntry[LiebherrData]
class LiebherrCoordinator(DataUpdateCoordinator[DeviceState]):

View File

@@ -29,6 +29,6 @@ async def async_get_config_entry_diagnostics(
},
"data": asdict(coordinator.data),
}
for device_id, coordinator in entry.runtime_data.items()
for device_id, coordinator in entry.runtime_data.coordinators.items()
},
}

View File

@@ -16,9 +16,11 @@ from homeassistant.components.number import (
NumberEntityDescription,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import LiebherrZoneEntity
@@ -53,22 +55,41 @@ NUMBER_TYPES: tuple[LiebherrNumberEntityDescription, ...] = (
)
def _create_number_entities(
coordinators: list[LiebherrCoordinator],
) -> list[LiebherrNumber]:
"""Create number entities for the given coordinators."""
return [
LiebherrNumber(
coordinator=coordinator,
zone_id=temp_control.zone_id,
description=description,
)
for coordinator in coordinators
for temp_control in coordinator.data.get_temperature_controls().values()
for description in NUMBER_TYPES
]
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr number entities."""
coordinators = entry.runtime_data
async_add_entities(
LiebherrNumber(
coordinator=coordinator,
zone_id=temp_control.zone_id,
description=description,
_create_number_entities(list(entry.runtime_data.coordinators.values()))
)
@callback
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
"""Add number entities for new devices."""
async_add_entities(_create_number_entities(coordinators))
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
)
for coordinator in coordinators.values()
for temp_control in coordinator.data.get_temperature_controls().values()
for description in NUMBER_TYPES
)

View File

@@ -47,13 +47,13 @@ rules:
comment: Cloud API does not require updating entry data from network discovery.
discovery: done
docs-data-update: done
docs-examples: todo
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
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default:

View File

@@ -18,9 +18,11 @@ from pyliebherrhomeapi import (
)
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import ZONE_POSITION_MAP, LiebherrEntity
@@ -109,15 +111,13 @@ SELECT_TYPES: list[LiebherrSelectEntityDescription] = [
]
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr select entities."""
def _create_select_entities(
coordinators: list[LiebherrCoordinator],
) -> list[LiebherrSelectEntity]:
"""Create select entities for the given coordinators."""
entities: list[LiebherrSelectEntity] = []
for coordinator in entry.runtime_data.values():
for coordinator in coordinators:
has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
for control in coordinator.data.controls:
@@ -137,7 +137,29 @@ async def async_setup_entry(
)
)
async_add_entities(entities)
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr select entities."""
async_add_entities(
_create_select_entities(list(entry.runtime_data.coordinators.values()))
)
@callback
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
"""Add select entities for new devices."""
async_add_entities(_create_select_entities(coordinators))
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
)
)
class LiebherrSelectEntity(LiebherrEntity, SelectEntity):

View File

@@ -14,10 +14,12 @@ from homeassistant.components.sensor import (
SensorStateClass,
)
from homeassistant.const import UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import LiebherrZoneEntity
@@ -48,22 +50,41 @@ SENSOR_TYPES: tuple[LiebherrSensorEntityDescription, ...] = (
)
def _create_sensor_entities(
coordinators: list[LiebherrCoordinator],
) -> list[LiebherrSensor]:
"""Create sensor entities for the given coordinators."""
return [
LiebherrSensor(
coordinator=coordinator,
zone_id=temp_control.zone_id,
description=description,
)
for coordinator in coordinators
for temp_control in coordinator.data.get_temperature_controls().values()
for description in SENSOR_TYPES
]
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr sensor entities."""
coordinators = entry.runtime_data
async_add_entities(
LiebherrSensor(
coordinator=coordinator,
zone_id=temp_control.zone_id,
description=description,
_create_sensor_entities(list(entry.runtime_data.coordinators.values()))
)
@callback
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
"""Add sensor entities for new devices."""
async_add_entities(_create_sensor_entities(coordinators))
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
)
for coordinator in coordinators.values()
for temp_control in coordinator.data.get_temperature_controls().values()
for description in SENSOR_TYPES
)

View File

@@ -15,9 +15,11 @@ from pyliebherrhomeapi.const import (
)
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
from homeassistant.core import HomeAssistant
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LiebherrConfigEntry, LiebherrCoordinator
from .entity import ZONE_POSITION_MAP, LiebherrEntity
@@ -90,15 +92,13 @@ DEVICE_SWITCH_TYPES: dict[str, LiebherrDeviceSwitchEntityDescription] = {
}
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr switch entities."""
def _create_switch_entities(
coordinators: list[LiebherrCoordinator],
) -> list[LiebherrDeviceSwitch | LiebherrZoneSwitch]:
"""Create switch entities for the given coordinators."""
entities: list[LiebherrDeviceSwitch | LiebherrZoneSwitch] = []
for coordinator in entry.runtime_data.values():
for coordinator in coordinators:
has_multiple_zones = len(coordinator.data.get_temperature_controls()) > 1
for control in coordinator.data.controls:
@@ -127,7 +127,29 @@ async def async_setup_entry(
)
)
async_add_entities(entities)
return entities
async def async_setup_entry(
hass: HomeAssistant,
entry: LiebherrConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Liebherr switch entities."""
async_add_entities(
_create_switch_entities(list(entry.runtime_data.coordinators.values()))
)
@callback
def _async_new_device(coordinators: list[LiebherrCoordinator]) -> None:
"""Add switch entities for new devices."""
async_add_entities(_create_switch_entities(coordinators))
entry.async_on_unload(
async_dispatcher_connect(
hass, f"{DOMAIN}_new_device_{entry.entry_id}", _async_new_device
)
)
class LiebherrDeviceSwitch(LiebherrEntity, SwitchEntity):

View File

@@ -285,9 +285,9 @@ class MatterEntity(Entity):
self,
command: ClusterCommand,
**kwargs: Any,
) -> None:
) -> Any:
"""Send device command on the primary attribute's endpoint."""
await self.matter_client.send_device_command(
return await self.matter_client.send_device_command(
node_id=self._endpoint.node.node_id,
endpoint_id=self._endpoint.endpoint_id,
command=command,

View File

@@ -10,6 +10,7 @@ from chip.clusters import Objects as clusters
from matter_server.client.models import device_types
from homeassistant.components.vacuum import (
Segment,
StateVacuumEntity,
StateVacuumEntityDescription,
VacuumActivity,
@@ -70,6 +71,7 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
"""Representation of a Matter Vacuum cleaner entity."""
_last_accepted_commands: list[int] | None = None
_last_service_area_feature_map: int | None = None
_supported_run_modes: (
dict[int, clusters.RvcRunMode.Structs.ModeOptionStruct] | None
) = None
@@ -136,6 +138,16 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
"No supported run mode found to start the vacuum cleaner."
)
# Reset selected areas to an unconstrained selection to ensure start
# performs a full clean and does not reuse a previous area-targeted
# selection.
if VacuumEntityFeature.CLEAN_AREA in self.supported_features:
# Matter ServiceArea: an empty NewAreas list means unconstrained
# operation (full clean).
await self.send_device_command(
clusters.ServiceArea.Commands.SelectAreas(newAreas=[])
)
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
@@ -144,6 +156,66 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
"""Pause the cleaning task."""
await self.send_device_command(clusters.RvcOperationalState.Commands.Pause())
@property
def _current_segments(self) -> dict[str, Segment]:
"""Return the current cleanable segments reported by the device."""
supported_areas: list[clusters.ServiceArea.Structs.AreaStruct] = (
self.get_matter_attribute_value(
clusters.ServiceArea.Attributes.SupportedAreas
)
)
segments: dict[str, Segment] = {}
for area in supported_areas:
area_name = None
if area.areaInfo and area.areaInfo.locationInfo:
area_name = area.areaInfo.locationInfo.locationName
if area_name:
segment_id = str(area.areaID)
segments[segment_id] = Segment(id=segment_id, name=area_name)
return segments
async def async_get_segments(self) -> list[Segment]:
"""Get the segments that can be cleaned.
Returns a list of segments containing their ids and names.
"""
return list(self._current_segments.values())
async def async_clean_segments(self, segment_ids: list[str], **kwargs: Any) -> None:
"""Clean the specified segments.
Args:
segment_ids: List of segment IDs to clean.
**kwargs: Additional arguments (unused).
"""
area_ids = [int(segment_id) for segment_id in segment_ids]
mode = self._get_run_mode_by_tag(ModeTag.CLEANING)
if mode is None:
raise HomeAssistantError(
"No supported run mode found to start the vacuum cleaner."
)
response = await self.send_device_command(
clusters.ServiceArea.Commands.SelectAreas(newAreas=area_ids)
)
if (
response
and response.status != clusters.ServiceArea.Enums.SelectAreasStatus.kSuccess
):
raise HomeAssistantError(
f"Failed to select areas: {response.statusText or response.status.name}"
)
await self.send_device_command(
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
)
@callback
def _update_from_device(self) -> None:
"""Update from device."""
@@ -176,16 +248,34 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
state = VacuumActivity.CLEANING
self._attr_activity = state
if (
VacuumEntityFeature.CLEAN_AREA in self.supported_features
and self.registry_entry is not None
and (last_seen_segments := self.last_seen_segments) is not None
and self._current_segments != {s.id: s for s in last_seen_segments}
):
self.async_create_segments_issue()
@callback
def _calculate_features(self) -> None:
"""Calculate features for HA Vacuum platform."""
accepted_operational_commands: list[int] = self.get_matter_attribute_value(
clusters.RvcOperationalState.Attributes.AcceptedCommandList
)
# in principle the feature set should not change, except for the accepted commands
if self._last_accepted_commands == accepted_operational_commands:
service_area_feature_map: int | None = self.get_matter_attribute_value(
clusters.ServiceArea.Attributes.FeatureMap
)
# In principle the feature set should not change, except for accepted
# commands and service area feature map.
if (
self._last_accepted_commands == accepted_operational_commands
and self._last_service_area_feature_map == service_area_feature_map
):
return
self._last_accepted_commands = accepted_operational_commands
self._last_service_area_feature_map = service_area_feature_map
supported_features: VacuumEntityFeature = VacuumEntityFeature(0)
supported_features |= VacuumEntityFeature.START
supported_features |= VacuumEntityFeature.STATE
@@ -212,6 +302,12 @@ class MatterVacuum(MatterEntity, StateVacuumEntity):
in accepted_operational_commands
):
supported_features |= VacuumEntityFeature.RETURN_HOME
# Check if Map feature is enabled for clean area support
if (
service_area_feature_map is not None
and service_area_feature_map & clusters.ServiceArea.Bitmaps.Feature.kMaps
):
supported_features |= VacuumEntityFeature.CLEAN_AREA
self._attr_supported_features = supported_features
@@ -228,6 +324,10 @@ DISCOVERY_SCHEMAS = [
clusters.RvcRunMode.Attributes.CurrentMode,
clusters.RvcOperationalState.Attributes.OperationalState,
),
optional_attributes=(
clusters.ServiceArea.Attributes.FeatureMap,
clusters.ServiceArea.Attributes.SupportedAreas,
),
device_type=(device_types.RoboticVacuumCleaner,),
allow_none_value=True,
),

View File

@@ -2,9 +2,11 @@
from __future__ import annotations
from collections.abc import Mapping
import asyncio
from collections.abc import Iterable, Mapping
from dataclasses import dataclass
import logging
import re
from typing import Any, cast
import httpx
@@ -41,6 +43,48 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
# Headers and regex for WWW-Authenticate parsing for rfc9728
WWW_AUTHENTICATE_HEADER = "WWW-Authenticate"
RESOURCE_METADATA_REGEXP = r'resource_metadata="([^"]+)"'
OAUTH_PROTECTED_RESOURCE_ENDPOINT = "/.well-known/oauth-protected-resource"
SCOPES_REGEXP = r'scope="([^"]+)"'
@dataclass
class AuthenticateHeader:
"""Class to hold info from the WWW-Authenticate header for supporting rfc9728."""
resource_metadata_url: str
scopes: list[str] | None = None
@classmethod
def from_header(
cls, url: str, error_response: httpx.Response
) -> AuthenticateHeader | None:
"""Create AuthenticateHeader from WWW-Authenticate header."""
if not (header := error_response.headers.get(WWW_AUTHENTICATE_HEADER)) or not (
match := re.search(RESOURCE_METADATA_REGEXP, header)
):
return None
resource_metadata_url = str(URL(url).join(URL(match.group(1))))
scope_match = re.search(SCOPES_REGEXP, header)
return cls(
resource_metadata_url=resource_metadata_url,
scopes=scope_match.group(1).split(" ") if scope_match else None,
)
@dataclass
class ResourceMetadata:
"""Class to hold protected resource metadata defined in rfc9728."""
authorization_servers: list[str]
"""List of authorization server URLs."""
supported_scopes: list[str] | None = None
"""List of supported scopes."""
# OAuth server discovery endpoint for rfc8414
OAUTH_DISCOVERY_ENDPOINT = ".well-known/oauth-authorization-server"
MCP_DISCOVERY_HEADERS = {
@@ -58,40 +102,27 @@ class OAuthConfig:
scopes: list[str] | None = None
async def async_discover_oauth_config(
hass: HomeAssistant, mcp_server_url: str
async def async_discover_authorization_server(
hass: HomeAssistant, auth_server_url: str
) -> OAuthConfig:
"""Discover the OAuth configuration for the MCP server.
This implements the functionality in the MCP spec for discovery. If the MCP server URL
is https://api.example.com/v1/mcp, then:
- The authorization base URL is https://api.example.com
- The metadata endpoint MUST be at https://api.example.com/.well-known/oauth-authorization-server
- For servers that do not implement OAuth 2.0 Authorization Server Metadata, the client uses
default paths relative to the authorization base URL.
"""
parsed_url = URL(mcp_server_url)
discovery_endpoint = str(parsed_url.with_path(OAUTH_DISCOVERY_ENDPOINT))
"""Perform OAuth 2.0 Authorization Server Metadata discovery as per RFC8414."""
parsed_url = URL(auth_server_url)
urls_to_try = [
str(parsed_url.with_path(path))
for path in _authorization_server_discovery_paths(parsed_url)
]
# Pick any successful response and propagate exceptions except for
# 404 where we fall back to assuming some default paths.
try:
async with httpx.AsyncClient(headers=MCP_DISCOVERY_HEADERS) as client:
response = await client.get(discovery_endpoint)
response.raise_for_status()
except httpx.TimeoutException as error:
_LOGGER.info("Timeout connecting to MCP server: %s", error)
raise TimeoutConnectError from error
except httpx.HTTPStatusError as error:
if error.response.status_code == 404:
_LOGGER.info("Authorization Server Metadata not found, using default paths")
return OAuthConfig(
authorization_server=AuthorizationServer(
authorize_url=str(parsed_url.with_path("/authorize")),
token_url=str(parsed_url.with_path("/token")),
)
response = await _async_fetch_any(hass, urls_to_try)
except NotFoundError:
_LOGGER.info("Authorization Server Metadata not found, using default paths")
return OAuthConfig(
authorization_server=AuthorizationServer(
authorize_url=str(parsed_url.with_path("/authorize")),
token_url=str(parsed_url.with_path("/token")),
)
raise CannotConnect from error
except httpx.HTTPError as error:
_LOGGER.info("Cannot discover OAuth configuration: %s", error)
raise CannotConnect from error
)
data = response.json()
authorize_url = data["authorization_endpoint"]
@@ -130,7 +161,8 @@ async def validate_input(
except httpx.HTTPStatusError as error:
_LOGGER.info("Cannot connect to MCP server: %s", error)
if error.response.status_code == 401:
raise InvalidAuth from error
auth_header = AuthenticateHeader.from_header(url, error.response)
raise InvalidAuth(auth_header) from error
raise CannotConnect from error
except httpx.HTTPError as error:
_LOGGER.info("Cannot connect to MCP server: %s", error)
@@ -156,6 +188,7 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
super().__init__()
self.data: dict[str, Any] = {}
self.oauth_config: OAuthConfig | None = None
self.auth_header: AuthenticateHeader | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -171,7 +204,8 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
errors["base"] = "timeout_connect"
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
except InvalidAuth as err:
self.auth_header = err.metadata
self.data[CONF_URL] = user_input[CONF_URL]
return await self.async_step_auth_discovery()
except MissingCapabilities:
@@ -196,12 +230,34 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle the OAuth server discovery step.
Since this OAuth server requires authentication, this step will attempt
to find the OAuth medata then run the OAuth authentication flow.
to find the OAuth metadata then run the OAuth authentication flow.
"""
resource_metadata: ResourceMetadata | None = None
try:
oauth_config = await async_discover_oauth_config(
self.hass, self.data[CONF_URL]
)
if self.auth_header:
_LOGGER.debug(
"Resource metadata discovery from header: %s", self.auth_header
)
resource_metadata = await async_discover_protected_resource(
self.hass,
self.auth_header.resource_metadata_url,
self.data[CONF_URL],
)
_LOGGER.debug("Protected resource metadata: %s", resource_metadata)
oauth_config = await async_discover_authorization_server(
self.hass,
# Use the first authorization server from the resource metadata as it
# is the most common to have only one and there is not a defined strategy.
resource_metadata.authorization_servers[0],
)
else:
_LOGGER.debug(
"Discovering authorization server without protected resource metadata"
)
oauth_config = await async_discover_authorization_server(
self.hass,
self.data[CONF_URL],
)
except TimeoutConnectError:
return self.async_abort(reason="timeout_connect")
except CannotConnect:
@@ -216,7 +272,9 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
{
CONF_AUTHORIZATION_URL: oauth_config.authorization_server.authorize_url,
CONF_TOKEN_URL: oauth_config.authorization_server.token_url,
CONF_SCOPE: oauth_config.scopes,
CONF_SCOPE: _select_scopes(
self.auth_header, oauth_config, resource_metadata
),
}
)
return await self.async_step_credentials_choice()
@@ -326,6 +384,143 @@ class ModelContextProtocolConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
return await self.async_step_auth()
async def _async_fetch_any(
hass: HomeAssistant,
urls: Iterable[str],
) -> httpx.Response:
"""Fetch all URLs concurrently and return the first successful response."""
async def fetch(url: str) -> httpx.Response:
_LOGGER.debug("Fetching URL %s", url)
try:
async with httpx.AsyncClient() as client:
response = await client.get(url)
response.raise_for_status()
return response
except httpx.TimeoutException as error:
_LOGGER.debug("Timeout fetching URL %s: %s", url, error)
raise TimeoutConnectError from error
except httpx.HTTPStatusError as error:
_LOGGER.debug("Server error for URL %s: %s", url, error)
if error.response.status_code == 404:
raise NotFoundError from error
raise CannotConnect from error
except httpx.HTTPError as error:
_LOGGER.debug("Cannot fetch URL %s: %s", url, error)
raise CannotConnect from error
tasks = [asyncio.create_task(fetch(url)) for url in urls]
return_err: Exception | None = None
try:
for future in asyncio.as_completed(tasks):
try:
return await future
except Exception as err: # noqa: BLE001
_LOGGER.debug("Fetch failed: %s", err)
if return_err is None:
return_err = err
continue
finally:
for task in tasks:
task.cancel()
raise return_err or CannotConnect("No responses received from any URL")
async def async_discover_protected_resource(
hass: HomeAssistant,
auth_url: str,
mcp_server_url: str,
) -> ResourceMetadata:
"""Discover the OAuth configuration for a protected resource for MCP spec version 2025-11-25+.
This implements the functionality in the MCP spec for discovery. We use the information
from the WWW-Authenticate header to fetch the resource metadata implementing
RFC9728.
For the url https://example.com/public/mcp we attempt these urls:
- https://example.com/.well-known/oauth-protected-resource/public/mcp
- https://example.com/.well-known/oauth-protected-resource
"""
parsed_url = URL(mcp_server_url)
urls_to_try = {
auth_url,
str(
parsed_url.with_path(
f"{OAUTH_PROTECTED_RESOURCE_ENDPOINT}{parsed_url.path}"
)
),
str(parsed_url.with_path(OAUTH_PROTECTED_RESOURCE_ENDPOINT)),
}
response = await _async_fetch_any(hass, list(urls_to_try))
# Parse the OAuth Authorization Protected Resource Metadata (rfc9728). We
# expect to find at least one authorization server in the response and
# a valid resource field that matches the MCP server URL.
data = response.json()
if (
not (authorization_servers := data.get("authorization_servers"))
or not (resource := data.get("resource"))
or (resource != mcp_server_url)
):
_LOGGER.error("Invalid OAuth resource metadata: %s", data)
raise CannotConnect("OAuth resource metadata is invalid")
return ResourceMetadata(
authorization_servers=authorization_servers,
supported_scopes=data.get("scopes_supported"),
)
def _authorization_server_discovery_paths(auth_server_url: URL) -> list[str]:
"""Return the list of paths to try for OAuth server discovery.
For an auth server url with path components, e.g., https://auth.example.com/tenant1
clients try endpoints in the following priority order:
- OAuth 2.0 Authorization Server Metadata with path insertion:
https://auth.example.com/.well-known/oauth-authorization-server/tenant1
- OpenID Connect Discovery 1.0 with path insertion:
https://auth.example.com/.well-known/openid-configuration/tenant1
- OpenID Connect Discovery 1.0 path appending:
https://auth.example.com/tenant1/.well-known/openid-configuration
For an auth server url without path components, e.g., https://auth.example.com
clients try:
- OAuth 2.0 Authorization Server Metadata:
https://auth.example.com/.well-known/oauth-authorization-server
- OpenID Connect Discovery 1.0:
https://auth.example.com/.well-known/openid-configuration
"""
if auth_server_url.path and auth_server_url.path != "/":
return [
f"/.well-known/oauth-authorization-server{auth_server_url.path}",
f"/.well-known/openid-configuration{auth_server_url.path}",
f"{auth_server_url.path}/.well-known/openid-configuration",
]
return [
"/.well-known/oauth-authorization-server",
"/.well-known/openid-configuration",
]
def _select_scopes(
auth_header: AuthenticateHeader | None,
oauth_config: OAuthConfig,
resource_metadata: ResourceMetadata | None,
) -> list[str] | None:
"""Select OAuth scopes based on the MCP spec scope selection strategy.
This follows the MCP spec strategy of preferring first the authenticate header,
then the protected resource metadata, then finally the default scopes from
the OAuth discovery.
"""
if auth_header and auth_header.scopes:
return auth_header.scopes
if resource_metadata and resource_metadata.supported_scopes:
return resource_metadata.supported_scopes
return oauth_config.scopes
class InvalidUrl(HomeAssistantError):
"""Error to indicate the URL format is invalid."""
@@ -338,9 +533,18 @@ class TimeoutConnectError(HomeAssistantError):
"""Error to indicate we cannot connect."""
class NotFoundError(CannotConnect):
"""Error to indicate the resource was not found."""
class InvalidAuth(HomeAssistantError):
"""Error to indicate there is invalid auth."""
def __init__(self, metadata: AuthenticateHeader | None = None) -> None:
"""Initialize the error."""
super().__init__()
self.metadata = metadata
class MissingCapabilities(HomeAssistantError):
"""Error to indicate that the MCP server is missing required capabilities."""

View File

@@ -0,0 +1,30 @@
"""Diagnostics support for Met.no integration."""
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from .coordinator import MetWeatherConfigEntry
TO_REDACT = [
CONF_LATITUDE,
CONF_LONGITUDE,
]
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: MetWeatherConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator_data = entry.runtime_data.data
return {
"entry_data": async_redact_data(entry.data, TO_REDACT),
"data": {
"current_weather_data": coordinator_data.current_weather_data,
"daily_forecast": coordinator_data.daily_forecast,
"hourly_forecast": coordinator_data.hourly_forecast,
},
}

View File

@@ -213,8 +213,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
if info := await self._async_validate_credentials(
self._pending_host,
errors,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
username=user_input.get(CONF_USERNAME),
password=user_input.get(CONF_PASSWORD),
):
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
self._abort_if_unique_id_configured()
@@ -222,8 +222,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
title=info["title"],
data={
CONF_HOST: self._pending_host,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_USERNAME: user_input.get(CONF_USERNAME),
CONF_PASSWORD: user_input.get(CONF_PASSWORD),
},
)
@@ -253,8 +253,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
if info := await self._async_validate_credentials(
reauth_entry.data[CONF_HOST],
errors,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
username=user_input.get(CONF_USERNAME),
password=user_input.get(CONF_PASSWORD),
):
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
self._abort_if_unique_id_mismatch()
@@ -318,11 +318,13 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
reconfigure_entry = self._get_reconfigure_entry()
if user_input is not None:
username = user_input.get(CONF_USERNAME)
password = user_input.get(CONF_PASSWORD)
if info := await self._async_validate_credentials(
self._pending_host,
errors,
username=user_input[CONF_USERNAME],
password=user_input[CONF_PASSWORD],
username=username,
password=password,
):
await self.async_set_unique_id(info["serial"], raise_on_progress=False)
self._abort_if_unique_id_mismatch()
@@ -330,8 +332,8 @@ class NRGkickConfigFlow(ConfigFlow, domain=DOMAIN):
reconfigure_entry,
data_updates={
CONF_HOST: self._pending_host,
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_USERNAME: username,
CONF_PASSWORD: password,
},
)

View File

@@ -0,0 +1,30 @@
"""Diagnostics support for NRGkick."""
from __future__ import annotations
from dataclasses import asdict
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from .coordinator import NRGkickConfigEntry
TO_REDACT = {
CONF_PASSWORD,
CONF_USERNAME,
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: NRGkickConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
return async_redact_data(
{
"entry_data": entry.data,
"coordinator_data": asdict(entry.runtime_data.data),
},
TO_REDACT,
)

View File

@@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/nrgkick",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["nrgkick-api==1.7.1"],
"zeroconf": ["_nrgkick._tcp.local."]
}

View File

@@ -41,14 +41,14 @@ rules:
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: done
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery: done
discovery-update-info: done
docs-data-update: done

View File

@@ -11,6 +11,7 @@ from aiontfy.exceptions import (
NtfyTimeoutError,
NtfyUnauthorizedAuthenticationError,
)
from aiontfy.update import UpdateChecker
from homeassistant.const import CONF_TOKEN, CONF_URL, CONF_VERIFY_SSL, Platform
from homeassistant.core import HomeAssistant
@@ -18,14 +19,27 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.hass_dict import HassKey
from .const import DOMAIN
from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator
from .coordinator import (
NtfyConfigEntry,
NtfyDataUpdateCoordinator,
NtfyLatestReleaseUpdateCoordinator,
NtfyRuntimeData,
NtfyVersionDataUpdateCoordinator,
)
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
PLATFORMS: list[Platform] = [Platform.EVENT, Platform.NOTIFY, Platform.SENSOR]
PLATFORMS: list[Platform] = [
Platform.EVENT,
Platform.NOTIFY,
Platform.SENSOR,
Platform.UPDATE,
]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
NTFY_KEY: HassKey[NtfyLatestReleaseUpdateCoordinator] = HassKey(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
@@ -40,6 +54,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool
session = async_get_clientsession(hass, entry.data.get(CONF_VERIFY_SSL, True))
ntfy = Ntfy(entry.data[CONF_URL], session, token=entry.data.get(CONF_TOKEN))
if NTFY_KEY not in hass.data:
update_checker = UpdateChecker(session)
update_coordinator = NtfyLatestReleaseUpdateCoordinator(hass, update_checker)
await update_coordinator.async_request_refresh()
hass.data[NTFY_KEY] = update_coordinator
try:
await ntfy.account()
@@ -69,7 +88,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: NtfyConfigEntry) -> bool
coordinator = NtfyDataUpdateCoordinator(hass, entry, ntfy)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
version = NtfyVersionDataUpdateCoordinator(hass, entry, ntfy)
await version.async_config_entry_first_refresh()
entry.runtime_data = NtfyRuntimeData(coordinator, version)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)

View File

@@ -3,7 +3,7 @@
from typing import Final
DOMAIN = "ntfy"
DEFAULT_URL: Final = "https://ntfy.sh"
DEFAULT_URL: Final = "https://ntfy.sh/"
CONF_TOPIC = "topic"
CONF_PRIORITY = "filter_priority"

View File

@@ -2,16 +2,20 @@
from __future__ import annotations
from abc import abstractmethod
from dataclasses import dataclass
from datetime import timedelta
import logging
from aiontfy import Account as NtfyAccount, Ntfy
from aiontfy import Account as NtfyAccount, Ntfy, Version
from aiontfy.exceptions import (
NtfyConnectionError,
NtfyHTTPError,
NtfyNotFoundPageError,
NtfyTimeoutError,
NtfyUnauthorizedAuthenticationError,
)
from aiontfy.update import LatestRelease, UpdateChecker, UpdateCheckerError
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
@@ -22,13 +26,22 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
type NtfyConfigEntry = ConfigEntry[NtfyDataUpdateCoordinator]
type NtfyConfigEntry = ConfigEntry[NtfyRuntimeData]
class NtfyDataUpdateCoordinator(DataUpdateCoordinator[NtfyAccount]):
"""Ntfy data update coordinator."""
@dataclass
class NtfyRuntimeData:
"""Holds ntfy runtime data."""
account: NtfyDataUpdateCoordinator
version: NtfyVersionDataUpdateCoordinator
class BaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
"""Ntfy base coordinator."""
config_entry: NtfyConfigEntry
update_interval: timedelta
def __init__(
self, hass: HomeAssistant, config_entry: NtfyConfigEntry, ntfy: Ntfy
@@ -39,21 +52,19 @@ class NtfyDataUpdateCoordinator(DataUpdateCoordinator[NtfyAccount]):
_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(minutes=15),
update_interval=self.update_interval,
)
self.ntfy = ntfy
async def _async_update_data(self) -> NtfyAccount:
"""Fetch account data from ntfy."""
@abstractmethod
async def async_update_data(self) -> _DataT:
"""Fetch the latest data from the source."""
async def _async_update_data(self) -> _DataT:
"""Fetch the latest data from the source."""
try:
return await self.ntfy.account()
except NtfyUnauthorizedAuthenticationError as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from e
return await self.async_update_data()
except NtfyHTTPError as e:
_LOGGER.debug("Error %s: %s [%s]", e.code, e.error, e.link)
raise UpdateFailed(
@@ -72,3 +83,62 @@ class NtfyDataUpdateCoordinator(DataUpdateCoordinator[NtfyAccount]):
translation_domain=DOMAIN,
translation_key="timeout_error",
) from e
class NtfyDataUpdateCoordinator(BaseDataUpdateCoordinator[NtfyAccount]):
"""Ntfy data update coordinator."""
update_interval = timedelta(minutes=15)
async def async_update_data(self) -> NtfyAccount:
"""Fetch account data from ntfy."""
try:
return await self.ntfy.account()
except NtfyUnauthorizedAuthenticationError as e:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="authentication_error",
) from e
class NtfyVersionDataUpdateCoordinator(BaseDataUpdateCoordinator[Version | None]):
"""Ntfy data update coordinator."""
update_interval = timedelta(hours=3)
async def async_update_data(self) -> Version | None:
"""Fetch version data from ntfy."""
try:
version = await self.ntfy.version()
except NtfyUnauthorizedAuthenticationError, NtfyNotFoundPageError:
# /v1/version endpoint is only accessible to admins and
# available in ntfy since version 2.17.0
return None
return version
class NtfyLatestReleaseUpdateCoordinator(DataUpdateCoordinator[LatestRelease]):
"""Ntfy latest release update coordinator."""
def __init__(self, hass: HomeAssistant, update_checker: UpdateChecker) -> None:
"""Initialize coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=None,
name=DOMAIN,
update_interval=timedelta(hours=3),
)
self.update_checker = update_checker
async def _async_update_data(self) -> LatestRelease:
"""Fetch latest release data."""
try:
return await self.update_checker.latest_release()
except UpdateCheckerError as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_check_failed",
) from e

View File

@@ -7,10 +7,11 @@ from yarl import URL
from homeassistant.config_entries import ConfigSubentry
from homeassistant.const import CONF_URL
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_TOPIC, DOMAIN
from .coordinator import NtfyConfigEntry
from .coordinator import BaseDataUpdateCoordinator, NtfyConfigEntry
class NtfyBaseEntity(Entity):
@@ -38,6 +39,29 @@ class NtfyBaseEntity(Entity):
identifiers={(DOMAIN, f"{config_entry.entry_id}_{subentry.subentry_id}")},
via_device=(DOMAIN, config_entry.entry_id),
)
self.ntfy = config_entry.runtime_data.ntfy
self.ntfy = config_entry.runtime_data.account.ntfy
self.config_entry = config_entry
self.subentry = subentry
class NtfyCommonBaseEntity(CoordinatorEntity[BaseDataUpdateCoordinator]):
"""Base entity for common entities."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: BaseDataUpdateCoordinator,
description: EntityDescription,
) -> None:
"""Initialize entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer="ntfy LLC",
model="ntfy",
configuration_url=URL(coordinator.config_entry.data[CONF_URL]) / "app",
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
)

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["aiontfy"],
"quality_scale": "platinum",
"requirements": ["aiontfy==0.8.0"]
"requirements": ["aiontfy==0.8.1"]
}

View File

@@ -7,22 +7,19 @@ from dataclasses import dataclass
from enum import StrEnum
from aiontfy import Account as NtfyAccount
from yarl import URL
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.const import CONF_URL, EntityCategory, UnitOfInformation, UnitOfTime
from homeassistant.const import EntityCategory, UnitOfInformation, UnitOfTime
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import NtfyConfigEntry, NtfyDataUpdateCoordinator
from .entity import NtfyCommonBaseEntity
PARALLEL_UPDATES = 0
@@ -233,38 +230,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the sensor platform."""
coordinator = config_entry.runtime_data
coordinator = config_entry.runtime_data.account
async_add_entities(
NtfySensorEntity(coordinator, description)
for description in SENSOR_DESCRIPTIONS
)
class NtfySensorEntity(CoordinatorEntity[NtfyDataUpdateCoordinator], SensorEntity):
class NtfySensorEntity(NtfyCommonBaseEntity, SensorEntity):
"""Representation of a ntfy sensor entity."""
entity_description: NtfySensorEntityDescription
coordinator: NtfyDataUpdateCoordinator
_attr_has_entity_name = True
def __init__(
self,
coordinator: NtfyDataUpdateCoordinator,
description: NtfySensorEntityDescription,
) -> None:
"""Initialize a sensor entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{description.key}"
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer="ntfy LLC",
model="ntfy",
configuration_url=URL(coordinator.config_entry.data[CONF_URL]) / "app",
identifiers={(DOMAIN, coordinator.config_entry.entry_id)},
)
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""

View File

@@ -261,6 +261,11 @@
"supporter": "Supporter"
}
}
},
"update": {
"update": {
"name": "ntfy version"
}
}
},
"exceptions": {
@@ -302,6 +307,9 @@
},
"timeout_error": {
"message": "Failed to connect to ntfy service due to a connection timeout"
},
"update_check_failed": {
"message": "Failed to check for latest ntfy update"
}
},
"issues": {

View File

@@ -0,0 +1,116 @@
"""Update platform for the ntfy integration."""
from __future__ import annotations
from enum import StrEnum
from homeassistant.components.update import (
UpdateEntity,
UpdateEntityDescription,
UpdateEntityFeature,
)
from homeassistant.const import CONF_URL, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import NTFY_KEY
from .const import DEFAULT_URL
from .coordinator import (
NtfyConfigEntry,
NtfyLatestReleaseUpdateCoordinator,
NtfyVersionDataUpdateCoordinator,
)
from .entity import NtfyCommonBaseEntity
PARALLEL_UPDATES = 0
class NtfyUpdate(StrEnum):
"""Ntfy update."""
UPDATE = "update"
DESCRIPTION = UpdateEntityDescription(
key=NtfyUpdate.UPDATE,
translation_key=NtfyUpdate.UPDATE,
entity_category=EntityCategory.DIAGNOSTIC,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: NtfyConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up update platform."""
if (
entry.data[CONF_URL] != DEFAULT_URL
and (version_coordinator := entry.runtime_data.version).data is not None
):
update_coordinator = hass.data[NTFY_KEY]
async_add_entities(
[NtfyUpdateEntity(version_coordinator, update_coordinator, DESCRIPTION)]
)
class NtfyUpdateEntity(NtfyCommonBaseEntity, UpdateEntity):
"""Representation of an update entity."""
_attr_supported_features = UpdateEntityFeature.RELEASE_NOTES
coordinator: NtfyVersionDataUpdateCoordinator
def __init__(
self,
coordinator: NtfyVersionDataUpdateCoordinator,
update_checker: NtfyLatestReleaseUpdateCoordinator,
description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator, description)
self.update_checker = update_checker
if self._attr_device_info and self.installed_version:
self._attr_device_info.update({"sw_version": self.installed_version})
@property
def installed_version(self) -> str | None:
"""Current version."""
return self.coordinator.data.version if self.coordinator.data else None
@property
def title(self) -> str | None:
"""Title of the release."""
return f"ntfy {self.update_checker.data.name}"
@property
def release_url(self) -> str | None:
"""URL to the full release notes."""
return self.update_checker.data.html_url
@property
def latest_version(self) -> str | None:
"""Latest version."""
return self.update_checker.data.tag_name.removeprefix("v")
async def async_release_notes(self) -> str | None:
"""Return the release notes."""
return self.update_checker.data.body
async def async_added_to_hass(self) -> None:
"""When entity is added to hass.
Register extra update listener for the update checker coordinator.
"""
await super().async_added_to_hass()
self.async_on_remove(
self.update_checker.async_add_listener(self._handle_coordinator_update)
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return super().available and self.update_checker.last_update_success

View File

@@ -173,5 +173,10 @@
"set_value": {
"service": "mdi:numeric"
}
},
"triggers": {
"changed": {
"trigger": "mdi:counter"
}
}
}

View File

@@ -204,5 +204,11 @@
"name": "Set"
}
},
"title": "Number"
"title": "Number",
"triggers": {
"changed": {
"description": "Triggers when a number value changes.",
"name": "Number changed"
}
}
}

View File

@@ -0,0 +1,18 @@
"""Provides triggers for number entities."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_trigger,
)
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"changed": make_entity_numerical_state_changed_trigger(DOMAIN),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for number entities."""
return TRIGGERS

View File

@@ -0,0 +1,4 @@
changed:
target:
entity:
domain: number

View File

@@ -50,6 +50,7 @@ from .const import (
CONF_TOP_P,
DEFAULT_AI_TASK_NAME,
DEFAULT_NAME,
DEFAULT_STT_NAME,
DEFAULT_TTS_NAME,
DOMAIN,
LOGGER,
@@ -57,6 +58,7 @@ from .const import (
RECOMMENDED_CHAT_MODEL,
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_STT_OPTIONS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
RECOMMENDED_TTS_OPTIONS,
@@ -66,7 +68,7 @@ from .entity import async_prepare_files_for_prompt
SERVICE_GENERATE_IMAGE = "generate_image"
SERVICE_GENERATE_CONTENT = "generate_content"
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION, Platform.TTS)
PLATFORMS = (Platform.AI_TASK, Platform.CONVERSATION, Platform.STT, Platform.TTS)
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
type OpenAIConfigEntry = ConfigEntry[openai.AsyncClient]
@@ -480,6 +482,10 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) ->
_add_tts_subentry(hass, entry)
hass.config_entries.async_update_entry(entry, minor_version=5)
if entry.version == 2 and entry.minor_version == 5:
_add_stt_subentry(hass, entry)
hass.config_entries.async_update_entry(entry, minor_version=6)
LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
)
@@ -500,6 +506,19 @@ def _add_ai_task_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None
)
def _add_stt_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None:
"""Add STT subentry to the config entry."""
hass.config_entries.async_add_subentry(
entry,
ConfigSubentry(
data=MappingProxyType(RECOMMENDED_STT_OPTIONS),
subentry_type="stt",
title=DEFAULT_STT_NAME,
unique_id=None,
),
)
def _add_tts_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None:
"""Add TTS subentry to the config entry."""
hass.config_entries.async_add_subentry(

View File

@@ -68,6 +68,8 @@ from .const import (
CONF_WEB_SEARCH_USER_LOCATION,
DEFAULT_AI_TASK_NAME,
DEFAULT_CONVERSATION_NAME,
DEFAULT_STT_NAME,
DEFAULT_STT_PROMPT,
DEFAULT_TTS_NAME,
DOMAIN,
RECOMMENDED_AI_TASK_OPTIONS,
@@ -78,6 +80,8 @@ from .const import (
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_STT_MODEL,
RECOMMENDED_STT_OPTIONS,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
RECOMMENDED_TTS_OPTIONS,
@@ -110,14 +114,14 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None:
client = openai.AsyncOpenAI(
api_key=data[CONF_API_KEY], http_client=get_async_client(hass)
)
await hass.async_add_executor_job(client.with_options(timeout=10.0).models.list)
await client.models.list(timeout=10.0)
class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for OpenAI Conversation."""
VERSION = 2
MINOR_VERSION = 5
MINOR_VERSION = 6
async def async_step_user(
self, user_input: dict[str, Any] | None = None
@@ -158,6 +162,12 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
"title": DEFAULT_AI_TASK_NAME,
"unique_id": None,
},
{
"subentry_type": "stt",
"data": RECOMMENDED_STT_OPTIONS,
"title": DEFAULT_STT_NAME,
"unique_id": None,
},
{
"subentry_type": "tts",
"data": RECOMMENDED_TTS_OPTIONS,
@@ -204,6 +214,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
return {
"conversation": OpenAISubentryFlowHandler,
"ai_task_data": OpenAISubentryFlowHandler,
"stt": OpenAISubentrySTTFlowHandler,
"tts": OpenAISubentryTTSFlowHandler,
}
@@ -595,6 +606,95 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
return location_data
class OpenAISubentrySTTFlowHandler(ConfigSubentryFlow):
"""Flow for managing OpenAI STT subentries."""
options: dict[str, Any]
@property
def _is_new(self) -> bool:
"""Return if this is a new subentry."""
return self.source == "user"
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Add a subentry."""
self.options = RECOMMENDED_STT_OPTIONS.copy()
return await self.async_step_init()
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Handle reconfiguration of a subentry."""
self.options = self._get_reconfigure_subentry().data.copy()
return await self.async_step_init()
async def async_step_init(
self, user_input: dict[str, Any] | None = None
) -> SubentryFlowResult:
"""Manage initial options."""
# abort if entry is not loaded
if self._get_entry().state != ConfigEntryState.LOADED:
return self.async_abort(reason="entry_not_loaded")
options = self.options
errors: dict[str, str] = {}
step_schema: VolDictType = {}
if self._is_new:
step_schema[vol.Required(CONF_NAME, default=DEFAULT_STT_NAME)] = str
step_schema.update(
{
vol.Optional(
CONF_PROMPT,
description={
"suggested_value": options.get(CONF_PROMPT, DEFAULT_STT_PROMPT)
},
): TextSelector(
TextSelectorConfig(multiline=True, type=TextSelectorType.TEXT)
),
vol.Optional(
CONF_CHAT_MODEL, default=RECOMMENDED_STT_MODEL
): SelectSelector(
SelectSelectorConfig(
options=[
"gpt-4o-transcribe",
"gpt-4o-mini-transcribe",
"whisper-1",
],
mode=SelectSelectorMode.DROPDOWN,
custom_value=True,
)
),
}
)
if user_input is not None:
options.update(user_input)
if not errors:
if self._is_new:
return self.async_create_entry(
title=options.pop(CONF_NAME),
data=options,
)
return self.async_update_and_abort(
self._get_entry(),
self._get_reconfigure_subentry(),
data=options,
)
return self.async_show_form(
step_id="init",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(step_schema), options
),
errors=errors,
)
class OpenAISubentryTTSFlowHandler(ConfigSubentryFlow):
"""Flow for managing OpenAI TTS subentries."""

View File

@@ -1,6 +1,7 @@
"""Constants for the OpenAI Conversation integration."""
import logging
from typing import Any
from homeassistant.const import CONF_LLM_HASS_API
from homeassistant.helpers import llm
@@ -10,6 +11,7 @@ LOGGER: logging.Logger = logging.getLogger(__package__)
DEFAULT_CONVERSATION_NAME = "OpenAI Conversation"
DEFAULT_AI_TASK_NAME = "OpenAI AI Task"
DEFAULT_STT_NAME = "OpenAI STT"
DEFAULT_TTS_NAME = "OpenAI TTS"
DEFAULT_NAME = "OpenAI Conversation"
@@ -40,6 +42,7 @@ RECOMMENDED_IMAGE_MODEL = "gpt-image-1.5"
RECOMMENDED_MAX_TOKENS = 3000
RECOMMENDED_REASONING_EFFORT = "low"
RECOMMENDED_REASONING_SUMMARY = "auto"
RECOMMENDED_STT_MODEL = "gpt-4o-mini-transcribe"
RECOMMENDED_TEMPERATURE = 1.0
RECOMMENDED_TOP_P = 1.0
RECOMMENDED_TTS_SPEED = 1.0
@@ -48,6 +51,9 @@ RECOMMENDED_WEB_SEARCH = False
RECOMMENDED_WEB_SEARCH_CONTEXT_SIZE = "medium"
RECOMMENDED_WEB_SEARCH_USER_LOCATION = False
RECOMMENDED_WEB_SEARCH_INLINE_CITATIONS = False
DEFAULT_STT_PROMPT = (
"The following conversation is a smart home user talking to Home Assistant."
)
UNSUPPORTED_MODELS: list[str] = [
"o1-mini",
@@ -108,6 +114,7 @@ RECOMMENDED_CONVERSATION_OPTIONS = {
RECOMMENDED_AI_TASK_OPTIONS = {
CONF_RECOMMENDED: True,
}
RECOMMENDED_STT_OPTIONS: dict[str, Any] = {}
RECOMMENDED_TTS_OPTIONS = {
CONF_PROMPT: "",
CONF_CHAT_MODEL: "gpt-4o-mini-tts",

View File

@@ -92,6 +92,7 @@ from .const import (
RECOMMENDED_MAX_TOKENS,
RECOMMENDED_REASONING_EFFORT,
RECOMMENDED_REASONING_SUMMARY,
RECOMMENDED_STT_MODEL,
RECOMMENDED_TEMPERATURE,
RECOMMENDED_TOP_P,
RECOMMENDED_VERBOSITY,
@@ -471,7 +472,12 @@ class OpenAIBaseLLMEntity(Entity):
identifiers={(DOMAIN, subentry.subentry_id)},
name=subentry.title,
manufacturer="OpenAI",
model=subentry.data.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL),
model=subentry.data.get(
CONF_CHAT_MODEL,
RECOMMENDED_CHAT_MODEL
if subentry.subentry_type != "stt"
else RECOMMENDED_STT_MODEL,
),
entry_type=dr.DeviceEntryType.SERVICE,
)

View File

@@ -146,6 +146,30 @@
}
}
},
"stt": {
"abort": {
"entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"entry_type": "Speech-to-text",
"initiate_flow": {
"reconfigure": "Reconfigure speech-to-text service",
"user": "Add speech-to-text service"
},
"step": {
"init": {
"data": {
"chat_model": "Model",
"name": "[%key:common::config_flow::data::name%]",
"prompt": "[%key:common::config_flow::data::prompt%]"
},
"data_description": {
"chat_model": "The model to use to transcribe speech.",
"prompt": "Use this prompt to improve the quality of the transcripts. Translate to the pipeline language for best results. See the documentation for more details."
}
}
}
},
"tts": {
"abort": {
"entry_not_loaded": "[%key:component::openai_conversation::config_subentries::conversation::abort::entry_not_loaded%]",

View File

@@ -0,0 +1,196 @@
"""Speech to text support for OpenAI."""
from __future__ import annotations
from collections.abc import AsyncIterable
import io
import logging
from typing import TYPE_CHECKING
import wave
from openai import OpenAIError
from homeassistant.components import stt
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import (
CONF_CHAT_MODEL,
CONF_PROMPT,
DEFAULT_STT_PROMPT,
RECOMMENDED_STT_MODEL,
)
from .entity import OpenAIBaseLLMEntity
if TYPE_CHECKING:
from . import OpenAIConfigEntry
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: OpenAIConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up STT entities."""
for subentry in config_entry.subentries.values():
if subentry.subentry_type != "stt":
continue
async_add_entities(
[OpenAISTTEntity(config_entry, subentry)],
config_subentry_id=subentry.subentry_id,
)
class OpenAISTTEntity(stt.SpeechToTextEntity, OpenAIBaseLLMEntity):
"""OpenAI Speech to text entity."""
@property
def supported_languages(self) -> list[str]:
"""Return a list of supported languages."""
# https://developers.openai.com/api/docs/guides/speech-to-text#supported-languages
# The model may also transcribe the audio in other languages but with lower quality
return [
"af-ZA", # Afrikaans
"ar-SA", # Arabic
"hy-AM", # Armenian
"az-AZ", # Azerbaijani
"be-BY", # Belarusian
"bs-BA", # Bosnian
"bg-BG", # Bulgarian
"ca-ES", # Catalan
"zh-CN", # Chinese (Mandarin)
"hr-HR", # Croatian
"cs-CZ", # Czech
"da-DK", # Danish
"nl-NL", # Dutch
"en-US", # English
"et-EE", # Estonian
"fi-FI", # Finnish
"fr-FR", # French
"gl-ES", # Galician
"de-DE", # German
"el-GR", # Greek
"he-IL", # Hebrew
"hi-IN", # Hindi
"hu-HU", # Hungarian
"is-IS", # Icelandic
"id-ID", # Indonesian
"it-IT", # Italian
"ja-JP", # Japanese
"kn-IN", # Kannada
"kk-KZ", # Kazakh
"ko-KR", # Korean
"lv-LV", # Latvian
"lt-LT", # Lithuanian
"mk-MK", # Macedonian
"ms-MY", # Malay
"mr-IN", # Marathi
"mi-NZ", # Maori
"ne-NP", # Nepali
"no-NO", # Norwegian
"fa-IR", # Persian
"pl-PL", # Polish
"pt-PT", # Portuguese
"ro-RO", # Romanian
"ru-RU", # Russian
"sr-RS", # Serbian
"sk-SK", # Slovak
"sl-SI", # Slovenian
"es-ES", # Spanish
"sw-KE", # Swahili
"sv-SE", # Swedish
"fil-PH", # Tagalog (Filipino)
"ta-IN", # Tamil
"th-TH", # Thai
"tr-TR", # Turkish
"uk-UA", # Ukrainian
"ur-PK", # Urdu
"vi-VN", # Vietnamese
"cy-GB", # Welsh
]
@property
def supported_formats(self) -> list[stt.AudioFormats]:
"""Return a list of supported formats."""
# https://developers.openai.com/api/docs/guides/speech-to-text#transcriptions
return [stt.AudioFormats.WAV, stt.AudioFormats.OGG]
@property
def supported_codecs(self) -> list[stt.AudioCodecs]:
"""Return a list of supported codecs."""
return [stt.AudioCodecs.PCM, stt.AudioCodecs.OPUS]
@property
def supported_bit_rates(self) -> list[stt.AudioBitRates]:
"""Return a list of supported bit rates."""
return [
stt.AudioBitRates.BITRATE_8,
stt.AudioBitRates.BITRATE_16,
stt.AudioBitRates.BITRATE_24,
stt.AudioBitRates.BITRATE_32,
]
@property
def supported_sample_rates(self) -> list[stt.AudioSampleRates]:
"""Return a list of supported sample rates."""
return [
stt.AudioSampleRates.SAMPLERATE_8000,
stt.AudioSampleRates.SAMPLERATE_11000,
stt.AudioSampleRates.SAMPLERATE_16000,
stt.AudioSampleRates.SAMPLERATE_18900,
stt.AudioSampleRates.SAMPLERATE_22000,
stt.AudioSampleRates.SAMPLERATE_32000,
stt.AudioSampleRates.SAMPLERATE_37800,
stt.AudioSampleRates.SAMPLERATE_44100,
stt.AudioSampleRates.SAMPLERATE_48000,
]
@property
def supported_channels(self) -> list[stt.AudioChannels]:
"""Return a list of supported channels."""
return [stt.AudioChannels.CHANNEL_MONO, stt.AudioChannels.CHANNEL_STEREO]
async def async_process_audio_stream(
self, metadata: stt.SpeechMetadata, stream: AsyncIterable[bytes]
) -> stt.SpeechResult:
"""Process an audio stream to STT service."""
audio_bytes = bytearray()
async for chunk in stream:
audio_bytes.extend(chunk)
audio_data = bytes(audio_bytes)
if metadata.format == stt.AudioFormats.WAV:
# Add missing wav header
wav_buffer = io.BytesIO()
with wave.open(wav_buffer, "wb") as wf:
wf.setnchannels(metadata.channel.value)
wf.setsampwidth(metadata.bit_rate.value // 8)
wf.setframerate(metadata.sample_rate.value)
wf.writeframes(audio_data)
audio_data = wav_buffer.getvalue()
options = self.subentry.data
client = self.entry.runtime_data
try:
response = await client.audio.transcriptions.create(
model=options.get(CONF_CHAT_MODEL, RECOMMENDED_STT_MODEL),
file=(f"a.{metadata.format.value}", audio_data),
response_format="json",
language=metadata.language.split("-")[0],
prompt=options.get(CONF_PROMPT, DEFAULT_STT_PROMPT),
)
except OpenAIError:
_LOGGER.exception("Error during STT")
else:
if response.text:
return stt.SpeechResult(
response.text,
stt.SpeechResultState.SUCCESS,
)
return stt.SpeechResult(None, stt.SpeechResultState.ERROR)

View File

@@ -15,12 +15,14 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PortainerConfigEntry
from .const import CONTAINER_STATE_RUNNING
from .const import CONTAINER_STATE_RUNNING, STACK_STATUS_ACTIVE
from .coordinator import PortainerContainerData, PortainerCoordinator
from .entity import (
PortainerContainerEntity,
PortainerCoordinatorData,
PortainerEndpointEntity,
PortainerStackData,
PortainerStackEntity,
)
PARALLEL_UPDATES = 1
@@ -40,6 +42,13 @@ class PortainerEndpointBinarySensorEntityDescription(BinarySensorEntityDescripti
state_fn: Callable[[PortainerCoordinatorData], bool | None]
@dataclass(frozen=True, kw_only=True)
class PortainerStackBinarySensorEntityDescription(BinarySensorEntityDescription):
"""Class to hold Portainer stack binary sensor description."""
state_fn: Callable[[PortainerStackData], bool | None]
CONTAINER_SENSORS: tuple[PortainerContainerBinarySensorEntityDescription, ...] = (
PortainerContainerBinarySensorEntityDescription(
key="status",
@@ -60,6 +69,18 @@ ENDPOINT_SENSORS: tuple[PortainerEndpointBinarySensorEntityDescription, ...] = (
),
)
STACK_SENSORS: tuple[PortainerStackBinarySensorEntityDescription, ...] = (
PortainerStackBinarySensorEntityDescription(
key="stack_status",
translation_key="status",
state_fn=lambda data: (
data.stack.status == STACK_STATUS_ACTIVE
), # 1 = Active | 2 = Inactive
device_class=BinarySensorDeviceClass.RUNNING,
entity_category=EntityCategory.DIAGNOSTIC,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -98,9 +119,24 @@ async def async_setup_entry(
if entity_description.state_fn(container)
)
def _async_add_new_stacks(
stacks: list[tuple[PortainerCoordinatorData, PortainerStackData]],
) -> None:
"""Add new stack sensors."""
async_add_entities(
PortainerStackSensor(
coordinator,
entity_description,
stack,
endpoint,
)
for (endpoint, stack) in stacks
for entity_description in STACK_SENSORS
)
coordinator.new_endpoints_callbacks.append(_async_add_new_endpoints)
coordinator.new_containers_callbacks.append(_async_add_new_containers)
coordinator.new_stacks_callbacks.append(_async_add_new_stacks)
_async_add_new_endpoints(
[
endpoint
@@ -115,6 +151,13 @@ async def async_setup_entry(
for container in endpoint.containers.values()
]
)
_async_add_new_stacks(
[
(endpoint, stack)
for endpoint in coordinator.data.values()
for stack in endpoint.stacks.values()
]
)
class PortainerEndpointSensor(PortainerEndpointEntity, BinarySensorEntity):
@@ -162,3 +205,27 @@ class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity):
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.state_fn(self.container_data)
class PortainerStackSensor(PortainerStackEntity, BinarySensorEntity):
"""Representation of a Portainer stack sensor."""
entity_description: PortainerStackBinarySensorEntityDescription
def __init__(
self,
coordinator: PortainerCoordinator,
entity_description: PortainerStackBinarySensorEntityDescription,
device_info: PortainerStackData,
via_device: PortainerCoordinatorData,
) -> None:
"""Initialize the Portainer stack sensor."""
self.entity_description = entity_description
super().__init__(device_info, coordinator, via_device)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.stack.id}_{entity_description.key}"
@property
def is_on(self) -> bool | None:
"""Return true if the binary sensor is on."""
return self.entity_description.state_fn(self.stack_data)

View File

@@ -7,3 +7,11 @@ DEFAULT_NAME = "Portainer"
ENDPOINT_STATUS_DOWN = 2
CONTAINER_STATE_RUNNING = "running"
STACK_STATUS_ACTIVE = 1
STACK_STATUS_INACTIVE = 2
STACK_TYPE_SWARM = 1
STACK_TYPE_COMPOSE = 2
STACK_TYPE_KUBERNETES = 3

View File

@@ -21,6 +21,7 @@ from pyportainer.models.docker import (
)
from pyportainer.models.docker_inspect import DockerInfo, DockerVersion
from pyportainer.models.portainer import Endpoint
from pyportainer.models.stacks import Stack
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_URL
@@ -48,6 +49,7 @@ class PortainerCoordinatorData:
docker_version: DockerVersion
docker_info: DockerInfo
docker_system_df: DockerSystemDF
stacks: dict[str, PortainerStackData]
@dataclass(slots=True)
@@ -57,6 +59,15 @@ class PortainerContainerData:
container: DockerContainer
stats: DockerContainerStats | None
stats_pre: DockerContainerStats | None
stack: Stack | None
@dataclass(slots=True)
class PortainerStackData:
"""Stack data held by the Portainer coordinator."""
stack: Stack
container_count: int = 0
class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorData]]):
@@ -82,6 +93,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
self.known_endpoints: set[int] = set()
self.known_containers: set[tuple[int, str]] = set()
self.known_stacks: set[tuple[int, str]] = set()
self.new_endpoints_callbacks: list[
Callable[[list[PortainerCoordinatorData]], None]
@@ -91,6 +103,9 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
[list[tuple[PortainerCoordinatorData, PortainerContainerData]]], None
]
] = []
self.new_stacks_callbacks: list[
Callable[[list[tuple[PortainerCoordinatorData, PortainerStackData]]], None]
] = []
async def _async_setup(self) -> None:
"""Set up the Portainer Data Update Coordinator."""
@@ -153,28 +168,47 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
docker_version,
docker_info,
docker_system_df,
stacks,
) = await asyncio.gather(
self.portainer.get_containers(endpoint.id),
self.portainer.docker_version(endpoint.id),
self.portainer.docker_info(endpoint.id),
self.portainer.get_containers(endpoint_id=endpoint.id),
self.portainer.docker_version(endpoint_id=endpoint.id),
self.portainer.docker_info(endpoint_id=endpoint.id),
self.portainer.docker_system_df(endpoint.id),
self.portainer.get_stacks(endpoint_id=endpoint.id),
)
prev_endpoint = self.data.get(endpoint.id) if self.data else None
container_map: dict[str, PortainerContainerData] = {}
stack_map: dict[str, PortainerStackData] = {
stack.name: PortainerStackData(stack=stack, container_count=0)
for stack in stacks
}
# Map containers, started and stopped
for container in containers:
container_name = self._get_container_name(container.names[0])
prev_container = (
prev_endpoint.containers[container_name]
prev_endpoint.containers.get(container_name)
if prev_endpoint
else None
)
# Check if container belongs to a stack via docker compose label
stack_name: str | None = (
container.labels.get("com.docker.compose.project")
if container.labels
else None
)
if stack_name and (stack_data := stack_map.get(stack_name)):
stack_data.container_count += 1
container_map[container_name] = PortainerContainerData(
container=container,
stats=None,
stats_pre=prev_container.stats if prev_container else None,
stack=stack_map[stack_name].stack
if stack_name and stack_name in stack_map
else None,
)
# Separately fetch stats for running containers
@@ -229,6 +263,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
docker_version=docker_version,
docker_info=docker_info,
docker_system_df=docker_system_df,
stacks=stack_map,
)
self._async_add_remove_endpoints(mapped_endpoints)
@@ -256,6 +291,17 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
_LOGGER.debug("New containers found: %s", new_containers)
self.known_containers.update(new_containers)
# Stack management
current_stacks = {
(endpoint.id, stack_name)
for endpoint in mapped_endpoints.values()
for stack_name in endpoint.stacks
}
new_stacks = current_stacks - self.known_stacks
if new_stacks:
_LOGGER.debug("New stacks found: %s", new_stacks)
self.known_stacks.update(new_stacks)
def _get_container_name(self, container_name: str) -> str:
"""Sanitize to get a proper container name."""
return container_name.replace("/", " ").strip()

View File

@@ -11,6 +11,7 @@ from .coordinator import (
PortainerContainerData,
PortainerCoordinator,
PortainerCoordinatorData,
PortainerStackData,
)
@@ -86,9 +87,13 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
),
model="Container",
name=self.device_name,
# If the container belongs to a stack, nest it under the stack
# else it's the endpoint
via_device=(
DOMAIN,
f"{self.coordinator.config_entry.entry_id}_{self.endpoint_id}",
f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_{device_info.stack.name}"
if device_info.stack
else f"{coordinator.config_entry.entry_id}_{self.endpoint_id}",
),
translation_key=None if self.device_name else "unknown_container",
entry_type=DeviceEntryType.SERVICE,
@@ -107,3 +112,54 @@ class PortainerContainerEntity(PortainerCoordinatorEntity):
def container_data(self) -> PortainerContainerData:
"""Return the coordinator data for this container."""
return self.coordinator.data[self.endpoint_id].containers[self.device_name]
class PortainerStackEntity(PortainerCoordinatorEntity):
"""Base implementation for Portainer stack."""
def __init__(
self,
device_info: PortainerStackData,
coordinator: PortainerCoordinator,
via_device: PortainerCoordinatorData,
) -> None:
"""Initialize a Portainer stack."""
super().__init__(coordinator)
self._device_info = device_info
self.stack_id = device_info.stack.id
self.device_name = device_info.stack.name
self.endpoint_id = via_device.endpoint.id
self.endpoint_name = via_device.endpoint.name
self._attr_device_info = DeviceInfo(
identifiers={
(
DOMAIN,
f"{coordinator.config_entry.entry_id}_{self.endpoint_id}_{self.device_name}",
)
},
manufacturer=DEFAULT_NAME,
configuration_url=URL(
f"{coordinator.config_entry.data[CONF_URL]}#!/{self.endpoint_id}/docker/stacks/{self.device_name}"
),
model="Stack",
name=self.device_name,
via_device=(
DOMAIN,
f"{coordinator.config_entry.entry_id}_{self.endpoint_id}",
),
)
@property
def available(self) -> bool:
"""Return if the stack is available."""
return (
super().available
and self.endpoint_id in self.coordinator.data
and self.device_name in self.coordinator.data[self.endpoint_id].stacks
)
@property
def stack_data(self) -> PortainerStackData:
"""Return the coordinator data for this stack."""
return self.coordinator.data[self.endpoint_id].stacks[self.device_name]

View File

@@ -70,6 +70,12 @@
"operating_system_version": {
"default": "mdi:alpha-v-box"
},
"stack_containers_count": {
"default": "mdi:server"
},
"stack_type": {
"default": "mdi:server"
},
"volume_disk_usage_total_size": {
"default": "mdi:harddisk"
}
@@ -80,6 +86,12 @@
"state": {
"on": "mdi:arrow-up-box"
}
},
"stack": {
"default": "mdi:arrow-down-box",
"state": {
"on": "mdi:arrow-up-box"
}
}
}
},

View File

@@ -17,15 +17,18 @@ from homeassistant.const import PERCENTAGE, UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import STACK_TYPE_COMPOSE, STACK_TYPE_KUBERNETES, STACK_TYPE_SWARM
from .coordinator import (
PortainerConfigEntry,
PortainerContainerData,
PortainerCoordinator,
PortainerStackData,
)
from .entity import (
PortainerContainerEntity,
PortainerCoordinatorData,
PortainerEndpointEntity,
PortainerStackEntity,
)
PARALLEL_UPDATES = 1
@@ -45,6 +48,13 @@ class PortainerEndpointSensorEntityDescription(SensorEntityDescription):
value_fn: Callable[[PortainerCoordinatorData], StateType]
@dataclass(frozen=True, kw_only=True)
class PortainerStackSensorEntityDescription(SensorEntityDescription):
"""Class to hold Portainer stack sensor description."""
value_fn: Callable[[PortainerStackData], StateType]
CONTAINER_SENSORS: tuple[PortainerContainerSensorEntityDescription, ...] = (
PortainerContainerSensorEntityDescription(
key="image",
@@ -278,6 +288,32 @@ ENDPOINT_SENSORS: tuple[PortainerEndpointSensorEntityDescription, ...] = (
),
)
STACK_SENSORS: tuple[PortainerStackSensorEntityDescription, ...] = (
PortainerStackSensorEntityDescription(
key="stack_type",
translation_key="stack_type",
value_fn=lambda data: (
"swarm"
if data.stack.type == STACK_TYPE_SWARM
else "compose"
if data.stack.type == STACK_TYPE_COMPOSE
else "kubernetes"
if data.stack.type == STACK_TYPE_KUBERNETES
else None
),
device_class=SensorDeviceClass.ENUM,
options=["swarm", "compose", "kubernetes"],
entity_category=EntityCategory.DIAGNOSTIC,
),
PortainerStackSensorEntityDescription(
key="stack_containers_count",
translation_key="stack_containers_count",
value_fn=lambda data: data.container_count,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant,
@@ -315,8 +351,24 @@ async def async_setup_entry(
for entity_description in CONTAINER_SENSORS
)
def _async_add_new_stacks(
stacks: list[tuple[PortainerCoordinatorData, PortainerStackData]],
) -> None:
"""Add new stack sensors."""
async_add_entities(
PortainerStackSensor(
coordinator,
entity_description,
stack,
endpoint,
)
for (endpoint, stack) in stacks
for entity_description in STACK_SENSORS
)
coordinator.new_endpoints_callbacks.append(_async_add_new_endpoints)
coordinator.new_containers_callbacks.append(_async_add_new_containers)
coordinator.new_stacks_callbacks.append(_async_add_new_stacks)
_async_add_new_endpoints(
[
@@ -332,6 +384,13 @@ async def async_setup_entry(
for container in endpoint.containers.values()
]
)
_async_add_new_stacks(
[
(endpoint, stack)
for endpoint in coordinator.data.values()
for stack in endpoint.stacks.values()
]
)
class PortainerContainerSensor(PortainerContainerEntity, SensorEntity):
@@ -380,3 +439,27 @@ class PortainerEndpointSensor(PortainerEndpointEntity, SensorEntity):
"""Return the state of the sensor."""
endpoint_data = self.coordinator.data[self._device_info.endpoint.id]
return self.entity_description.value_fn(endpoint_data)
class PortainerStackSensor(PortainerStackEntity, SensorEntity):
"""Representation of a Portainer stack sensor."""
entity_description: PortainerStackSensorEntityDescription
def __init__(
self,
coordinator: PortainerCoordinator,
entity_description: PortainerStackSensorEntityDescription,
device_info: PortainerStackData,
via_device: PortainerCoordinatorData,
) -> None:
"""Initialize the Portainer stack sensor."""
self.entity_description = entity_description
super().__init__(device_info, coordinator, via_device)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.stack.id}_{entity_description.key}"
@property
def native_value(self) -> StateType:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.stack_data)

View File

@@ -147,6 +147,18 @@
"operating_system_version": {
"name": "Operating system version"
},
"stack_containers_count": {
"name": "Containers",
"unit_of_measurement": "containers"
},
"stack_type": {
"name": "Type",
"state": {
"compose": "Compose",
"kubernetes": "Kubernetes",
"swarm": "Swarm"
}
},
"volume_disk_usage_total_size": {
"name": "Volume disk usage total size"
}
@@ -154,6 +166,9 @@
"switch": {
"container": {
"name": "Container"
},
"stack": {
"name": "Stack"
}
}
},

View File

@@ -23,9 +23,17 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import PortainerConfigEntry
from .const import DOMAIN
from .coordinator import PortainerContainerData, PortainerCoordinator
from .entity import PortainerContainerEntity, PortainerCoordinatorData
from .const import DOMAIN, STACK_STATUS_ACTIVE
from .coordinator import (
PortainerContainerData,
PortainerCoordinator,
PortainerStackData,
)
from .entity import (
PortainerContainerEntity,
PortainerCoordinatorData,
PortainerStackEntity,
)
@dataclass(frozen=True, kw_only=True)
@@ -37,10 +45,19 @@ class PortainerSwitchEntityDescription(SwitchEntityDescription):
turn_off_fn: Callable[[str, Portainer, int, str], Coroutine[Any, Any, None]]
@dataclass(frozen=True, kw_only=True)
class PortainerStackSwitchEntityDescription(SwitchEntityDescription):
"""Class to hold Portainer stack switch description."""
is_on_fn: Callable[[PortainerStackData], bool | None]
turn_on_fn: Callable[[str, Portainer, int, int], Coroutine[Any, Any, None]]
turn_off_fn: Callable[[str, Portainer, int, int], Coroutine[Any, Any, None]]
PARALLEL_UPDATES = 1
async def perform_action(
async def perform_container_action(
action: str, portainer: Portainer, endpoint_id: int, container_id: str
) -> None:
"""Perform an action on a container."""
@@ -70,14 +87,52 @@ async def perform_action(
) from err
SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = (
async def perform_stack_action(
action: str, portainer: Portainer, endpoint_id: int, stack_id: int
) -> None:
"""Perform an action on a stack."""
try:
match action:
case "start":
await portainer.start_stack(stack_id, endpoint_id)
case "stop":
await portainer.stop_stack(stack_id, endpoint_id)
except PortainerAuthenticationError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="invalid_auth_no_details",
) from err
except PortainerConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_no_details",
) from err
except PortainerTimeoutError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="timeout_connect_no_details",
) from err
CONTAINER_SWITCHES: tuple[PortainerSwitchEntityDescription, ...] = (
PortainerSwitchEntityDescription(
key="container",
translation_key="container",
device_class=SwitchDeviceClass.SWITCH,
is_on_fn=lambda data: data.container.state == "running",
turn_on_fn=perform_action,
turn_off_fn=perform_action,
turn_on_fn=perform_container_action,
turn_off_fn=perform_container_action,
),
)
STACK_SWITCHES: tuple[PortainerStackSwitchEntityDescription, ...] = (
PortainerStackSwitchEntityDescription(
key="stack",
translation_key="stack",
device_class=SwitchDeviceClass.SWITCH,
is_on_fn=lambda data: data.stack.status == STACK_STATUS_ACTIVE,
turn_on_fn=perform_stack_action,
turn_off_fn=perform_stack_action,
),
)
@@ -102,10 +157,26 @@ async def async_setup_entry(
endpoint,
)
for (endpoint, container) in containers
for entity_description in SWITCHES
for entity_description in CONTAINER_SWITCHES
)
def _async_add_new_stacks(
stacks: list[tuple[PortainerCoordinatorData, PortainerStackData]],
) -> None:
"""Add new stack switch sensors."""
async_add_entities(
PortainerStackSwitch(
coordinator,
entity_description,
stack,
endpoint,
)
for (endpoint, stack) in stacks
for entity_description in STACK_SWITCHES
)
coordinator.new_containers_callbacks.append(_async_add_new_containers)
coordinator.new_stacks_callbacks.append(_async_add_new_stacks)
_async_add_new_containers(
[
(endpoint, container)
@@ -113,6 +184,13 @@ async def async_setup_entry(
for container in endpoint.containers.values()
]
)
_async_add_new_stacks(
[
(endpoint, stack)
for endpoint in coordinator.data.values()
for stack in endpoint.stacks.values()
]
)
class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity):
@@ -157,3 +235,47 @@ class PortainerContainerSwitch(PortainerContainerEntity, SwitchEntity):
self.container_data.container.id,
)
await self.coordinator.async_request_refresh()
class PortainerStackSwitch(PortainerStackEntity, SwitchEntity):
"""Representation of a Portainer stack switch."""
entity_description: PortainerStackSwitchEntityDescription
def __init__(
self,
coordinator: PortainerCoordinator,
entity_description: PortainerStackSwitchEntityDescription,
device_info: PortainerStackData,
via_device: PortainerCoordinatorData,
) -> None:
"""Initialize the Portainer stack switch."""
self.entity_description = entity_description
super().__init__(device_info, coordinator, via_device)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.stack.id}_{entity_description.key}"
@property
def is_on(self) -> bool | None:
"""Return the state of the device."""
return self.entity_description.is_on_fn(self.stack_data)
async def async_turn_on(self, **kwargs: Any) -> None:
"""Start (turn on) the stack."""
await self.entity_description.turn_on_fn(
"start",
self.coordinator.portainer,
self.endpoint_id,
self.stack_data.stack.id,
)
await self.coordinator.async_request_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Stop (turn off) the stack."""
await self.entity_description.turn_off_fn(
"stop",
self.coordinator.portainer,
self.endpoint_id,
self.stack_data.stack.id,
)
await self.coordinator.async_request_refresh()

View File

@@ -7,7 +7,7 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "silver",
"requirements": ["powerfox==2.1.0"],
"requirements": ["powerfox==2.1.1"],
"zeroconf": [
{
"name": "powerfox*",

View File

@@ -2,12 +2,18 @@
from __future__ import annotations
from collections.abc import Mapping
from typing import Any
from powerfox import PowerfoxAuthenticationError, PowerfoxConnectionError, PowerfoxLocal
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
SOURCE_USER,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_API_KEY, CONF_HOST
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
@@ -21,6 +27,12 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
}
)
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_API_KEY): str,
}
)
class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Powerfox Local."""
@@ -33,7 +45,7 @@ class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step."""
errors: dict[str, str] = {}
errors = {}
if user_input is not None:
self._host = user_input[CONF_HOST]
@@ -47,7 +59,15 @@ class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
except PowerfoxConnectionError:
errors["base"] = "cannot_connect"
else:
return self._async_create_entry()
if self.source == SOURCE_USER:
return self._async_create_entry()
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(),
data={
CONF_HOST: self._host,
CONF_API_KEY: self._api_key,
},
)
return self.async_show_form(
step_id="user",
@@ -84,6 +104,51 @@ class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a confirmation flow for zeroconf discovery."""
return self._async_create_entry()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle re-authentication flow."""
self._host = entry_data[CONF_HOST]
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 = {}
if user_input is not None:
self._api_key = user_input[CONF_API_KEY]
reauth_entry = self._get_reauth_entry()
client = PowerfoxLocal(
host=reauth_entry.data[CONF_HOST],
api_key=user_input[CONF_API_KEY],
session=async_get_clientsession(self.hass),
)
try:
await client.value()
except PowerfoxAuthenticationError:
errors["base"] = "invalid_auth"
except PowerfoxConnectionError:
errors["base"] = "cannot_connect"
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates=user_input,
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=STEP_REAUTH_DATA_SCHEMA,
errors=errors,
)
async def async_step_reconfigure(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reconfiguration."""
return await self.async_step_user()
def _async_create_entry(self) -> ConfigFlowResult:
"""Create a config entry."""
return self.async_create_entry(
@@ -103,5 +168,8 @@ class PowerfoxLocalConfigFlow(ConfigFlow, domain=DOMAIN):
)
await client.value()
await self.async_set_unique_id(self._device_id)
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})
await self.async_set_unique_id(self._device_id, raise_on_progress=False)
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
else:
self._abort_if_unique_id_configured(updates={CONF_HOST: self._host})

View File

@@ -2,11 +2,17 @@
from __future__ import annotations
from powerfox import LocalResponse, PowerfoxConnectionError, PowerfoxLocal
from powerfox import (
LocalResponse,
PowerfoxAuthenticationError,
PowerfoxConnectionError,
PowerfoxLocal,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_KEY, CONF_HOST
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
@@ -40,6 +46,12 @@ class PowerfoxLocalDataUpdateCoordinator(DataUpdateCoordinator[LocalResponse]):
"""Fetch data from the local poweropti."""
try:
return await self.client.value()
except PowerfoxAuthenticationError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_auth",
translation_placeholders={"error": str(err)},
) from err
except PowerfoxConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,

View File

@@ -0,0 +1,24 @@
"""Support for Powerfox Local diagnostics."""
from __future__ import annotations
from typing import Any
from homeassistant.core import HomeAssistant
from .coordinator import PowerfoxLocalConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: PowerfoxLocalConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for Powerfox Local config entry."""
coordinator = entry.runtime_data
return {
"power": coordinator.data.power,
"energy_usage": coordinator.data.energy_usage,
"energy_usage_high_tariff": coordinator.data.energy_usage_high_tariff,
"energy_usage_low_tariff": coordinator.data.energy_usage_low_tariff,
"energy_return": coordinator.data.energy_return,
}

View File

@@ -6,8 +6,8 @@
"documentation": "https://www.home-assistant.io/integrations/powerfox_local",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["powerfox==2.1.0"],
"quality_scale": "platinum",
"requirements": ["powerfox==2.1.1"],
"zeroconf": [
{
"name": "powerfox*",

View File

@@ -43,12 +43,12 @@ 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
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: done
@@ -74,7 +74,7 @@ rules:
status: exempt
comment: |
There is no need for icon translations.
reconfiguration-flow: todo
reconfiguration-flow: done
repair-issues:
status: exempt
comment: |

View File

@@ -2,13 +2,26 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]"
},
"step": {
"reauth_confirm": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]"
},
"data_description": {
"api_key": "[%key:component::powerfox_local::config::step::user::data_description::api_key%]"
},
"description": "The API key for your Poweropti device is no longer valid.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"api_key": "[%key:common::config_flow::data::api_key%]",
@@ -43,6 +56,9 @@
}
},
"exceptions": {
"invalid_auth": {
"message": "Error while authenticating with the device: {error}"
},
"update_failed": {
"message": "Error while updating the device: {error}"
}

View File

@@ -40,6 +40,7 @@ from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.SENSOR,
]

View File

@@ -74,16 +74,20 @@ def _get_nodes_data(data: dict[str, Any]) -> list[dict[str, Any]]:
raise ProxmoxSSLError from err
except ConnectTimeout as err:
raise ProxmoxConnectTimeout from err
except (ResourceException, requests.exceptions.ConnectionError) as err:
except ResourceException as err:
raise ProxmoxNoNodesFound from err
except requests.exceptions.ConnectionError as err:
raise ProxmoxConnectionError from err
nodes_data: list[dict[str, Any]] = []
for node in nodes:
try:
vms = client.nodes(node["node"]).qemu.get()
containers = client.nodes(node["node"]).lxc.get()
except (ResourceException, requests.exceptions.ConnectionError) as err:
except ResourceException as err:
raise ProxmoxNoNodesFound from err
except requests.exceptions.ConnectionError as err:
raise ProxmoxConnectionError from err
nodes_data.append(
{
@@ -197,18 +201,30 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
"""Validate the user input. Return nodes data and/or errors."""
errors: dict[str, str] = {}
proxmox_nodes: list[dict[str, Any]] = []
err: ProxmoxError | None = None
try:
proxmox_nodes = await self.hass.async_add_executor_job(
_get_nodes_data, user_input
)
except ProxmoxConnectTimeout:
except ProxmoxConnectTimeout as exc:
errors["base"] = "connect_timeout"
except ProxmoxAuthenticationError:
err = exc
except ProxmoxAuthenticationError as exc:
errors["base"] = "invalid_auth"
except ProxmoxSSLError:
err = exc
except ProxmoxSSLError as exc:
errors["base"] = "ssl_error"
except ProxmoxNoNodesFound:
err = exc
except ProxmoxNoNodesFound as exc:
errors["base"] = "no_nodes_found"
err = exc
except ProxmoxConnectionError as exc:
errors["base"] = "cannot_connect"
err = exc
if err is not None:
_LOGGER.debug("Error: %s: %s", errors["base"], err)
return proxmox_nodes, errors
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
@@ -227,6 +243,8 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
return self.async_abort(reason="ssl_error")
except ProxmoxNoNodesFound:
return self.async_abort(reason="no_nodes_found")
except ProxmoxConnectionError:
return self.async_abort(reason="cannot_connect")
return self.async_create_entry(
title=import_data[CONF_HOST],
@@ -234,17 +252,25 @@ class ProxmoxveConfigFlow(ConfigFlow, domain=DOMAIN):
)
class ProxmoxNoNodesFound(HomeAssistantError):
class ProxmoxError(HomeAssistantError):
"""Base class for Proxmox VE errors."""
class ProxmoxNoNodesFound(ProxmoxError):
"""Error to indicate no nodes found."""
class ProxmoxConnectTimeout(HomeAssistantError):
class ProxmoxConnectTimeout(ProxmoxError):
"""Error to indicate a connection timeout."""
class ProxmoxSSLError(HomeAssistantError):
class ProxmoxSSLError(ProxmoxError):
"""Error to indicate an SSL error."""
class ProxmoxAuthenticationError(HomeAssistantError):
class ProxmoxAuthenticationError(ProxmoxError):
"""Error to indicate an authentication error."""
class ProxmoxConnectionError(ProxmoxError):
"""Error to indicate a connection error."""

View File

@@ -101,12 +101,18 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
except (ResourceException, requests.exceptions.ConnectionError) as err:
except ResourceException as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="no_nodes_found",
translation_placeholders={"error": repr(err)},
) from err
except requests.exceptions.ConnectionError as err:
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
async def _async_update_data(self) -> dict[str, ProxmoxNodeData]:
"""Fetch data from Proxmox VE API."""
@@ -133,12 +139,18 @@ class ProxmoxCoordinator(DataUpdateCoordinator[dict[str, ProxmoxNodeData]]):
translation_key="timeout_connect",
translation_placeholders={"error": repr(err)},
) from err
except (ResourceException, requests.exceptions.ConnectionError) as err:
except ResourceException as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="no_nodes_found",
translation_placeholders={"error": repr(err)},
) from err
except requests.exceptions.ConnectionError as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
translation_placeholders={"error": repr(err)},
) from err
data: dict[str, ProxmoxNodeData] = {}
for node, (vms, containers) in zip(nodes, vms_containers, strict=True):

View File

@@ -13,6 +13,71 @@
"stop": {
"default": "mdi:stop"
}
},
"sensor": {
"container_cpu": {
"default": "mdi:cpu-64-bit"
},
"container_disk": {
"default": "mdi:harddisk"
},
"container_max_cpu": {
"default": "mdi:cpu-64-bit"
},
"container_max_disk": {
"default": "mdi:harddisk"
},
"container_max_memory": {
"default": "mdi:memory"
},
"container_memory": {
"default": "mdi:memory"
},
"container_status": {
"default": "mdi:server"
},
"node_cpu": {
"default": "mdi:cpu-64-bit"
},
"node_disk": {
"default": "mdi:harddisk"
},
"node_max_cpu": {
"default": "mdi:cpu-64-bit"
},
"node_max_disk": {
"default": "mdi:harddisk"
},
"node_max_memory": {
"default": "mdi:memory"
},
"node_memory": {
"default": "mdi:memory"
},
"node_status": {
"default": "mdi:server"
},
"vm_cpu": {
"default": "mdi:cpu-64-bit"
},
"vm_disk": {
"default": "mdi:harddisk"
},
"vm_max_cpu": {
"default": "mdi:cpu-64-bit"
},
"vm_max_disk": {
"default": "mdi:harddisk"
},
"vm_max_memory": {
"default": "mdi:memory"
},
"vm_memory": {
"default": "mdi:memory"
},
"vm_status": {
"default": "mdi:server"
}
}
}
}

View File

@@ -0,0 +1,386 @@
"""Sensor platform for Proxmox VE integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from homeassistant.components.sensor import (
EntityCategory,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import PERCENTAGE, UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import ProxmoxConfigEntry, ProxmoxCoordinator, ProxmoxNodeData
from .entity import ProxmoxContainerEntity, ProxmoxNodeEntity, ProxmoxVMEntity
@dataclass(frozen=True, kw_only=True)
class ProxmoxNodeSensorEntityDescription(SensorEntityDescription):
"""Class to hold Proxmox node sensor description."""
value_fn: Callable[[ProxmoxNodeData], StateType]
@dataclass(frozen=True, kw_only=True)
class ProxmoxVMSensorEntityDescription(SensorEntityDescription):
"""Class to hold Proxmox VM sensor description."""
value_fn: Callable[[dict[str, Any]], StateType]
@dataclass(frozen=True, kw_only=True)
class ProxmoxContainerSensorEntityDescription(SensorEntityDescription):
"""Class to hold Proxmox container sensor description."""
value_fn: Callable[[dict[str, Any]], StateType]
NODE_SENSORS: tuple[ProxmoxNodeSensorEntityDescription, ...] = (
ProxmoxNodeSensorEntityDescription(
key="node_cpu",
translation_key="node_cpu",
value_fn=lambda data: data.node["cpu"] * 100,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxNodeSensorEntityDescription(
key="node_max_cpu",
translation_key="node_max_cpu",
value_fn=lambda data: data.node["maxcpu"],
),
ProxmoxNodeSensorEntityDescription(
key="node_disk",
translation_key="node_disk",
value_fn=lambda data: data.node["disk"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxNodeSensorEntityDescription(
key="node_max_disk",
translation_key="node_max_disk",
value_fn=lambda data: data.node["maxdisk"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxNodeSensorEntityDescription(
key="node_memory",
translation_key="node_memory",
value_fn=lambda data: data.node["mem"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxNodeSensorEntityDescription(
key="node_max_memory",
translation_key="node_max_memory",
value_fn=lambda data: data.node["maxmem"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxNodeSensorEntityDescription(
key="node_status",
translation_key="node_status",
value_fn=lambda data: data.node["status"],
device_class=SensorDeviceClass.ENUM,
options=["online", "offline"],
),
)
VM_SENSORS: tuple[ProxmoxVMSensorEntityDescription, ...] = (
ProxmoxVMSensorEntityDescription(
key="vm_max_cpu",
translation_key="vm_max_cpu",
value_fn=lambda data: data["cpus"],
),
ProxmoxVMSensorEntityDescription(
key="vm_cpu",
translation_key="vm_cpu",
value_fn=lambda data: data["cpu"] * 100,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxVMSensorEntityDescription(
key="vm_memory",
translation_key="vm_memory",
value_fn=lambda data: data["mem"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxVMSensorEntityDescription(
key="vm_max_memory",
translation_key="vm_max_memory",
value_fn=lambda data: data["maxmem"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxVMSensorEntityDescription(
key="vm_disk",
translation_key="vm_disk",
value_fn=lambda data: data["disk"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxVMSensorEntityDescription(
key="vm_max_disk",
translation_key="vm_max_disk",
value_fn=lambda data: data["maxdisk"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxVMSensorEntityDescription(
key="vm_status",
translation_key="vm_status",
value_fn=lambda data: data["status"],
device_class=SensorDeviceClass.ENUM,
options=["running", "stopped", "suspended"],
),
)
CONTAINER_SENSORS: tuple[ProxmoxContainerSensorEntityDescription, ...] = (
ProxmoxContainerSensorEntityDescription(
key="container_max_cpu",
translation_key="container_max_cpu",
value_fn=lambda data: data["cpus"],
),
ProxmoxContainerSensorEntityDescription(
key="container_cpu",
translation_key="container_cpu",
value_fn=lambda data: data["cpu"] * 100,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxContainerSensorEntityDescription(
key="container_memory",
translation_key="container_memory",
value_fn=lambda data: data["mem"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxContainerSensorEntityDescription(
key="container_max_memory",
translation_key="container_max_memory",
value_fn=lambda data: data["maxmem"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxContainerSensorEntityDescription(
key="container_disk",
translation_key="container_disk",
value_fn=lambda data: data["disk"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxContainerSensorEntityDescription(
key="container_max_disk",
translation_key="container_max_disk",
value_fn=lambda data: data["maxdisk"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxContainerSensorEntityDescription(
key="container_status",
translation_key="container_status",
value_fn=lambda data: data["status"],
device_class=SensorDeviceClass.ENUM,
options=["running", "stopped", "suspended"],
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ProxmoxConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Proxmox VE sensors."""
coordinator = entry.runtime_data
def _async_add_new_nodes(nodes: list[ProxmoxNodeData]) -> None:
"""Add new node sensors."""
async_add_entities(
ProxmoxNodeSensor(coordinator, entity_description, node)
for node in nodes
for entity_description in NODE_SENSORS
)
def _async_add_new_vms(
vms: list[tuple[ProxmoxNodeData, dict[str, Any]]],
) -> None:
"""Add new VM sensors."""
async_add_entities(
ProxmoxVMSensor(coordinator, entity_description, vm, node_data)
for (node_data, vm) in vms
for entity_description in VM_SENSORS
)
def _async_add_new_containers(
containers: list[tuple[ProxmoxNodeData, dict[str, Any]]],
) -> None:
"""Add new container sensors."""
async_add_entities(
ProxmoxContainerSensor(
coordinator, entity_description, container, node_data
)
for (node_data, container) in containers
for entity_description in CONTAINER_SENSORS
)
coordinator.new_nodes_callbacks.append(_async_add_new_nodes)
coordinator.new_vms_callbacks.append(_async_add_new_vms)
coordinator.new_containers_callbacks.append(_async_add_new_containers)
_async_add_new_nodes(
[
node_data
for node_data in coordinator.data.values()
if node_data.node["node"] in coordinator.known_nodes
]
)
_async_add_new_vms(
[
(node_data, vm_data)
for node_data in coordinator.data.values()
for vmid, vm_data in node_data.vms.items()
if (node_data.node["node"], vmid) in coordinator.known_vms
]
)
_async_add_new_containers(
[
(node_data, container_data)
for node_data in coordinator.data.values()
for vmid, container_data in node_data.containers.items()
if (node_data.node["node"], vmid) in coordinator.known_containers
]
)
class ProxmoxNodeSensor(ProxmoxNodeEntity, SensorEntity):
"""Representation of a Proxmox VE node sensor."""
entity_description: ProxmoxNodeSensorEntityDescription
def __init__(
self,
coordinator: ProxmoxCoordinator,
entity_description: ProxmoxNodeSensorEntityDescription,
node_data: ProxmoxNodeData,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator, node_data)
self.entity_description = entity_description
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{node_data.node['id']}_{entity_description.key}"
@property
def native_value(self) -> StateType:
"""Return the native value of the sensor."""
return self.entity_description.value_fn(self.coordinator.data[self.device_name])
class ProxmoxVMSensor(ProxmoxVMEntity, SensorEntity):
"""Represents a Proxmox VE VM sensor."""
entity_description: ProxmoxVMSensorEntityDescription
def __init__(
self,
coordinator: ProxmoxCoordinator,
entity_description: ProxmoxVMSensorEntityDescription,
vm_data: dict[str, Any],
node_data: ProxmoxNodeData,
) -> None:
"""Initialize the Proxmox VM sensor."""
self.entity_description = entity_description
super().__init__(coordinator, vm_data, node_data)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
@property
def native_value(self) -> StateType:
"""Return the native value of the sensor."""
return self.entity_description.value_fn(self.vm_data)
class ProxmoxContainerSensor(ProxmoxContainerEntity, SensorEntity):
"""Represents a Proxmox VE container sensor."""
entity_description: ProxmoxContainerSensorEntityDescription
def __init__(
self,
coordinator: ProxmoxCoordinator,
entity_description: ProxmoxContainerSensorEntityDescription,
container_data: dict[str, Any],
node_data: ProxmoxNodeData,
) -> None:
"""Initialize the Proxmox container sensor."""
self.entity_description = entity_description
super().__init__(coordinator, container_data, node_data)
self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{self.device_id}_{entity_description.key}"
@property
def native_value(self) -> StateType:
"""Return the native value of the sensor."""
return self.entity_description.value_fn(self.container_data)

View File

@@ -77,6 +77,85 @@
"stop_all": {
"name": "Stop all"
}
},
"sensor": {
"container_cpu": {
"name": "CPU usage"
},
"container_disk": {
"name": "Disk usage"
},
"container_max_cpu": {
"name": "Max CPU"
},
"container_max_disk": {
"name": "Max disk usage"
},
"container_max_memory": {
"name": "Max memory usage"
},
"container_memory": {
"name": "Memory usage"
},
"container_status": {
"name": "Status",
"state": {
"running": "Running",
"stopped": "Stopped",
"suspended": "Suspended"
}
},
"node_cpu": {
"name": "CPU usage"
},
"node_disk": {
"name": "Disk usage"
},
"node_max_cpu": {
"name": "Max CPU"
},
"node_max_disk": {
"name": "Max disk usage"
},
"node_max_memory": {
"name": "Max memory usage"
},
"node_memory": {
"name": "Memory usage"
},
"node_status": {
"name": "Status",
"state": {
"offline": "Offline",
"online": "Online"
}
},
"vm_cpu": {
"name": "CPU usage"
},
"vm_disk": {
"name": "Disk usage"
},
"vm_max_cpu": {
"name": "Max CPU"
},
"vm_max_disk": {
"name": "Max disk usage"
},
"vm_max_memory": {
"name": "Max memory usage"
},
"vm_memory": {
"name": "Memory usage"
},
"vm_status": {
"name": "Status",
"state": {
"running": "Running",
"stopped": "Stopped",
"suspended": "Suspended"
}
}
}
},
"exceptions": {
@@ -109,6 +188,10 @@
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection error occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "[%key:component::proxmoxve::issues::deprecated_yaml_import_issue_connect_timeout::title%]"
},
"deprecated_yaml_import_issue_connect_timeout": {
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, a connection timeout occurred. Please correct your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
"title": "The {integration_title} YAML configuration is being removed"

View File

@@ -6,7 +6,7 @@ import logging
from typing import Any
import aiohttp
from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
from pyrainbird.async_client import AsyncRainbirdController, CreateController
from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException
from homeassistant.const import (
@@ -77,12 +77,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: RainbirdConfigEntry) ->
clientsession = async_create_clientsession()
_async_register_clientsession_shutdown(hass, entry, clientsession)
controller = AsyncRainbirdController(
AsyncRainbirdClient(
clientsession,
entry.data[CONF_HOST],
entry.data[CONF_PASSWORD],
)
controller = CreateController(
clientsession,
entry.data[CONF_HOST],
entry.data[CONF_PASSWORD],
)
if not (await _async_fix_unique_id(hass, controller, entry)):

View File

@@ -7,7 +7,7 @@ from collections.abc import Mapping
import logging
from typing import Any
from pyrainbird.async_client import AsyncRainbirdClient, AsyncRainbirdController
from pyrainbird.async_client import CreateController
from pyrainbird.data import WifiParams
from pyrainbird.exceptions import RainbirdApiException, RainbirdAuthException
import voluptuous as vol
@@ -137,13 +137,7 @@ class RainbirdConfigFlowHandler(ConfigFlow, domain=DOMAIN):
Raises a ConfigFlowError on failure.
"""
clientsession = async_create_clientsession()
controller = AsyncRainbirdController(
AsyncRainbirdClient(
clientsession,
host,
password,
)
)
controller = CreateController(clientsession, host, password)
try:
async with asyncio.timeout(TIMEOUT_SECONDS):
return await asyncio.gather(

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyrainbird"],
"requirements": ["pyrainbird==6.0.5"]
"requirements": ["pyrainbird==6.1.0"]
}

View File

@@ -92,17 +92,6 @@ SERVICE_AC_SET_SCHEDULES_SCHEMA = SERVICE_VEHICLE_SCHEMA.extend(
}
)
SERVICE_AC_CANCEL = "ac_cancel"
SERVICE_AC_START = "ac_start"
SERVICE_CHARGE_SET_SCHEDULES = "charge_set_schedules"
SERVICE_AC_SET_SCHEDULES = "ac_set_schedules"
SERVICES = [
SERVICE_AC_CANCEL,
SERVICE_AC_START,
SERVICE_CHARGE_SET_SCHEDULES,
SERVICE_AC_SET_SCHEDULES,
]
async def ac_cancel(service_call: ServiceCall) -> None:
"""Cancel A/C."""
@@ -197,25 +186,25 @@ def async_setup_services(hass: HomeAssistant) -> None:
hass.services.async_register(
DOMAIN,
SERVICE_AC_CANCEL,
"ac_cancel",
ac_cancel,
schema=SERVICE_VEHICLE_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_AC_START,
"ac_start",
ac_start,
schema=SERVICE_AC_START_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_CHARGE_SET_SCHEDULES,
"charge_set_schedules",
charge_set_schedules,
schema=SERVICE_CHARGE_SET_SCHEDULES_SCHEMA,
)
hass.services.async_register(
DOMAIN,
SERVICE_AC_SET_SCHEDULES,
"ac_set_schedules",
ac_set_schedules,
schema=SERVICE_AC_SET_SCHEDULES_SCHEMA,
)

View File

@@ -6,6 +6,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from roborock.data import CleanFluidStatus, RoborockStateCode
from roborock.roborock_message import RoborockZeoProtocol
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
@@ -15,9 +16,15 @@ from homeassistant.components.binary_sensor import (
from homeassistant.const import ATTR_BATTERY_CHARGING, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
from .entity import RoborockCoordinatedEntityV1
from .coordinator import (
RoborockConfigEntry,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
RoborockWashingMachineUpdateCoordinator,
)
from .entity import RoborockCoordinatedEntityA01, RoborockCoordinatedEntityV1
from .models import DeviceState
PARALLEL_UPDATES = 0
@@ -34,6 +41,14 @@ class RoborockBinarySensorDescription(BinarySensorEntityDescription):
"""Whether this sensor is for the dock."""
@dataclass(frozen=True, kw_only=True)
class RoborockBinarySensorDescriptionA01(BinarySensorEntityDescription):
"""A class that describes Roborock A01 binary sensors."""
data_protocol: RoborockZeoProtocol
value_fn: Callable[[StateType], bool]
BINARY_SENSOR_DESCRIPTIONS = [
RoborockBinarySensorDescription(
key="dry_status",
@@ -111,13 +126,33 @@ BINARY_SENSOR_DESCRIPTIONS = [
]
ZEO_BINARY_SENSOR_DESCRIPTIONS: list[RoborockBinarySensorDescriptionA01] = [
RoborockBinarySensorDescriptionA01(
key="detergent_empty",
data_protocol=RoborockZeoProtocol.DETERGENT_EMPTY,
device_class=BinarySensorDeviceClass.PROBLEM,
translation_key="detergent_empty",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=bool,
),
RoborockBinarySensorDescriptionA01(
key="softener_empty",
data_protocol=RoborockZeoProtocol.SOFTENER_EMPTY,
device_class=BinarySensorDeviceClass.PROBLEM,
translation_key="softener_empty",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=bool,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Roborock vacuum binary sensors."""
async_add_entities(
entities: list[BinarySensorEntity] = [
RoborockBinarySensorEntity(
coordinator,
description,
@@ -125,7 +160,18 @@ async def async_setup_entry(
for coordinator in config_entry.runtime_data.v1
for description in BINARY_SENSOR_DESCRIPTIONS
if description.value_fn(coordinator.data) is not None
]
entities.extend(
RoborockBinarySensorEntityA01(
coordinator,
description,
)
for coordinator in config_entry.runtime_data.a01
if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator)
for description in ZEO_BINARY_SENSOR_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
async_add_entities(entities)
class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity):
@@ -150,3 +196,24 @@ class RoborockBinarySensorEntity(RoborockCoordinatedEntityV1, BinarySensorEntity
def is_on(self) -> bool:
"""Return the value reported by the sensor."""
return bool(self.entity_description.value_fn(self.coordinator.data))
class RoborockBinarySensorEntityA01(RoborockCoordinatedEntityA01, BinarySensorEntity):
"""Representation of a A01 Roborock binary sensor."""
entity_description: RoborockBinarySensorDescriptionA01
def __init__(
self,
coordinator: RoborockDataUpdateCoordinatorA01,
description: RoborockBinarySensorDescriptionA01,
) -> None:
"""Initialize the entity."""
self.entity_description = description
super().__init__(f"{description.key}_{coordinator.duid_slug}", coordinator)
@property
def is_on(self) -> bool:
"""Return the value reported by the sensor."""
value = self.coordinator.data[self.entity_description.data_protocol]
return self.entity_description.value_fn(value)

View File

@@ -10,6 +10,7 @@ from typing import Any
from roborock.devices.traits.v1.consumeable import ConsumableAttribute
from roborock.exceptions import RoborockException
from roborock.roborock_message import RoborockZeoProtocol
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.const import EntityCategory
@@ -18,8 +19,13 @@ from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import RoborockConfigEntry, RoborockDataUpdateCoordinator
from .entity import RoborockEntity, RoborockEntityV1
from .coordinator import (
RoborockConfigEntry,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
RoborockWashingMachineUpdateCoordinator,
)
from .entity import RoborockCoordinatedEntityA01, RoborockEntity, RoborockEntityV1
_LOGGER = logging.getLogger(__name__)
@@ -65,6 +71,32 @@ CONSUMABLE_BUTTON_DESCRIPTIONS = [
]
@dataclass(frozen=True, kw_only=True)
class RoborockButtonDescriptionA01(ButtonEntityDescription):
"""Describes a Roborock A01 button entity."""
data_protocol: RoborockZeoProtocol
ZEO_BUTTON_DESCRIPTIONS = [
RoborockButtonDescriptionA01(
key="start",
data_protocol=RoborockZeoProtocol.START,
translation_key="start",
),
RoborockButtonDescriptionA01(
key="pause",
data_protocol=RoborockZeoProtocol.PAUSE,
translation_key="pause",
),
RoborockButtonDescriptionA01(
key="shutdown",
data_protocol=RoborockZeoProtocol.SHUTDOWN,
translation_key="shutdown",
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
@@ -98,6 +130,15 @@ async def async_setup_entry(
)
for routine in routines
),
(
RoborockButtonEntityA01(
coordinator,
description,
)
for coordinator in config_entry.runtime_data.a01
if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator)
for description in ZEO_BUTTON_DESCRIPTIONS
),
)
)
@@ -160,3 +201,35 @@ class RoborockRoutineButtonEntity(RoborockEntity, ButtonEntity):
async def async_press(self, **kwargs: Any) -> None:
"""Press the button."""
await self._coordinator.execute_routines(self._routine_id)
class RoborockButtonEntityA01(RoborockCoordinatedEntityA01, ButtonEntity):
"""A class to define Roborock A01 button entities."""
entity_description: RoborockButtonDescriptionA01
def __init__(
self,
coordinator: RoborockDataUpdateCoordinatorA01,
entity_description: RoborockButtonDescriptionA01,
) -> None:
"""Create an A01 button entity."""
self.entity_description = entity_description
super().__init__(
f"{entity_description.key}_{coordinator.duid_slug}", coordinator
)
async def async_press(self) -> None:
"""Press the button."""
try:
await self.coordinator.api.set_value( # type: ignore[attr-defined]
self.entity_description.data_protocol,
1,
)
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="button_press_failed",
) from err
finally:
await self.coordinator.async_request_refresh()

View File

@@ -432,6 +432,18 @@ class RoborockWashingMachineUpdateCoordinator(
RoborockZeoProtocol.COUNTDOWN,
RoborockZeoProtocol.WASHING_LEFT,
RoborockZeoProtocol.ERROR,
RoborockZeoProtocol.TIMES_AFTER_CLEAN,
RoborockZeoProtocol.DETERGENT_EMPTY,
RoborockZeoProtocol.SOFTENER_EMPTY,
RoborockZeoProtocol.DETERGENT_TYPE,
RoborockZeoProtocol.SOFTENER_TYPE,
RoborockZeoProtocol.MODE,
RoborockZeoProtocol.PROGRAM,
RoborockZeoProtocol.TEMP,
RoborockZeoProtocol.RINSE_TIMES,
RoborockZeoProtocol.SPIN_LEVEL,
RoborockZeoProtocol.DRYING_MODE,
RoborockZeoProtocol.SOUND_SET,
]
async def _async_update_data(

View File

@@ -3,21 +3,35 @@
import asyncio
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import logging
from typing import Any
from roborock import B01Props, CleanTypeMapping
from roborock.data import RoborockDockDustCollectionModeCode, WaterLevelMapping
from roborock.data import (
RoborockDockDustCollectionModeCode,
RoborockEnum,
WaterLevelMapping,
ZeoDetergentType,
ZeoDryingMode,
ZeoMode,
ZeoProgram,
ZeoRinse,
ZeoSoftenerType,
ZeoSpin,
ZeoTemperature,
)
from roborock.devices.traits.b01 import Q7PropertiesApi
from roborock.devices.traits.v1 import PropertiesApi
from roborock.devices.traits.v1.home import HomeTrait
from roborock.devices.traits.v1.maps import MapsTrait
from roborock.exceptions import RoborockException
from roborock.roborock_message import RoborockZeoProtocol
from roborock.roborock_typing import RoborockCommand
from homeassistant.components.select import SelectEntity, SelectEntityDescription
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN, MAP_SLEEP
@@ -25,11 +39,18 @@ from .coordinator import (
RoborockB01Q7UpdateCoordinator,
RoborockConfigEntry,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
)
from .entity import (
RoborockCoordinatedEntityA01,
RoborockCoordinatedEntityB01Q7,
RoborockCoordinatedEntityV1,
)
from .entity import RoborockCoordinatedEntityB01Q7, RoborockCoordinatedEntityV1
PARALLEL_UPDATES = 0
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True, kw_only=True)
class RoborockSelectDescription(SelectEntityDescription):
@@ -65,6 +86,16 @@ class RoborockB01SelectDescription(SelectEntityDescription):
"""Function to get all options of the select entity or returns None if not supported."""
@dataclass(frozen=True, kw_only=True)
class RoborockSelectDescriptionA01(SelectEntityDescription):
"""Class to describe a Roborock A01 select entity."""
# The protocol that the select entity will send to the api.
data_protocol: RoborockZeoProtocol
# Enum class for the select entity
enum_class: type[RoborockEnum]
B01_SELECT_DESCRIPTIONS: list[RoborockB01SelectDescription] = [
RoborockB01SelectDescription(
key="water_flow",
@@ -139,6 +170,66 @@ SELECT_DESCRIPTIONS: list[RoborockSelectDescription] = [
]
A01_SELECT_DESCRIPTIONS: list[RoborockSelectDescriptionA01] = [
RoborockSelectDescriptionA01(
key="program",
data_protocol=RoborockZeoProtocol.PROGRAM,
translation_key="program",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoProgram,
),
RoborockSelectDescriptionA01(
key="mode",
data_protocol=RoborockZeoProtocol.MODE,
translation_key="mode",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoMode,
),
RoborockSelectDescriptionA01(
key="temperature",
data_protocol=RoborockZeoProtocol.TEMP,
translation_key="temperature",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoTemperature,
),
RoborockSelectDescriptionA01(
key="drying_mode",
data_protocol=RoborockZeoProtocol.DRYING_MODE,
translation_key="drying_mode",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoDryingMode,
),
RoborockSelectDescriptionA01(
key="spin_level",
data_protocol=RoborockZeoProtocol.SPIN_LEVEL,
translation_key="spin_level",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoSpin,
),
RoborockSelectDescriptionA01(
key="rinse_times",
data_protocol=RoborockZeoProtocol.RINSE_TIMES,
translation_key="rinse_times",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoRinse,
),
RoborockSelectDescriptionA01(
key="detergent_type",
data_protocol=RoborockZeoProtocol.DETERGENT_TYPE,
translation_key="detergent_type",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoDetergentType,
),
RoborockSelectDescriptionA01(
key="softener_type",
data_protocol=RoborockZeoProtocol.SOFTENER_TYPE,
translation_key="softener_type",
entity_category=EntityCategory.CONFIG,
enum_class=ZeoSoftenerType,
),
]
async def async_setup_entry(
hass: HomeAssistant,
config_entry: RoborockConfigEntry,
@@ -169,6 +260,12 @@ async def async_setup_entry(
for description in B01_SELECT_DESCRIPTIONS
if (options := description.options_lambda(coordinator.api)) is not None
)
async_add_entities(
RoborockSelectEntityA01(coordinator, description)
for coordinator in config_entry.runtime_data.a01
for description in A01_SELECT_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
class RoborockB01SelectEntity(RoborockCoordinatedEntityB01Q7, SelectEntity):
@@ -308,3 +405,64 @@ class RoborockCurrentMapSelectEntity(RoborockCoordinatedEntityV1, SelectEntity):
if current_map_info := self._home_trait.current_map_data:
return current_map_info.name or f"Map {current_map_info.map_flag}"
return None
class RoborockSelectEntityA01(RoborockCoordinatedEntityA01, SelectEntity):
"""A class to let you set options on a Roborock A01 device."""
entity_description: RoborockSelectDescriptionA01
def __init__(
self,
coordinator: RoborockDataUpdateCoordinatorA01,
entity_description: RoborockSelectDescriptionA01,
) -> None:
"""Create an A01 select entity."""
self.entity_description = entity_description
super().__init__(
f"{entity_description.key}_{coordinator.duid_slug}",
coordinator,
)
self._attr_options = list(entity_description.enum_class.keys())
async def async_select_option(self, option: str) -> None:
"""Set the option."""
# Get the protocol value for the selected option
option_values = self.entity_description.enum_class.as_dict()
if option not in option_values:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="select_option_failed",
)
value = option_values[option]
try:
await self.coordinator.api.set_value( # type: ignore[attr-defined]
self.entity_description.data_protocol,
value,
)
except RoborockException as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={
"command": self.entity_description.key,
},
) from err
await self.coordinator.async_request_refresh()
@property
def current_option(self) -> str | None:
"""Get the current status of the select entity from coordinator data."""
if self.entity_description.data_protocol not in self.coordinator.data:
return None
current_value = self.coordinator.data[self.entity_description.data_protocol]
if current_value is None:
return None
_LOGGER.debug(
"current_value: %s for %s",
current_value,
self.entity_description.key,
)
return str(current_value)

View File

@@ -37,6 +37,8 @@ from .coordinator import (
RoborockConfigEntry,
RoborockDataUpdateCoordinator,
RoborockDataUpdateCoordinatorA01,
RoborockWashingMachineUpdateCoordinator,
RoborockWetDryVacUpdateCoordinator,
)
from .entity import (
RoborockCoordinatedEntityA01,
@@ -252,7 +254,7 @@ SENSOR_DESCRIPTIONS = [
),
]
A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
DYAD_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
RoborockSensorDescriptionA01(
key="status",
data_protocol=RoborockDyadDataProtocol.STATUS,
@@ -303,6 +305,9 @@ A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
translation_key="total_cleaning_time",
entity_category=EntityCategory.DIAGNOSTIC,
),
]
ZEO_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
RoborockSensorDescriptionA01(
key="state",
data_protocol=RoborockZeoProtocol.STATE,
@@ -335,6 +340,12 @@ A01_SENSOR_DESCRIPTIONS: list[RoborockSensorDescriptionA01] = [
entity_category=EntityCategory.DIAGNOSTIC,
options=ZeoError.keys(),
),
RoborockSensorDescriptionA01(
key="times_after_clean",
data_protocol=RoborockZeoProtocol.TIMES_AFTER_CLEAN,
translation_key="times_after_clean",
entity_category=EntityCategory.DIAGNOSTIC,
),
]
Q7_B01_SENSOR_DESCRIPTIONS = [
@@ -418,7 +429,18 @@ async def async_setup_entry(
description,
)
for coordinator in coordinators.a01
for description in A01_SENSOR_DESCRIPTIONS
if isinstance(coordinator, RoborockWetDryVacUpdateCoordinator)
for description in DYAD_SENSOR_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
entities.extend(
RoborockSensorEntityA01(
coordinator,
description,
)
for coordinator in coordinators.a01
if isinstance(coordinator, RoborockWashingMachineUpdateCoordinator)
for description in ZEO_SENSOR_DESCRIPTIONS
if description.data_protocol in coordinator.request_protocols
)
entities.extend(

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