mirror of
https://github.com/home-assistant/core.git
synced 2026-02-27 12:31:32 +01:00
Compare commits
144 Commits
homvolt_se
...
2026.3.0b1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4a301eceac | ||
|
|
d138a99e62 | ||
|
|
a431f84dc9 | ||
|
|
aa9534600e | ||
|
|
54fa49e754 | ||
|
|
459b6152f4 | ||
|
|
60c8d997ca | ||
|
|
a598368895 | ||
|
|
2ff1499c48 | ||
|
|
348ddbe124 | ||
|
|
71ed43faf2 | ||
|
|
dc69a90296 | ||
|
|
f5db8e6ba4 | ||
|
|
b82a26ef68 | ||
|
|
0eaaeedf11 | ||
|
|
62e26e53ac | ||
|
|
80574f7ae0 | ||
|
|
390b62551d | ||
|
|
17e0fd1885 | ||
|
|
4eb3e77891 | ||
|
|
324ed65999 | ||
|
|
42428b91bb | ||
|
|
c41dd3e3a8 | ||
|
|
02171a1da0 | ||
|
|
19c7f663ca | ||
|
|
87bd04af5a | ||
|
|
5af6227ad7 | ||
|
|
9b56f936fd | ||
|
|
f2afd324d9 | ||
|
|
173aab5233 | ||
|
|
1d97729547 | ||
|
|
91ca674a36 | ||
|
|
6157802fb5 | ||
|
|
7e3b7a0c02 | ||
|
|
6a5455d7a5 | ||
|
|
09765fe53d | ||
|
|
2fccbd6e47 | ||
|
|
ef7cccbe3f | ||
|
|
a704c2d44b | ||
|
|
f12c5b627d | ||
|
|
b241054a96 | ||
|
|
0fd515404d | ||
|
|
52382b7fe5 | ||
|
|
209af5dccc | ||
|
|
227d2e8de6 | ||
|
|
96d50565f9 | ||
|
|
80fc3691d8 | ||
|
|
15e00f6ffa | ||
|
|
f25b437832 | ||
|
|
2e34d4d3a6 | ||
|
|
b81b12f094 | ||
|
|
7446d5ea7c | ||
|
|
7b811cddce | ||
|
|
19545f29dc | ||
|
|
e591291cbe | ||
|
|
cb990823cd | ||
|
|
2cfafc04ce | ||
|
|
0563037c5a | ||
|
|
70f5f2c1ee | ||
|
|
c5b31d6782 | ||
|
|
925bcea1c0 | ||
|
|
01f0e4fe48 | ||
|
|
f9a61e5412 | ||
|
|
caf40f9d25 | ||
|
|
89c5511558 | ||
|
|
fc79e0cbfa | ||
|
|
317f95ff0f | ||
|
|
0cb34d2888 | ||
|
|
b8df61fc5f | ||
|
|
44a4be012d | ||
|
|
8dcaed62b5 | ||
|
|
195e55097b | ||
|
|
910f501194 | ||
|
|
f0edfbf053 | ||
|
|
834227a762 | ||
|
|
3426846361 | ||
|
|
50f39621e9 | ||
|
|
dc133bf7cc | ||
|
|
3219417a7d | ||
|
|
9a23a518ed | ||
|
|
7e62852723 | ||
|
|
0a1027391f | ||
|
|
7644fc4325 | ||
|
|
2f80720730 | ||
|
|
644c74f311 | ||
|
|
29370add66 | ||
|
|
fc4680ad86 | ||
|
|
174076ba76 | ||
|
|
f3590bd9cf | ||
|
|
ae7f71219f | ||
|
|
e1529620db | ||
|
|
9a56d30924 | ||
|
|
d6df2b3c4c | ||
|
|
9740dc65aa | ||
|
|
b914971531 | ||
|
|
9007c65b50 | ||
|
|
a4a2847b03 | ||
|
|
9a11db2ad5 | ||
|
|
2d445f8f53 | ||
|
|
f07c386529 | ||
|
|
3cd79581dc | ||
|
|
e82df86dda | ||
|
|
1629d2b204 | ||
|
|
a6e60d8b73 | ||
|
|
ef6650548e | ||
|
|
52a2e94fc4 | ||
|
|
6bba7e7583 | ||
|
|
58e8a8d398 | ||
|
|
6b0303a1ef | ||
|
|
249e6c2f3d | ||
|
|
7ae0380b33 | ||
|
|
889faa5a5c | ||
|
|
9b810c64d9 | ||
|
|
1e3bed9864 | ||
|
|
eac3fb651e | ||
|
|
8b285239f0 | ||
|
|
d0a74ad539 | ||
|
|
0f071c1ae5 | ||
|
|
e671e4408b | ||
|
|
697441969b | ||
|
|
bc324a1a6e | ||
|
|
e505ad9003 | ||
|
|
6a91771f04 | ||
|
|
e7df4356f4 | ||
|
|
a41207d369 | ||
|
|
28e8d7c3eb | ||
|
|
e514faf0bc | ||
|
|
7894a80728 | ||
|
|
6751f6f4a2 | ||
|
|
ce0dd0eb7b | ||
|
|
7cb595f768 | ||
|
|
dfbd4ffb2d | ||
|
|
6abefc852d | ||
|
|
9ba28150e9 | ||
|
|
adfe4f2b62 | ||
|
|
dc3dc116d2 | ||
|
|
f16e7aaec4 | ||
|
|
ea68152f32 | ||
|
|
c75c9d9dd8 | ||
|
|
4760f9b8eb | ||
|
|
9bb879e061 | ||
|
|
f2c87f96a2 | ||
|
|
30fffafceb | ||
|
|
ff916a783b |
20
.github/workflows/builder.yml
vendored
20
.github/workflows/builder.yml
vendored
@@ -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 \
|
||||
|
||||
4
.github/workflows/wheels.yml
vendored
4
.github/workflows/wheels.yml
vendored
@@ -110,7 +110,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
abi: ["cp313", "cp314"]
|
||||
abi: ["cp314"]
|
||||
arch: ["amd64", "aarch64"]
|
||||
include:
|
||||
- arch: amd64
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
abi: ["cp313", "cp314"]
|
||||
abi: ["cp314"]
|
||||
arch: ["amd64", "aarch64"]
|
||||
include:
|
||||
- arch: amd64
|
||||
|
||||
7
CODEOWNERS
generated
7
CODEOWNERS
generated
@@ -242,6 +242,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/bosch_alarm/ @mag1024 @sanjay900
|
||||
/homeassistant/components/bosch_shc/ @tschamm
|
||||
/tests/components/bosch_shc/ @tschamm
|
||||
/homeassistant/components/brands/ @home-assistant/core
|
||||
/tests/components/brands/ @home-assistant/core
|
||||
/homeassistant/components/braviatv/ @bieniu @Drafteed
|
||||
/tests/components/braviatv/ @bieniu @Drafteed
|
||||
/homeassistant/components/bring/ @miaucl @tr4nt0r
|
||||
@@ -717,8 +719,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/homematic/ @pvizeli
|
||||
/homeassistant/components/homematicip_cloud/ @hahn-th @lackas
|
||||
/tests/components/homematicip_cloud/ @hahn-th @lackas
|
||||
/homeassistant/components/homevolt/ @danielhiversen
|
||||
/tests/components/homevolt/ @danielhiversen
|
||||
/homeassistant/components/homevolt/ @danielhiversen @liudger
|
||||
/tests/components/homevolt/ @danielhiversen @liudger
|
||||
/homeassistant/components/homewizard/ @DCSBL
|
||||
/tests/components/homewizard/ @DCSBL
|
||||
/homeassistant/components/honeywell/ @rdfurman @mkmer
|
||||
@@ -1966,6 +1968,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
|
||||
|
||||
2
Dockerfile
generated
2
Dockerfile
generated
@@ -30,7 +30,7 @@ RUN \
|
||||
# Verify go2rtc can be executed
|
||||
go2rtc --version \
|
||||
# Install uv
|
||||
&& pip3 install uv==0.9.26
|
||||
&& pip3 install uv==0.10.6
|
||||
|
||||
WORKDIR /usr/src
|
||||
|
||||
|
||||
@@ -210,6 +210,7 @@ DEFAULT_INTEGRATIONS = {
|
||||
"analytics", # Needed for onboarding
|
||||
"application_credentials",
|
||||
"backup",
|
||||
"brands",
|
||||
"frontend",
|
||||
"hardware",
|
||||
"labs",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["accuweather"],
|
||||
"requirements": ["accuweather==5.0.0"]
|
||||
"requirements": ["accuweather==5.1.0"]
|
||||
}
|
||||
|
||||
@@ -30,6 +30,8 @@ async def system_health_info(hass: HomeAssistant) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
return {
|
||||
"can_reach_server": system_health.async_check_can_reach_url(hass, ENDPOINT),
|
||||
"can_reach_server": system_health.async_check_can_reach_url(
|
||||
hass, str(ENDPOINT)
|
||||
),
|
||||
"remaining_requests": remaining_requests,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,9 +89,18 @@ 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 = [
|
||||
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):
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
entities = [AirOSSensor(coordinator, description) for description in COMMON_SENSORS]
|
||||
|
||||
if coordinator.device_data["fw_major"] == 8:
|
||||
entities.extend(
|
||||
AirOSSensor(coordinator, description) for description in AIROS8_SENSORS
|
||||
)
|
||||
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class AirOSSensor(AirOSEntity, SensorEntity):
|
||||
|
||||
@@ -5,12 +5,13 @@ from __future__ import annotations
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import aiohttp
|
||||
from genie_partner_sdk.client import AladdinConnectClient
|
||||
from genie_partner_sdk.model import GarageDoor
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
type AladdinConnectConfigEntry = ConfigEntry[dict[str, AladdinConnectCoordinator]]
|
||||
@@ -40,7 +41,10 @@ class AladdinConnectCoordinator(DataUpdateCoordinator[GarageDoor]):
|
||||
|
||||
async def _async_update_data(self) -> GarageDoor:
|
||||
"""Fetch data from the Aladdin Connect API."""
|
||||
await self.client.update_door(self.data.device_id, self.data.door_number)
|
||||
try:
|
||||
await self.client.update_door(self.data.device_id, self.data.door_number)
|
||||
except aiohttp.ClientError as err:
|
||||
raise UpdateFailed(f"Error communicating with API: {err}") from err
|
||||
self.data.status = self.client.get_door_status(
|
||||
self.data.device_id, self.data.door_number
|
||||
)
|
||||
|
||||
@@ -4,14 +4,19 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import aiohttp
|
||||
|
||||
from homeassistant.components.cover import CoverDeviceClass, CoverEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import SUPPORTED_FEATURES
|
||||
from .const import DOMAIN, SUPPORTED_FEATURES
|
||||
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||
from .entity import AladdinConnectEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
@@ -40,11 +45,23 @@ class AladdinCoverEntity(AladdinConnectEntity, CoverEntity):
|
||||
|
||||
async def async_open_cover(self, **kwargs: Any) -> None:
|
||||
"""Issue open command to cover."""
|
||||
await self.client.open_door(self._device_id, self._number)
|
||||
try:
|
||||
await self.client.open_door(self._device_id, self._number)
|
||||
except aiohttp.ClientError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="open_door_failed",
|
||||
) from err
|
||||
|
||||
async def async_close_cover(self, **kwargs: Any) -> None:
|
||||
"""Issue close command to cover."""
|
||||
await self.client.close_door(self._device_id, self._number)
|
||||
try:
|
||||
await self.client.close_door(self._device_id, self._number)
|
||||
except aiohttp.ClientError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="close_door_failed",
|
||||
) from err
|
||||
|
||||
@property
|
||||
def is_closed(self) -> bool | None:
|
||||
|
||||
@@ -26,20 +26,22 @@ rules:
|
||||
unique-config-entry: done
|
||||
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
action-exceptions: done
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters:
|
||||
status: exempt
|
||||
comment: Integration does not have an options flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: todo
|
||||
entity-unavailable:
|
||||
status: done
|
||||
comment: Handled by the coordinator.
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: Handled by the coordinator.
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage:
|
||||
status: todo
|
||||
comment: Platform tests for cover and sensor need to be implemented to reach 95% coverage.
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -20,6 +20,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from .coordinator import AladdinConnectConfigEntry, AladdinConnectCoordinator
|
||||
from .entity import AladdinConnectEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class AladdinConnectSensorEntityDescription(SensorEntityDescription):
|
||||
|
||||
@@ -32,5 +32,13 @@
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"close_door_failed": {
|
||||
"message": "Failed to close the garage door"
|
||||
},
|
||||
"open_door_failed": {
|
||||
"message": "Failed to open the garage door"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ class AnthropicTaskEntity(
|
||||
ai_task.AITaskEntityFeature.GENERATE_DATA
|
||||
| ai_task.AITaskEntityFeature.SUPPORT_ATTACHMENTS
|
||||
)
|
||||
_attr_translation_key = "ai_task_data"
|
||||
|
||||
async def _async_generate_data(
|
||||
self,
|
||||
|
||||
@@ -43,7 +43,9 @@ from homeassistant.helpers.selector import (
|
||||
from homeassistant.helpers.typing import VolDictType
|
||||
|
||||
from .const import (
|
||||
CODE_EXECUTION_UNSUPPORTED_MODELS,
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_CODE_EXECUTION,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_PROMPT,
|
||||
CONF_RECOMMENDED,
|
||||
@@ -415,6 +417,16 @@ class ConversationSubentryFlowHandler(ConfigSubentryFlow):
|
||||
else:
|
||||
self.options.pop(CONF_THINKING_EFFORT, None)
|
||||
|
||||
if not model.startswith(tuple(CODE_EXECUTION_UNSUPPORTED_MODELS)):
|
||||
step_schema[
|
||||
vol.Optional(
|
||||
CONF_CODE_EXECUTION,
|
||||
default=DEFAULT[CONF_CODE_EXECUTION],
|
||||
)
|
||||
] = bool
|
||||
else:
|
||||
self.options.pop(CONF_CODE_EXECUTION, None)
|
||||
|
||||
if not model.startswith(tuple(WEB_SEARCH_UNSUPPORTED_MODELS)):
|
||||
step_schema.update(
|
||||
{
|
||||
|
||||
@@ -11,6 +11,7 @@ DEFAULT_AI_TASK_NAME = "Claude AI Task"
|
||||
CONF_RECOMMENDED = "recommended"
|
||||
CONF_PROMPT = "prompt"
|
||||
CONF_CHAT_MODEL = "chat_model"
|
||||
CONF_CODE_EXECUTION = "code_execution"
|
||||
CONF_MAX_TOKENS = "max_tokens"
|
||||
CONF_TEMPERATURE = "temperature"
|
||||
CONF_THINKING_BUDGET = "thinking_budget"
|
||||
@@ -25,6 +26,7 @@ CONF_WEB_SEARCH_TIMEZONE = "timezone"
|
||||
|
||||
DEFAULT = {
|
||||
CONF_CHAT_MODEL: "claude-haiku-4-5",
|
||||
CONF_CODE_EXECUTION: False,
|
||||
CONF_MAX_TOKENS: 3000,
|
||||
CONF_TEMPERATURE: 1.0,
|
||||
CONF_THINKING_BUDGET: 0,
|
||||
@@ -65,6 +67,10 @@ WEB_SEARCH_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
CODE_EXECUTION_UNSUPPORTED_MODELS = [
|
||||
"claude-3-haiku",
|
||||
]
|
||||
|
||||
DEPRECATED_MODELS = [
|
||||
"claude-3",
|
||||
]
|
||||
|
||||
@@ -37,6 +37,7 @@ class AnthropicConversationEntity(
|
||||
"""Anthropic conversation agent."""
|
||||
|
||||
_attr_supports_streaming = True
|
||||
_attr_translation_key = "conversation"
|
||||
|
||||
def __init__(self, entry: AnthropicConfigEntry, subentry: ConfigSubentry) -> None:
|
||||
"""Initialize the agent."""
|
||||
|
||||
@@ -3,19 +3,23 @@
|
||||
import base64
|
||||
from collections.abc import AsyncGenerator, Callable, Iterable
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
import json
|
||||
from mimetypes import guess_file_type
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal, cast
|
||||
|
||||
import anthropic
|
||||
from anthropic import AsyncStream
|
||||
from anthropic.types import (
|
||||
Base64ImageSourceParam,
|
||||
Base64PDFSourceParam,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
CitationsDelta,
|
||||
CitationsWebSearchResultLocation,
|
||||
CitationWebSearchResultLocationParam,
|
||||
CodeExecutionTool20250825Param,
|
||||
Container,
|
||||
ContentBlockParam,
|
||||
DocumentBlockParam,
|
||||
ImageBlockParam,
|
||||
@@ -41,6 +45,7 @@ from anthropic.types import (
|
||||
TextCitation,
|
||||
TextCitationParam,
|
||||
TextDelta,
|
||||
TextEditorCodeExecutionToolResultBlock,
|
||||
ThinkingBlock,
|
||||
ThinkingBlockParam,
|
||||
ThinkingConfigAdaptiveParam,
|
||||
@@ -51,18 +56,21 @@ from anthropic.types import (
|
||||
ToolChoiceAutoParam,
|
||||
ToolChoiceToolParam,
|
||||
ToolParam,
|
||||
ToolResultBlockParam,
|
||||
ToolUnionParam,
|
||||
ToolUseBlock,
|
||||
ToolUseBlockParam,
|
||||
Usage,
|
||||
WebSearchTool20250305Param,
|
||||
WebSearchToolRequestErrorParam,
|
||||
WebSearchToolResultBlock,
|
||||
WebSearchToolResultBlockParam,
|
||||
WebSearchToolResultError,
|
||||
WebSearchToolResultBlockParamContentParam,
|
||||
)
|
||||
from anthropic.types.bash_code_execution_tool_result_block_param import (
|
||||
Content as BashCodeExecutionToolResultContentParam,
|
||||
)
|
||||
from anthropic.types.message_create_params import MessageCreateParamsStreaming
|
||||
from anthropic.types.text_editor_code_execution_tool_result_block_param import (
|
||||
Content as TextEditorCodeExecutionToolResultContentParam,
|
||||
)
|
||||
import voluptuous as vol
|
||||
from voluptuous_openapi import convert
|
||||
|
||||
@@ -74,10 +82,12 @@ from homeassistant.helpers import device_registry as dr, llm
|
||||
from homeassistant.helpers.entity import Entity
|
||||
from homeassistant.helpers.json import json_dumps
|
||||
from homeassistant.util import slugify
|
||||
from homeassistant.util.json import JsonObjectType
|
||||
|
||||
from . import AnthropicConfigEntry
|
||||
from .const import (
|
||||
CONF_CHAT_MODEL,
|
||||
CONF_CODE_EXECUTION,
|
||||
CONF_MAX_TOKENS,
|
||||
CONF_TEMPERATURE,
|
||||
CONF_THINKING_BUDGET,
|
||||
@@ -134,6 +144,7 @@ class ContentDetails:
|
||||
citation_details: list[CitationDetails] = field(default_factory=list)
|
||||
thinking_signature: str | None = None
|
||||
redacted_thinking: str | None = None
|
||||
container: Container | None = None
|
||||
|
||||
def has_content(self) -> bool:
|
||||
"""Check if there is any text content."""
|
||||
@@ -144,6 +155,7 @@ class ContentDetails:
|
||||
return (
|
||||
self.thinking_signature is not None
|
||||
or self.redacted_thinking is not None
|
||||
or self.container is not None
|
||||
or self.has_citations()
|
||||
)
|
||||
|
||||
@@ -188,30 +200,53 @@ class ContentDetails:
|
||||
|
||||
def _convert_content(
|
||||
chat_content: Iterable[conversation.Content],
|
||||
) -> list[MessageParam]:
|
||||
) -> tuple[list[MessageParam], str | None]:
|
||||
"""Transform HA chat_log content into Anthropic API format."""
|
||||
messages: list[MessageParam] = []
|
||||
container_id: str | None = None
|
||||
|
||||
for content in chat_content:
|
||||
if isinstance(content, conversation.ToolResultContent):
|
||||
external_tool = True
|
||||
if content.tool_name == "web_search":
|
||||
tool_result_block: ContentBlockParam = WebSearchToolResultBlockParam(
|
||||
type="web_search_tool_result",
|
||||
tool_use_id=content.tool_call_id,
|
||||
content=content.tool_result["content"]
|
||||
if "content" in content.tool_result
|
||||
else WebSearchToolRequestErrorParam(
|
||||
type="web_search_tool_result_error",
|
||||
error_code=content.tool_result.get("error_code", "unavailable"), # type: ignore[typeddict-item]
|
||||
tool_result_block: ContentBlockParam = {
|
||||
"type": "web_search_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
WebSearchToolResultBlockParamContentParam,
|
||||
content.tool_result["content"]
|
||||
if "content" in content.tool_result
|
||||
else {
|
||||
"type": "web_search_tool_result_error",
|
||||
"error_code": content.tool_result.get(
|
||||
"error_code", "unavailable"
|
||||
),
|
||||
},
|
||||
),
|
||||
)
|
||||
external_tool = True
|
||||
}
|
||||
elif content.tool_name == "bash_code_execution":
|
||||
tool_result_block = {
|
||||
"type": "bash_code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
BashCodeExecutionToolResultContentParam, content.tool_result
|
||||
),
|
||||
}
|
||||
elif content.tool_name == "text_editor_code_execution":
|
||||
tool_result_block = {
|
||||
"type": "text_editor_code_execution_tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": cast(
|
||||
TextEditorCodeExecutionToolResultContentParam,
|
||||
content.tool_result,
|
||||
),
|
||||
}
|
||||
else:
|
||||
tool_result_block = ToolResultBlockParam(
|
||||
type="tool_result",
|
||||
tool_use_id=content.tool_call_id,
|
||||
content=json_dumps(content.tool_result),
|
||||
)
|
||||
tool_result_block = {
|
||||
"type": "tool_result",
|
||||
"tool_use_id": content.tool_call_id,
|
||||
"content": json_dumps(content.tool_result),
|
||||
}
|
||||
external_tool = False
|
||||
if not messages or messages[-1]["role"] != (
|
||||
"assistant" if external_tool else "user"
|
||||
@@ -277,6 +312,11 @@ def _convert_content(
|
||||
data=content.native.redacted_thinking,
|
||||
)
|
||||
)
|
||||
if (
|
||||
content.native.container is not None
|
||||
and content.native.container.expires_at > datetime.now(UTC)
|
||||
):
|
||||
container_id = content.native.container.id
|
||||
|
||||
if content.content:
|
||||
current_index = 0
|
||||
@@ -325,10 +365,23 @@ def _convert_content(
|
||||
ServerToolUseBlockParam(
|
||||
type="server_tool_use",
|
||||
id=tool_call.id,
|
||||
name="web_search",
|
||||
name=cast(
|
||||
Literal[
|
||||
"web_search",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
],
|
||||
tool_call.tool_name,
|
||||
),
|
||||
input=tool_call.tool_args,
|
||||
)
|
||||
if tool_call.external and tool_call.tool_name == "web_search"
|
||||
if tool_call.external
|
||||
and tool_call.tool_name
|
||||
in [
|
||||
"web_search",
|
||||
"bash_code_execution",
|
||||
"text_editor_code_execution",
|
||||
]
|
||||
else ToolUseBlockParam(
|
||||
type="tool_use",
|
||||
id=tool_call.id,
|
||||
@@ -350,7 +403,7 @@ def _convert_content(
|
||||
# Note: We don't pass SystemContent here as its passed to the API as the prompt
|
||||
raise TypeError(f"Unexpected content type: {type(content)}")
|
||||
|
||||
return messages
|
||||
return messages, container_id
|
||||
|
||||
|
||||
async def _transform_stream( # noqa: C901 - This is complex, but better to have it in one place
|
||||
@@ -478,7 +531,14 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
input={},
|
||||
)
|
||||
current_tool_args = ""
|
||||
elif isinstance(response.content_block, WebSearchToolResultBlock):
|
||||
elif isinstance(
|
||||
response.content_block,
|
||||
(
|
||||
WebSearchToolResultBlock,
|
||||
BashCodeExecutionToolResultBlock,
|
||||
TextEditorCodeExecutionToolResultBlock,
|
||||
),
|
||||
):
|
||||
if content_details:
|
||||
content_details.delete_empty()
|
||||
yield {"native": content_details}
|
||||
@@ -487,26 +547,16 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
yield {
|
||||
"role": "tool_result",
|
||||
"tool_call_id": response.content_block.tool_use_id,
|
||||
"tool_name": "web_search",
|
||||
"tool_name": response.content_block.type.removesuffix(
|
||||
"_tool_result"
|
||||
),
|
||||
"tool_result": {
|
||||
"type": "web_search_tool_result_error",
|
||||
"error_code": response.content_block.content.error_code,
|
||||
"content": cast(
|
||||
JsonObjectType, response.content_block.to_dict()["content"]
|
||||
)
|
||||
}
|
||||
if isinstance(
|
||||
response.content_block.content, WebSearchToolResultError
|
||||
)
|
||||
else {
|
||||
"content": [
|
||||
{
|
||||
"type": "web_search_result",
|
||||
"encrypted_content": block.encrypted_content,
|
||||
"page_age": block.page_age,
|
||||
"title": block.title,
|
||||
"url": block.url,
|
||||
}
|
||||
for block in response.content_block.content
|
||||
]
|
||||
},
|
||||
if isinstance(response.content_block.content, list)
|
||||
else cast(JsonObjectType, response.content_block.content.to_dict()),
|
||||
}
|
||||
first_block = True
|
||||
elif isinstance(response, RawContentBlockDeltaEvent):
|
||||
@@ -555,6 +605,7 @@ async def _transform_stream( # noqa: C901 - This is complex, but better to have
|
||||
elif isinstance(response, RawMessageDeltaEvent):
|
||||
if (usage := response.usage) is not None:
|
||||
chat_log.async_trace(_create_token_stats(input_usage, usage))
|
||||
content_details.container = response.delta.container
|
||||
if response.delta.stop_reason == "refusal":
|
||||
raise HomeAssistantError("Potential policy violation detected")
|
||||
elif isinstance(response, RawMessageStopEvent):
|
||||
@@ -626,7 +677,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
)
|
||||
]
|
||||
|
||||
messages = _convert_content(chat_log.content[1:])
|
||||
messages, container_id = _convert_content(chat_log.content[1:])
|
||||
|
||||
model = options.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
|
||||
|
||||
@@ -636,6 +687,7 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
max_tokens=options.get(CONF_MAX_TOKENS, DEFAULT[CONF_MAX_TOKENS]),
|
||||
system=system_prompt,
|
||||
stream=True,
|
||||
container=container_id,
|
||||
)
|
||||
|
||||
if not model.startswith(tuple(NON_ADAPTIVE_THINKING_MODELS)):
|
||||
@@ -674,6 +726,14 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
for tool in chat_log.llm_api.tools
|
||||
]
|
||||
|
||||
if options.get(CONF_CODE_EXECUTION):
|
||||
tools.append(
|
||||
CodeExecutionTool20250825Param(
|
||||
name="code_execution",
|
||||
type="code_execution_20250825",
|
||||
),
|
||||
)
|
||||
|
||||
if options.get(CONF_WEB_SEARCH):
|
||||
web_search = WebSearchTool20250305Param(
|
||||
name="web_search",
|
||||
@@ -784,21 +844,20 @@ class AnthropicBaseLLMEntity(Entity):
|
||||
try:
|
||||
stream = await client.messages.create(**model_args)
|
||||
|
||||
messages.extend(
|
||||
_convert_content(
|
||||
[
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id,
|
||||
_transform_stream(
|
||||
chat_log,
|
||||
stream,
|
||||
output_tool=structure_name or None,
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
new_messages, model_args["container"] = _convert_content(
|
||||
[
|
||||
content
|
||||
async for content in chat_log.async_add_delta_content_stream(
|
||||
self.entity_id,
|
||||
_transform_stream(
|
||||
chat_log,
|
||||
stream,
|
||||
output_tool=structure_name or None,
|
||||
),
|
||||
)
|
||||
]
|
||||
)
|
||||
messages.extend(new_messages)
|
||||
except anthropic.AnthropicError as err:
|
||||
raise HomeAssistantError(
|
||||
f"Sorry, I had a problem talking to Anthropic: {err}"
|
||||
|
||||
14
homeassistant/components/anthropic/icons.json
Normal file
14
homeassistant/components/anthropic/icons.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"entity": {
|
||||
"ai_task": {
|
||||
"ai_task_data": {
|
||||
"default": "mdi:asterisk"
|
||||
}
|
||||
},
|
||||
"conversation": {
|
||||
"conversation": {
|
||||
"default": "mdi:asterisk"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"domain": "anthropic",
|
||||
"name": "Anthropic Conversation",
|
||||
"name": "Anthropic",
|
||||
"after_dependencies": ["assist_pipeline", "intent"],
|
||||
"codeowners": ["@Shulyaka"],
|
||||
"config_flow": true,
|
||||
|
||||
@@ -92,7 +92,7 @@ rules:
|
||||
No entities disabled by default.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
repair-issues: done
|
||||
stale-devices:
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
},
|
||||
"model": {
|
||||
"data": {
|
||||
"code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data::code_execution%]",
|
||||
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_budget%]",
|
||||
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data::thinking_effort%]",
|
||||
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data::user_location%]",
|
||||
@@ -76,6 +77,7 @@
|
||||
"web_search_max_uses": "[%key:component::anthropic::config_subentries::conversation::step::model::data::web_search_max_uses%]"
|
||||
},
|
||||
"data_description": {
|
||||
"code_execution": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::code_execution%]",
|
||||
"thinking_budget": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_budget%]",
|
||||
"thinking_effort": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::thinking_effort%]",
|
||||
"user_location": "[%key:component::anthropic::config_subentries::conversation::step::model::data_description::user_location%]",
|
||||
@@ -127,6 +129,7 @@
|
||||
},
|
||||
"model": {
|
||||
"data": {
|
||||
"code_execution": "Code execution",
|
||||
"thinking_budget": "Thinking budget",
|
||||
"thinking_effort": "Thinking effort",
|
||||
"user_location": "Include home location",
|
||||
@@ -134,6 +137,7 @@
|
||||
"web_search_max_uses": "Maximum web searches"
|
||||
},
|
||||
"data_description": {
|
||||
"code_execution": "Allow the model to execute code in a secure sandbox environment, enabling it to analyze data and perform complex calculations.",
|
||||
"thinking_budget": "The number of tokens the model can use to think about the response out of the total maximum number of tokens. Set to 1024 or greater to enable extended thinking.",
|
||||
"thinking_effort": "Control how many tokens Claude uses when responding, trading off between response thoroughness and token efficiency",
|
||||
"user_location": "Localize search results based on home location",
|
||||
|
||||
@@ -60,9 +60,8 @@ class S3ConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
|
||||
if not urlparse(user_input[CONF_ENDPOINT_URL]).hostname.endswith(
|
||||
AWS_DOMAIN
|
||||
):
|
||||
hostname = urlparse(user_input[CONF_ENDPOINT_URL]).hostname
|
||||
if not hostname or not hostname.endswith(AWS_DOMAIN):
|
||||
errors[CONF_ENDPOINT_URL] = "invalid_endpoint_url"
|
||||
else:
|
||||
try:
|
||||
|
||||
@@ -43,11 +43,11 @@
|
||||
"title": "The backup location {agent_id} is unavailable"
|
||||
},
|
||||
"automatic_backup_failed_addons": {
|
||||
"description": "Add-ons {failed_addons} could not be included in automatic backup. Please check the Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured.",
|
||||
"title": "Not all add-ons could be included in automatic backup"
|
||||
"description": "Apps {failed_addons} could not be included in automatic backup. Please check the Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured.",
|
||||
"title": "Not all apps could be included in automatic backup"
|
||||
},
|
||||
"automatic_backup_failed_agents_addons_folders": {
|
||||
"description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Add-ons which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the Core and Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured.",
|
||||
"description": "The automatic backup was created with errors:\n* Locations which the backup could not be uploaded to: {failed_agents}\n* Apps which could not be backed up: {failed_addons}\n* Folders which could not be backed up: {failed_folders}\n\nPlease check the Core and Supervisor logs for more information. Another attempt will be made at the next scheduled time if a backup schedule is configured.",
|
||||
"title": "Automatic backup was created with errors"
|
||||
},
|
||||
"automatic_backup_failed_create": {
|
||||
|
||||
291
homeassistant/components/brands/__init__.py
Normal file
291
homeassistant/components/brands/__init__.py
Normal file
@@ -0,0 +1,291 @@
|
||||
"""The Brands integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from random import SystemRandom
|
||||
import time
|
||||
from typing import Any, Final
|
||||
|
||||
from aiohttp import ClientError, hdrs, web
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import websocket_api
|
||||
from homeassistant.components.http import KEY_AUTHENTICATED, HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback, valid_domain
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.loader import async_get_custom_components
|
||||
|
||||
from .const import (
|
||||
ALLOWED_IMAGES,
|
||||
BRANDS_CDN_URL,
|
||||
CACHE_TTL,
|
||||
CATEGORY_RE,
|
||||
CDN_TIMEOUT,
|
||||
DOMAIN,
|
||||
HARDWARE_IMAGE_RE,
|
||||
IMAGE_FALLBACKS,
|
||||
PLACEHOLDER,
|
||||
TOKEN_CHANGE_INTERVAL,
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
_RND: Final = SystemRandom()
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the Brands integration."""
|
||||
access_tokens: deque[str] = deque([], 2)
|
||||
access_tokens.append(hex(_RND.getrandbits(256))[2:])
|
||||
hass.data[DOMAIN] = access_tokens
|
||||
|
||||
@callback
|
||||
def _rotate_token(_now: Any) -> None:
|
||||
"""Rotate the access token."""
|
||||
access_tokens.append(hex(_RND.getrandbits(256))[2:])
|
||||
|
||||
async_track_time_interval(hass, _rotate_token, TOKEN_CHANGE_INTERVAL)
|
||||
|
||||
hass.http.register_view(BrandsIntegrationView(hass))
|
||||
hass.http.register_view(BrandsHardwareView(hass))
|
||||
websocket_api.async_register_command(hass, ws_access_token)
|
||||
return True
|
||||
|
||||
|
||||
@callback
|
||||
@websocket_api.websocket_command({vol.Required("type"): "brands/access_token"})
|
||||
def ws_access_token(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict[str, Any],
|
||||
) -> None:
|
||||
"""Return the current brands access token."""
|
||||
access_tokens: deque[str] = hass.data[DOMAIN]
|
||||
connection.send_result(msg["id"], {"token": access_tokens[-1]})
|
||||
|
||||
|
||||
def _read_cached_file_with_marker(
|
||||
cache_path: Path,
|
||||
) -> tuple[bytes | None, float] | None:
|
||||
"""Read a cached file, distinguishing between content and 404 markers.
|
||||
|
||||
Returns (content, mtime) where content is None for 404 markers (empty files).
|
||||
Returns None if the file does not exist at all.
|
||||
"""
|
||||
if not cache_path.is_file():
|
||||
return None
|
||||
mtime = cache_path.stat().st_mtime
|
||||
data = cache_path.read_bytes()
|
||||
if not data:
|
||||
# Empty file is a 404 marker
|
||||
return (None, mtime)
|
||||
return (data, mtime)
|
||||
|
||||
|
||||
def _write_cache_file(cache_path: Path, data: bytes) -> None:
|
||||
"""Write data to cache file, creating directories as needed."""
|
||||
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cache_path.write_bytes(data)
|
||||
|
||||
|
||||
def _read_brand_file(brand_dir: Path, image: str) -> bytes | None:
|
||||
"""Read a brand image, trying fallbacks in a single I/O pass."""
|
||||
for candidate in (image, *IMAGE_FALLBACKS.get(image, ())):
|
||||
file_path = brand_dir / candidate
|
||||
if file_path.is_file():
|
||||
return file_path.read_bytes()
|
||||
return None
|
||||
|
||||
|
||||
class _BrandsBaseView(HomeAssistantView):
|
||||
"""Base view for serving brand images."""
|
||||
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the view."""
|
||||
self._hass = hass
|
||||
self._cache_dir = Path(hass.config.cache_path(DOMAIN))
|
||||
|
||||
def _authenticate(self, request: web.Request) -> None:
|
||||
"""Authenticate the request using Bearer token or query token."""
|
||||
access_tokens: deque[str] = self._hass.data[DOMAIN]
|
||||
authenticated = (
|
||||
request[KEY_AUTHENTICATED] or request.query.get("token") in access_tokens
|
||||
)
|
||||
if not authenticated:
|
||||
if hdrs.AUTHORIZATION in request.headers:
|
||||
raise web.HTTPUnauthorized
|
||||
raise web.HTTPForbidden
|
||||
|
||||
async def _serve_from_custom_integration(
|
||||
self,
|
||||
domain: str,
|
||||
image: str,
|
||||
) -> web.Response | None:
|
||||
"""Try to serve a brand image from a custom integration."""
|
||||
custom_components = await async_get_custom_components(self._hass)
|
||||
if (integration := custom_components.get(domain)) is None:
|
||||
return None
|
||||
if not integration.has_branding:
|
||||
return None
|
||||
|
||||
brand_dir = Path(integration.file_path) / "brand"
|
||||
|
||||
data = await self._hass.async_add_executor_job(
|
||||
_read_brand_file, brand_dir, image
|
||||
)
|
||||
if data is not None:
|
||||
return self._build_response(data)
|
||||
|
||||
return None
|
||||
|
||||
async def _serve_from_cache_or_cdn(
|
||||
self,
|
||||
cdn_path: str,
|
||||
cache_subpath: str,
|
||||
*,
|
||||
fallback_placeholder: bool = True,
|
||||
) -> web.Response:
|
||||
"""Serve from disk cache, fetching from CDN if needed."""
|
||||
cache_path = self._cache_dir / cache_subpath
|
||||
now = time.time()
|
||||
|
||||
# Try disk cache
|
||||
result = await self._hass.async_add_executor_job(
|
||||
_read_cached_file_with_marker, cache_path
|
||||
)
|
||||
if result is not None:
|
||||
data, mtime = result
|
||||
# Schedule background refresh if stale
|
||||
if now - mtime > CACHE_TTL:
|
||||
self._hass.async_create_background_task(
|
||||
self._fetch_and_cache(cdn_path, cache_path),
|
||||
f"brands_refresh_{cache_subpath}",
|
||||
)
|
||||
else:
|
||||
# Cache miss - fetch from CDN
|
||||
data = await self._fetch_and_cache(cdn_path, cache_path)
|
||||
|
||||
if data is None:
|
||||
if fallback_placeholder:
|
||||
return await self._serve_placeholder(
|
||||
image=cache_subpath.rsplit("/", 1)[-1]
|
||||
)
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
return self._build_response(data)
|
||||
|
||||
async def _fetch_and_cache(
|
||||
self,
|
||||
cdn_path: str,
|
||||
cache_path: Path,
|
||||
) -> bytes | None:
|
||||
"""Fetch from CDN and write to cache. Returns data or None on 404."""
|
||||
url = f"{BRANDS_CDN_URL}/{cdn_path}"
|
||||
session = async_get_clientsession(self._hass)
|
||||
try:
|
||||
resp = await session.get(url, timeout=CDN_TIMEOUT)
|
||||
except ClientError, TimeoutError:
|
||||
_LOGGER.debug("Failed to fetch brand from CDN: %s", cdn_path)
|
||||
return None
|
||||
|
||||
if resp.status == HTTPStatus.NOT_FOUND:
|
||||
# Cache the 404 as empty file
|
||||
await self._hass.async_add_executor_job(_write_cache_file, cache_path, b"")
|
||||
return None
|
||||
|
||||
if resp.status != HTTPStatus.OK:
|
||||
_LOGGER.debug("Unexpected CDN response %s for %s", resp.status, cdn_path)
|
||||
return None
|
||||
|
||||
data = await resp.read()
|
||||
await self._hass.async_add_executor_job(_write_cache_file, cache_path, data)
|
||||
return data
|
||||
|
||||
async def _serve_placeholder(self, image: str) -> web.Response:
|
||||
"""Serve a placeholder image."""
|
||||
return await self._serve_from_cache_or_cdn(
|
||||
cdn_path=f"_/{PLACEHOLDER}/{image}",
|
||||
cache_subpath=f"integrations/{PLACEHOLDER}/{image}",
|
||||
fallback_placeholder=False,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_response(data: bytes) -> web.Response:
|
||||
"""Build a response with proper headers."""
|
||||
return web.Response(
|
||||
body=data,
|
||||
content_type="image/png",
|
||||
)
|
||||
|
||||
|
||||
class BrandsIntegrationView(_BrandsBaseView):
|
||||
"""Serve integration brand images."""
|
||||
|
||||
name = "api:brands:integration"
|
||||
url = "/api/brands/integration/{domain}/{image}"
|
||||
|
||||
async def get(
|
||||
self,
|
||||
request: web.Request,
|
||||
domain: str,
|
||||
image: str,
|
||||
) -> web.Response:
|
||||
"""Handle GET request for an integration brand image."""
|
||||
self._authenticate(request)
|
||||
|
||||
if not valid_domain(domain) or image not in ALLOWED_IMAGES:
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
use_placeholder = request.query.get("placeholder") != "no"
|
||||
|
||||
# 1. Try custom integration local files
|
||||
if (
|
||||
response := await self._serve_from_custom_integration(domain, image)
|
||||
) is not None:
|
||||
return response
|
||||
|
||||
# 2. Try cache / CDN (always use direct path for proper 404 caching)
|
||||
return await self._serve_from_cache_or_cdn(
|
||||
cdn_path=f"brands/{domain}/{image}",
|
||||
cache_subpath=f"integrations/{domain}/{image}",
|
||||
fallback_placeholder=use_placeholder,
|
||||
)
|
||||
|
||||
|
||||
class BrandsHardwareView(_BrandsBaseView):
|
||||
"""Serve hardware brand images."""
|
||||
|
||||
name = "api:brands:hardware"
|
||||
url = "/api/brands/hardware/{category}/{image:.+}"
|
||||
|
||||
async def get(
|
||||
self,
|
||||
request: web.Request,
|
||||
category: str,
|
||||
image: str,
|
||||
) -> web.Response:
|
||||
"""Handle GET request for a hardware brand image."""
|
||||
self._authenticate(request)
|
||||
|
||||
if not CATEGORY_RE.match(category):
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
# Hardware images have dynamic names like "manufacturer_model.png"
|
||||
# Validate it ends with .png and contains only safe characters
|
||||
if not HARDWARE_IMAGE_RE.match(image):
|
||||
return web.Response(status=HTTPStatus.NOT_FOUND)
|
||||
|
||||
cache_subpath = f"hardware/{category}/{image}"
|
||||
|
||||
return await self._serve_from_cache_or_cdn(
|
||||
cdn_path=cache_subpath,
|
||||
cache_subpath=cache_subpath,
|
||||
)
|
||||
57
homeassistant/components/brands/const.py
Normal file
57
homeassistant/components/brands/const.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Constants for the Brands integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import re
|
||||
from typing import Final
|
||||
|
||||
from aiohttp import ClientTimeout
|
||||
|
||||
DOMAIN: Final = "brands"
|
||||
|
||||
# CDN
|
||||
BRANDS_CDN_URL: Final = "https://brands.home-assistant.io"
|
||||
CDN_TIMEOUT: Final = ClientTimeout(total=10)
|
||||
PLACEHOLDER: Final = "_placeholder"
|
||||
|
||||
# Caching
|
||||
CACHE_TTL: Final = 30 * 24 * 60 * 60 # 30 days in seconds
|
||||
|
||||
# Access token
|
||||
TOKEN_CHANGE_INTERVAL: Final = timedelta(minutes=30)
|
||||
|
||||
# Validation
|
||||
CATEGORY_RE: Final = re.compile(r"^[a-z0-9_]+$")
|
||||
HARDWARE_IMAGE_RE: Final = re.compile(r"^[a-z0-9_-]+\.png$")
|
||||
|
||||
# Images and fallback chains
|
||||
ALLOWED_IMAGES: Final = frozenset(
|
||||
{
|
||||
"icon.png",
|
||||
"logo.png",
|
||||
"icon@2x.png",
|
||||
"logo@2x.png",
|
||||
"dark_icon.png",
|
||||
"dark_logo.png",
|
||||
"dark_icon@2x.png",
|
||||
"dark_logo@2x.png",
|
||||
}
|
||||
)
|
||||
|
||||
# Fallback chains for image resolution, mirroring the brands CDN build logic.
|
||||
# When a requested image is not found, we try each fallback in order.
|
||||
IMAGE_FALLBACKS: Final[dict[str, list[str]]] = {
|
||||
"logo.png": ["icon.png"],
|
||||
"icon@2x.png": ["icon.png"],
|
||||
"logo@2x.png": ["logo.png", "icon.png"],
|
||||
"dark_icon.png": ["icon.png"],
|
||||
"dark_logo.png": ["dark_icon.png", "logo.png", "icon.png"],
|
||||
"dark_icon@2x.png": ["icon@2x.png", "icon.png"],
|
||||
"dark_logo@2x.png": [
|
||||
"dark_icon@2x.png",
|
||||
"logo@2x.png",
|
||||
"logo.png",
|
||||
"icon.png",
|
||||
],
|
||||
}
|
||||
10
homeassistant/components/brands/manifest.json
Normal file
10
homeassistant/components/brands/manifest.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"domain": "brands",
|
||||
"name": "Brands",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"config_flow": false,
|
||||
"dependencies": ["http", "websocket_api"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/brands",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
@@ -7,7 +7,8 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["bsblan"],
|
||||
"requirements": ["python-bsblan==5.0.1"],
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["python-bsblan==5.1.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "bsb-lan*",
|
||||
|
||||
74
homeassistant/components/bsblan/quality_scale.yaml
Normal file
74
homeassistant/components/bsblan/quality_scale.yaml
Normal 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
|
||||
@@ -38,7 +38,7 @@ async def _root_payload(
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_id="",
|
||||
media_content_type="presets",
|
||||
thumbnail="https://brands.home-assistant.io/_/cambridge_audio/logo.png",
|
||||
thumbnail="/api/brands/integration/cambridge_audio/logo.png",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ PLATFORMS = [
|
||||
Platform.CLIMATE,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.WATER_HEATER,
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
1029
homeassistant/components/compit/sensor.py
Normal file
1029
homeassistant/components/compit/sensor.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::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": "[%key:common::state::off%]",
|
||||
"on": "[%key:common::state::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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ Wetterwarnungen (Stufe 1)
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity, SensorEntityDescription
|
||||
@@ -95,13 +96,25 @@ class DwdWeatherWarningsSensor(
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
def _filter_expired_warnings(
|
||||
self, warnings: list[dict[str, Any]] | None
|
||||
) -> list[dict[str, Any]]:
|
||||
if warnings is None:
|
||||
return []
|
||||
|
||||
now = datetime.now(UTC)
|
||||
return [warning for warning in warnings if warning[API_ATTR_WARNING_END] > now]
|
||||
|
||||
@property
|
||||
def native_value(self) -> int | None:
|
||||
"""Return the state of the sensor."""
|
||||
if self.entity_description.key == CURRENT_WARNING_SENSOR:
|
||||
return self.coordinator.api.current_warning_level
|
||||
warnings = self.coordinator.api.current_warnings
|
||||
else:
|
||||
warnings = self.coordinator.api.expected_warnings
|
||||
|
||||
return self.coordinator.api.expected_warning_level
|
||||
warnings = self._filter_expired_warnings(warnings)
|
||||
return max((w.get(API_ATTR_WARNING_LEVEL, 0) for w in warnings), default=0)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
@@ -117,6 +130,7 @@ class DwdWeatherWarningsSensor(
|
||||
else:
|
||||
searched_warnings = self.coordinator.api.expected_warnings
|
||||
|
||||
searched_warnings = self._filter_expired_warnings(searched_warnings)
|
||||
data[ATTR_WARNING_COUNT] = len(searched_warnings)
|
||||
|
||||
for i, warning in enumerate(searched_warnings, 1):
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
|
||||
@@ -304,7 +304,7 @@ def base_owntone_library() -> BrowseMedia:
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
children=children,
|
||||
thumbnail="https://brands.home-assistant.io/_/forked_daapd/logo.png",
|
||||
thumbnail="/api/brands/integration/forked_daapd/logo.png",
|
||||
)
|
||||
|
||||
|
||||
@@ -321,7 +321,7 @@ def library(other: Sequence[BrowseMedia] | None) -> BrowseMedia:
|
||||
media_content_type=MediaType.APP,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
thumbnail="https://brands.home-assistant.io/_/forked_daapd/logo.png",
|
||||
thumbnail="/api/brands/integration/forked_daapd/logo.png",
|
||||
)
|
||||
]
|
||||
if other:
|
||||
|
||||
@@ -297,6 +297,9 @@ class Panel:
|
||||
# If the panel should only be visible to admins
|
||||
require_admin = False
|
||||
|
||||
# If the panel should be shown in the sidebar
|
||||
show_in_sidebar = True
|
||||
|
||||
# If the panel is a configuration panel for a integration
|
||||
config_panel_domain: str | None = None
|
||||
|
||||
@@ -310,6 +313,7 @@ class Panel:
|
||||
config: dict[str, Any] | None,
|
||||
require_admin: bool,
|
||||
config_panel_domain: str | None,
|
||||
show_in_sidebar: bool,
|
||||
) -> None:
|
||||
"""Initialize a built-in panel."""
|
||||
self.component_name = component_name
|
||||
@@ -319,6 +323,7 @@ class Panel:
|
||||
self.config = config
|
||||
self.require_admin = require_admin
|
||||
self.config_panel_domain = config_panel_domain
|
||||
self.show_in_sidebar = show_in_sidebar
|
||||
self.sidebar_default_visible = sidebar_default_visible
|
||||
|
||||
@callback
|
||||
@@ -335,18 +340,17 @@ class Panel:
|
||||
"url_path": self.frontend_url_path,
|
||||
"require_admin": self.require_admin,
|
||||
"config_panel_domain": self.config_panel_domain,
|
||||
"show_in_sidebar": self.show_in_sidebar,
|
||||
}
|
||||
if config_override:
|
||||
if "require_admin" in config_override:
|
||||
response["require_admin"] = config_override["require_admin"]
|
||||
if config_override.get("show_in_sidebar") is False:
|
||||
response["title"] = None
|
||||
response["icon"] = None
|
||||
else:
|
||||
if "icon" in config_override:
|
||||
response["icon"] = config_override["icon"]
|
||||
if "title" in config_override:
|
||||
response["title"] = config_override["title"]
|
||||
if "show_in_sidebar" in config_override:
|
||||
response["show_in_sidebar"] = config_override["show_in_sidebar"]
|
||||
if "icon" in config_override:
|
||||
response["icon"] = config_override["icon"]
|
||||
if "title" in config_override:
|
||||
response["title"] = config_override["title"]
|
||||
return response
|
||||
|
||||
|
||||
@@ -364,6 +368,7 @@ def async_register_built_in_panel(
|
||||
*,
|
||||
update: bool = False,
|
||||
config_panel_domain: str | None = None,
|
||||
show_in_sidebar: bool = True,
|
||||
) -> None:
|
||||
"""Register a built-in panel."""
|
||||
panel = Panel(
|
||||
@@ -375,6 +380,7 @@ def async_register_built_in_panel(
|
||||
config,
|
||||
require_admin,
|
||||
config_panel_domain,
|
||||
show_in_sidebar,
|
||||
)
|
||||
|
||||
panels = hass.data.setdefault(DATA_PANELS, {})
|
||||
@@ -570,28 +576,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"light",
|
||||
sidebar_icon="mdi:lamps",
|
||||
sidebar_title="light",
|
||||
sidebar_default_visible=False,
|
||||
show_in_sidebar=False,
|
||||
)
|
||||
async_register_built_in_panel(
|
||||
hass,
|
||||
"security",
|
||||
sidebar_icon="mdi:security",
|
||||
sidebar_title="security",
|
||||
sidebar_default_visible=False,
|
||||
show_in_sidebar=False,
|
||||
)
|
||||
async_register_built_in_panel(
|
||||
hass,
|
||||
"climate",
|
||||
sidebar_icon="mdi:home-thermometer",
|
||||
sidebar_title="climate",
|
||||
sidebar_default_visible=False,
|
||||
show_in_sidebar=False,
|
||||
)
|
||||
async_register_built_in_panel(
|
||||
hass,
|
||||
"home",
|
||||
sidebar_icon="mdi:home",
|
||||
sidebar_title="home",
|
||||
sidebar_default_visible=False,
|
||||
show_in_sidebar=False,
|
||||
)
|
||||
|
||||
async_register_built_in_panel(hass, "profile")
|
||||
@@ -1085,3 +1091,4 @@ class PanelResponse(TypedDict):
|
||||
url_path: str
|
||||
require_admin: bool
|
||||
config_panel_domain: str | None
|
||||
show_in_sidebar: bool
|
||||
|
||||
@@ -21,5 +21,5 @@
|
||||
"integration_type": "system",
|
||||
"preview_features": { "winter_mode": {} },
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20260128.6"]
|
||||
"requirements": ["home-assistant-frontend==20260226.0"]
|
||||
}
|
||||
|
||||
@@ -19,6 +19,8 @@ from homeassistant.const import (
|
||||
CONF_SSL,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
|
||||
@@ -27,6 +29,34 @@ from homeassistant.helpers.service_info.mqtt import MqttServiceInfo
|
||||
from .const import DEFAULT_PORT, DOMAIN, LOGGER
|
||||
|
||||
|
||||
async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> Any:
|
||||
"""Validate the user input allows us to connect."""
|
||||
fully = FullyKiosk(
|
||||
async_get_clientsession(hass),
|
||||
data[CONF_HOST],
|
||||
DEFAULT_PORT,
|
||||
data[CONF_PASSWORD],
|
||||
use_ssl=data[CONF_SSL],
|
||||
verify_ssl=data[CONF_VERIFY_SSL],
|
||||
)
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(15):
|
||||
device_info = await fully.getDeviceInfo()
|
||||
except (
|
||||
ClientConnectorError,
|
||||
FullyKioskError,
|
||||
TimeoutError,
|
||||
) as error:
|
||||
LOGGER.debug(error.args, exc_info=True)
|
||||
raise CannotConnect from error
|
||||
except Exception as error: # pylint: disable=broad-except
|
||||
LOGGER.exception("Unexpected exception")
|
||||
raise UnknownError from error
|
||||
|
||||
return device_info
|
||||
|
||||
|
||||
class FullyKioskConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Fully Kiosk Browser."""
|
||||
|
||||
@@ -43,58 +73,42 @@ class FullyKioskConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
host: str,
|
||||
user_input: dict[str, Any],
|
||||
errors: dict[str, str],
|
||||
description_placeholders: dict[str, str] | Any = None,
|
||||
) -> ConfigFlowResult | None:
|
||||
fully = FullyKiosk(
|
||||
async_get_clientsession(self.hass),
|
||||
host,
|
||||
DEFAULT_PORT,
|
||||
user_input[CONF_PASSWORD],
|
||||
use_ssl=user_input[CONF_SSL],
|
||||
verify_ssl=user_input[CONF_VERIFY_SSL],
|
||||
)
|
||||
|
||||
"""Create a config entry."""
|
||||
self._async_abort_entries_match({CONF_HOST: host})
|
||||
try:
|
||||
async with asyncio.timeout(15):
|
||||
device_info = await fully.getDeviceInfo()
|
||||
except (
|
||||
ClientConnectorError,
|
||||
FullyKioskError,
|
||||
TimeoutError,
|
||||
) as error:
|
||||
LOGGER.debug(error.args, exc_info=True)
|
||||
device_info = await _validate_input(
|
||||
self.hass, {**user_input, CONF_HOST: host}
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
description_placeholders["error_detail"] = str(error.args)
|
||||
return None
|
||||
except Exception as error: # noqa: BLE001
|
||||
LOGGER.exception("Unexpected exception: %s", error)
|
||||
except UnknownError:
|
||||
errors["base"] = "unknown"
|
||||
description_placeholders["error_detail"] = str(error.args)
|
||||
return None
|
||||
|
||||
await self.async_set_unique_id(device_info["deviceID"], raise_on_progress=False)
|
||||
self._abort_if_unique_id_configured(updates=user_input)
|
||||
return self.async_create_entry(
|
||||
title=device_info["deviceName"],
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_MAC: format_mac(device_info["Mac"]),
|
||||
CONF_SSL: user_input[CONF_SSL],
|
||||
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
|
||||
},
|
||||
)
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
device_info["deviceID"], raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_configured(updates=user_input)
|
||||
return self.async_create_entry(
|
||||
title=device_info["deviceName"],
|
||||
data={
|
||||
CONF_HOST: host,
|
||||
CONF_PASSWORD: user_input[CONF_PASSWORD],
|
||||
CONF_MAC: format_mac(device_info["Mac"]),
|
||||
CONF_SSL: user_input[CONF_SSL],
|
||||
CONF_VERIFY_SSL: user_input[CONF_VERIFY_SSL],
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
placeholders: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
result = await self._create_entry(
|
||||
user_input[CONF_HOST], user_input, errors, placeholders
|
||||
)
|
||||
result = await self._create_entry(user_input[CONF_HOST], user_input, errors)
|
||||
if result:
|
||||
return result
|
||||
|
||||
@@ -108,7 +122,6 @@ class FullyKioskConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
vol.Optional(CONF_VERIFY_SSL, default=False): bool,
|
||||
}
|
||||
),
|
||||
description_placeholders=placeholders,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -171,3 +184,66 @@ class FullyKioskConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self.host = device_info["hostname4"]
|
||||
self._discovered_device_info = device_info
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of an existing config entry."""
|
||||
errors: dict[str, str] = {}
|
||||
reconf_entry = self._get_reconfigure_entry()
|
||||
suggested_values = {
|
||||
CONF_HOST: reconf_entry.data[CONF_HOST],
|
||||
CONF_PASSWORD: reconf_entry.data[CONF_PASSWORD],
|
||||
CONF_SSL: reconf_entry.data[CONF_SSL],
|
||||
CONF_VERIFY_SSL: reconf_entry.data[CONF_VERIFY_SSL],
|
||||
}
|
||||
|
||||
if user_input:
|
||||
try:
|
||||
device_info = await _validate_input(
|
||||
self.hass,
|
||||
data={
|
||||
**reconf_entry.data,
|
||||
**user_input,
|
||||
},
|
||||
)
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except UnknownError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(
|
||||
device_info["deviceID"], raise_on_progress=False
|
||||
)
|
||||
self._abort_if_unique_id_mismatch()
|
||||
return self.async_update_reload_and_abort(
|
||||
reconf_entry,
|
||||
data_updates={
|
||||
**reconf_entry.data,
|
||||
**user_input,
|
||||
},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
vol.Optional(CONF_SSL, default=False): bool,
|
||||
vol.Optional(CONF_VERIFY_SSL, default=False): bool,
|
||||
}
|
||||
),
|
||||
suggested_values=user_input or suggested_values,
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect to the Fully Kiosk device."""
|
||||
|
||||
|
||||
class UnknownError(HomeAssistantError):
|
||||
"""Error to indicate an unknown error occurred."""
|
||||
|
||||
@@ -6,11 +6,13 @@
|
||||
},
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "Please ensure you reconfigure the same device."
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "Cannot connect. Details: {error_detail}",
|
||||
"unknown": "Unknown. Details: {error_detail}"
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"discovery_confirm": {
|
||||
@@ -26,6 +28,20 @@
|
||||
},
|
||||
"description": "Do you want to set up {name} ({host})?"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"ssl": "[%key:common::config_flow::data::ssl%]",
|
||||
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "The hostname or IP address of the device running your Fully Kiosk Browser application.",
|
||||
"password": "[%key:component::fully_kiosk::common::data_description_password%]",
|
||||
"ssl": "[%key:component::fully_kiosk::common::data_description_ssl%]",
|
||||
"verify_ssl": "[%key:component::fully_kiosk::common::data_description_verify_ssl%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]",
|
||||
|
||||
@@ -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/"):
|
||||
|
||||
@@ -207,7 +207,7 @@ class SupervisorOSUpdateEntity(HassioOSEntity, UpdateEntity):
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the icon of the entity."""
|
||||
return "https://brands.home-assistant.io/homeassistant/icon.png"
|
||||
return "/api/brands/integration/homeassistant/icon.png?placeholder=no"
|
||||
|
||||
@property
|
||||
def release_url(self) -> str | None:
|
||||
@@ -258,7 +258,7 @@ class SupervisorSupervisorUpdateEntity(HassioSupervisorEntity, UpdateEntity):
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the icon of the entity."""
|
||||
return "https://brands.home-assistant.io/hassio/icon.png"
|
||||
return "/api/brands/integration/hassio/icon.png?placeholder=no"
|
||||
|
||||
async def async_install(
|
||||
self, version: str | None, backup: bool, **kwargs: Any
|
||||
@@ -296,7 +296,7 @@ class SupervisorCoreUpdateEntity(HassioCoreEntity, UpdateEntity):
|
||||
@property
|
||||
def entity_picture(self) -> str | None:
|
||||
"""Return the icon of the entity."""
|
||||
return "https://brands.home-assistant.io/homeassistant/icon.png"
|
||||
return "/api/brands/integration/homeassistant/icon.png?placeholder=no"
|
||||
|
||||
@property
|
||||
def release_url(self) -> str | None:
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["hdfury==1.5.0"],
|
||||
"requirements": ["hdfury==1.6.0"],
|
||||
"zeroconf": [
|
||||
{ "name": "diva-*", "type": "_http._tcp.local." },
|
||||
{ "name": "vertex2-*", "type": "_http._tcp.local." },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -18,6 +18,7 @@ PLATFORMS = [
|
||||
Platform.LIGHT,
|
||||
Platform.LOCK,
|
||||
Platform.SENSOR,
|
||||
Platform.SIREN,
|
||||
Platform.SWITCH,
|
||||
Platform.VALVE,
|
||||
Platform.WEATHER,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
86
homeassistant/components/homematicip_cloud/siren.py
Normal file
86
homeassistant/components/homematicip_cloud/siren.py
Normal file
@@ -0,0 +1,86 @@
|
||||
"""Support for HomematicIP Cloud sirens."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from homematicip.base.functionalChannels import NotificationMp3SoundChannel
|
||||
from homematicip.device import CombinationSignallingDevice
|
||||
|
||||
from homeassistant.components.siren import (
|
||||
ATTR_TONE,
|
||||
ATTR_VOLUME_LEVEL,
|
||||
SirenEntity,
|
||||
SirenEntityFeature,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .entity import HomematicipGenericEntity
|
||||
from .hap import HomematicIPConfigEntry, HomematicipHAP
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# Map tone integers to HmIP sound file strings
|
||||
_TONE_TO_SOUNDFILE: dict[int, str] = {0: "INTERNAL_SOUNDFILE"}
|
||||
_TONE_TO_SOUNDFILE.update({i: f"SOUNDFILE_{i:03d}" for i in range(1, 253)})
|
||||
|
||||
# Available tones as dict[int, str] for HA UI
|
||||
AVAILABLE_TONES: dict[int, str] = {0: "Internal"}
|
||||
AVAILABLE_TONES.update({i: f"Sound {i}" for i in range(1, 253)})
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
config_entry: HomematicIPConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up the HomematicIP Cloud sirens from a config entry."""
|
||||
hap = config_entry.runtime_data
|
||||
async_add_entities(
|
||||
HomematicipMP3Siren(hap, device)
|
||||
for device in hap.home.devices
|
||||
if isinstance(device, CombinationSignallingDevice)
|
||||
)
|
||||
|
||||
|
||||
class HomematicipMP3Siren(HomematicipGenericEntity, SirenEntity):
|
||||
"""Representation of the HomematicIP MP3 siren (HmIP-MP3P)."""
|
||||
|
||||
_attr_available_tones = AVAILABLE_TONES
|
||||
_attr_supported_features = (
|
||||
SirenEntityFeature.TURN_ON
|
||||
| SirenEntityFeature.TURN_OFF
|
||||
| SirenEntityFeature.TONES
|
||||
| SirenEntityFeature.VOLUME_SET
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self, hap: HomematicipHAP, device: CombinationSignallingDevice
|
||||
) -> None:
|
||||
"""Initialize the siren entity."""
|
||||
super().__init__(hap, device, post="Siren", channel=1, is_multi_channel=False)
|
||||
|
||||
@property
|
||||
def _func_channel(self) -> NotificationMp3SoundChannel:
|
||||
return self._device.functionalChannels[self._channel]
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
"""Return true if siren is playing."""
|
||||
return self._func_channel.playingFileActive
|
||||
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Turn the siren on."""
|
||||
tone = kwargs.get(ATTR_TONE, 0)
|
||||
volume_level = kwargs.get(ATTR_VOLUME_LEVEL, 1.0)
|
||||
|
||||
sound_file = _TONE_TO_SOUNDFILE.get(tone, "INTERNAL_SOUNDFILE")
|
||||
await self._func_channel.set_sound_file_volume_level_async(
|
||||
sound_file=sound_file, volume_level=volume_level
|
||||
)
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Turn the siren off."""
|
||||
await self._func_channel.stop_sound_async()
|
||||
@@ -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",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"domain": "homevolt",
|
||||
"name": "Homevolt",
|
||||
"codeowners": ["@danielhiversen"],
|
||||
"codeowners": ["@danielhiversen", "@liudger"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/homevolt",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
111
homeassistant/components/indevolt/select.py
Normal file
111
homeassistant/components/indevolt/select.py
Normal 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}")
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.15.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2026.2.13.222258"
|
||||
"knx-frontend==2026.2.25.165736"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -219,7 +219,7 @@ async def library_payload(hass):
|
||||
)
|
||||
|
||||
for child in library_info.children:
|
||||
child.thumbnail = "https://brands.home-assistant.io/_/kodi/logo.png"
|
||||
child.thumbnail = "/api/brands/integration/kodi/logo.png"
|
||||
|
||||
with contextlib.suppress(BrowseError):
|
||||
item = await media_source.async_browse_media(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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]):
|
||||
|
||||
@@ -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()
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -353,14 +353,13 @@ def _register_panel(
|
||||
kwargs = {
|
||||
"frontend_url_path": url_path,
|
||||
"require_admin": config[CONF_REQUIRE_ADMIN],
|
||||
"show_in_sidebar": config[CONF_SHOW_IN_SIDEBAR],
|
||||
"sidebar_title": config[CONF_TITLE],
|
||||
"sidebar_icon": config.get(CONF_ICON, DEFAULT_ICON),
|
||||
"config": {"mode": mode},
|
||||
"update": update,
|
||||
}
|
||||
|
||||
if config[CONF_SHOW_IN_SIDEBAR]:
|
||||
kwargs["sidebar_title"] = config[CONF_TITLE]
|
||||
kwargs["sidebar_icon"] = config.get(CONF_ICON, DEFAULT_ICON)
|
||||
|
||||
frontend.async_register_built_in_panel(hass, DOMAIN, **kwargs)
|
||||
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ async def async_get_media_browser_root_object(
|
||||
media_class=MediaClass.APP,
|
||||
media_content_id="",
|
||||
media_content_type=DOMAIN,
|
||||
thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png",
|
||||
thumbnail="/api/brands/integration/lovelace/logo.png",
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
)
|
||||
@@ -72,7 +72,7 @@ async def async_browse_media(
|
||||
media_class=MediaClass.APP,
|
||||
media_content_id=DEFAULT_DASHBOARD,
|
||||
media_content_type=DOMAIN,
|
||||
thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png",
|
||||
thumbnail="/api/brands/integration/lovelace/logo.png",
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
)
|
||||
@@ -104,7 +104,7 @@ async def async_browse_media(
|
||||
media_class=MediaClass.APP,
|
||||
media_content_id=f"{info['url_path']}/{view['path']}",
|
||||
media_content_type=DOMAIN,
|
||||
thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png",
|
||||
thumbnail="/api/brands/integration/lovelace/logo.png",
|
||||
can_play=True,
|
||||
can_expand=False,
|
||||
)
|
||||
@@ -213,7 +213,7 @@ def _item_from_info(info: dict) -> BrowseMedia:
|
||||
media_class=MediaClass.APP,
|
||||
media_content_id=info["url_path"],
|
||||
media_content_type=DOMAIN,
|
||||
thumbnail="https://brands.home-assistant.io/_/lovelace/logo.png",
|
||||
thumbnail="/api/brands/integration/lovelace/logo.png",
|
||||
can_play=True,
|
||||
can_expand=len(info["views"]) > 1,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,67 @@ 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']}"
|
||||
)
|
||||
|
||||
await self.send_device_command(
|
||||
clusters.RvcRunMode.Commands.ChangeToMode(newMode=mode.mode)
|
||||
)
|
||||
|
||||
@callback
|
||||
def _update_from_device(self) -> None:
|
||||
"""Update from device."""
|
||||
@@ -176,16 +249,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 +303,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 +325,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,
|
||||
),
|
||||
|
||||
@@ -69,34 +69,37 @@ class MatterValve(MatterEntity, ValveEntity):
|
||||
def _update_from_device(self) -> None:
|
||||
"""Update from device."""
|
||||
self._calculate_features()
|
||||
current_state: int
|
||||
self._attr_is_opening = False
|
||||
self._attr_is_closing = False
|
||||
|
||||
current_state: int | None
|
||||
current_state = self.get_matter_attribute_value(
|
||||
ValveConfigurationAndControl.Attributes.CurrentState
|
||||
)
|
||||
target_state: int
|
||||
target_state: int | None
|
||||
target_state = self.get_matter_attribute_value(
|
||||
ValveConfigurationAndControl.Attributes.TargetState
|
||||
)
|
||||
if (
|
||||
current_state == ValveStateEnum.kTransitioning
|
||||
and target_state == ValveStateEnum.kOpen
|
||||
|
||||
if current_state is None:
|
||||
self._attr_is_closed = None
|
||||
elif current_state == ValveStateEnum.kTransitioning and (
|
||||
target_state == ValveStateEnum.kOpen
|
||||
):
|
||||
self._attr_is_opening = True
|
||||
self._attr_is_closing = False
|
||||
elif (
|
||||
current_state == ValveStateEnum.kTransitioning
|
||||
and target_state == ValveStateEnum.kClosed
|
||||
self._attr_is_closed = None
|
||||
elif current_state == ValveStateEnum.kTransitioning and (
|
||||
target_state == ValveStateEnum.kClosed
|
||||
):
|
||||
self._attr_is_opening = False
|
||||
self._attr_is_closing = True
|
||||
self._attr_is_closed = None
|
||||
elif current_state == ValveStateEnum.kClosed:
|
||||
self._attr_is_opening = False
|
||||
self._attr_is_closing = False
|
||||
self._attr_is_closed = True
|
||||
else:
|
||||
self._attr_is_opening = False
|
||||
self._attr_is_closing = False
|
||||
elif current_state == ValveStateEnum.kOpen:
|
||||
self._attr_is_closed = False
|
||||
else:
|
||||
self._attr_is_closed = None
|
||||
|
||||
# handle optional position
|
||||
if self.supported_features & ValveEntityFeature.SET_POSITION:
|
||||
self._attr_current_valve_position = self.get_matter_attribute_value(
|
||||
@@ -145,6 +148,7 @@ DISCOVERY_SCHEMAS = [
|
||||
ValveConfigurationAndControl.Attributes.CurrentState,
|
||||
ValveConfigurationAndControl.Attributes.TargetState,
|
||||
),
|
||||
allow_none_value=True,
|
||||
optional_attributes=(ValveConfigurationAndControl.Attributes.CurrentLevel,),
|
||||
device_type=(device_types.WaterValve,),
|
||||
),
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -83,7 +83,7 @@ class MediaSourceItem:
|
||||
identifier=None,
|
||||
media_class=MediaClass.APP,
|
||||
media_content_type=MediaType.APP,
|
||||
thumbnail=f"https://brands.home-assistant.io/_/{source.domain}/logo.png",
|
||||
thumbnail=f"/api/brands/integration/{source.domain}/logo.png",
|
||||
title=source.name,
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
|
||||
30
homeassistant/components/met/diagnostics.py
Normal file
30
homeassistant/components/met/diagnostics.py
Normal 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,
|
||||
},
|
||||
}
|
||||
@@ -20,11 +20,11 @@ from .coordinator import NintendoParentalControlsConfigEntry, NintendoUpdateCoor
|
||||
from .services import async_setup_services
|
||||
|
||||
_PLATFORMS: list[Platform] = [
|
||||
Platform.SENSOR,
|
||||
Platform.TIME,
|
||||
Platform.SWITCH,
|
||||
Platform.NUMBER,
|
||||
Platform.SELECT,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
Platform.TIME,
|
||||
]
|
||||
|
||||
PLATFORM_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
30
homeassistant/components/nrgkick/diagnostics.py
Normal file
30
homeassistant/components/nrgkick/diagnostics.py
Normal 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,
|
||||
)
|
||||
@@ -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."]
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)},
|
||||
)
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["aiontfy"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiontfy==0.8.0"]
|
||||
"requirements": ["aiontfy==0.8.1"]
|
||||
}
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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": {
|
||||
|
||||
116
homeassistant/components/ntfy/update.py
Normal file
116
homeassistant/components/ntfy/update.py
Normal 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
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -501,6 +512,11 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
options.pop(CONF_WEB_SEARCH_REGION, None)
|
||||
options.pop(CONF_WEB_SEARCH_COUNTRY, None)
|
||||
options.pop(CONF_WEB_SEARCH_TIMEZONE, None)
|
||||
if (
|
||||
user_input.get(CONF_CODE_INTERPRETER)
|
||||
and user_input.get(CONF_REASONING_EFFORT) == "minimal"
|
||||
):
|
||||
errors[CONF_CODE_INTERPRETER] = "code_interpreter_minimal_reasoning"
|
||||
|
||||
options.update(user_input)
|
||||
if not errors:
|
||||
@@ -528,15 +544,15 @@ class OpenAISubentryFlowHandler(ConfigSubentryFlow):
|
||||
if not model.startswith(("o", "gpt-5")) or model.startswith("gpt-5-pro"):
|
||||
return []
|
||||
|
||||
MODELS_REASONING_MAP = {
|
||||
models_reasoning_map: dict[str | tuple[str, ...], list[str]] = {
|
||||
"gpt-5.2-pro": ["medium", "high", "xhigh"],
|
||||
"gpt-5.2": ["none", "low", "medium", "high", "xhigh"],
|
||||
("gpt-5.2", "gpt-5.3"): ["none", "low", "medium", "high", "xhigh"],
|
||||
"gpt-5.1": ["none", "low", "medium", "high"],
|
||||
"gpt-5": ["minimal", "low", "medium", "high"],
|
||||
"": ["low", "medium", "high"], # The default case
|
||||
}
|
||||
|
||||
for prefix, options in MODELS_REASONING_MAP.items():
|
||||
for prefix, options in models_reasoning_map.items():
|
||||
if model.startswith(prefix):
|
||||
return options
|
||||
return [] # pragma: no cover
|
||||
@@ -595,6 +611,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."""
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user