mirror of
https://github.com/home-assistant/core.git
synced 2026-04-13 05:06:12 +02:00
Compare commits
100 Commits
gj-2026040
...
python-3.1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
427faf4854 | ||
|
|
191dd42a92 | ||
|
|
35ffffb159 | ||
|
|
4494f9ff6b | ||
|
|
09e6b6533a | ||
|
|
c42e37dd7d | ||
|
|
853b6a80d2 | ||
|
|
eaa1fc591a | ||
|
|
3f388e88e0 | ||
|
|
44eea221b7 | ||
|
|
6a3937b96b | ||
|
|
1c5e020344 | ||
|
|
6ac7952f26 | ||
|
|
7f0d94da9f | ||
|
|
8f383bccd9 | ||
|
|
8c50cb2ab1 | ||
|
|
b0888b051c | ||
|
|
0764e3e239 | ||
|
|
cf4d8f0974 | ||
|
|
e7e4c495fd | ||
|
|
8f6ae15a6a | ||
|
|
910dcb4d68 | ||
|
|
86b5efaf2c | ||
|
|
5f8483ba07 | ||
|
|
496c9551b3 | ||
|
|
2d45f9978e | ||
|
|
caa1a8880f | ||
|
|
1e78666b90 | ||
|
|
53738c0168 | ||
|
|
ca96c751e1 | ||
|
|
2a0a386e6d | ||
|
|
79dfa61e8b | ||
|
|
431387b76d | ||
|
|
f4a2f37fa6 | ||
|
|
ec54a121c1 | ||
|
|
f5d5ee71f5 | ||
|
|
7e1f4d27e8 | ||
|
|
4700c79ace | ||
|
|
b6ea61f953 | ||
|
|
1eab08f986 | ||
|
|
f491ec8b44 | ||
|
|
e639e983dc | ||
|
|
a983cb7ccd | ||
|
|
89ddfff66f | ||
|
|
77c8eab698 | ||
|
|
9ac730fb58 | ||
|
|
6cc05e6a28 | ||
|
|
75a4b088bc | ||
|
|
9056e0b64f | ||
|
|
3a1002457b | ||
|
|
97fe710187 | ||
|
|
09585a7e1c | ||
|
|
6d55c076e4 | ||
|
|
8b37cc8719 | ||
|
|
6510b3d1d1 | ||
|
|
e5a83106d7 | ||
|
|
de973e8900 | ||
|
|
fefc5a950f | ||
|
|
b3e7ae0fdd | ||
|
|
7bad7fc4f6 | ||
|
|
36944525e1 | ||
|
|
f3d25a04f8 | ||
|
|
f2c20fedeb | ||
|
|
93e9575547 | ||
|
|
681f8bedb4 | ||
|
|
566ff6d1d5 | ||
|
|
5bec3d1b41 | ||
|
|
050d929d8a | ||
|
|
15045f55d5 | ||
|
|
b2fb6c0a68 | ||
|
|
66e35cef06 | ||
|
|
9ea527520a | ||
|
|
872120821c | ||
|
|
998f24649d | ||
|
|
cc21c99e55 | ||
|
|
4efb6b9b56 | ||
|
|
a9f0cd203c | ||
|
|
eb31499e78 | ||
|
|
d292aa2e90 | ||
|
|
87f44a67be | ||
|
|
efb0e80577 | ||
|
|
11c34c7ddf | ||
|
|
4b820a0204 | ||
|
|
0c98f01b07 | ||
|
|
aa50822a82 | ||
|
|
f634525798 | ||
|
|
047500af42 | ||
|
|
db589f7318 | ||
|
|
3ea15f2743 | ||
|
|
8e430d9f26 | ||
|
|
075b47b5f9 | ||
|
|
949c907407 | ||
|
|
326799209c | ||
|
|
65bc7c9ea7 | ||
|
|
86d443f8c6 | ||
|
|
19ae7e722e | ||
|
|
57568fdc2c | ||
|
|
4c8a660b2d | ||
|
|
b0511519a1 | ||
|
|
038b583888 |
14
.github/workflows/builder.yml
vendored
14
.github/workflows/builder.yml
vendored
@@ -14,7 +14,7 @@ env:
|
||||
UV_HTTP_TIMEOUT: 60
|
||||
UV_SYSTEM_PYTHON: "true"
|
||||
# Base image version from https://github.com/home-assistant/docker
|
||||
BASE_IMAGE_VERSION: "2026.01.0"
|
||||
BASE_IMAGE_VERSION: "2026.02.0"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
permissions: {}
|
||||
@@ -108,7 +108,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of frontend
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: home-assistant/frontend
|
||||
@@ -119,7 +119,7 @@ jobs:
|
||||
|
||||
- name: Download nightly wheels of intents
|
||||
if: needs.init.outputs.channel == 'dev'
|
||||
uses: dawidd6/action-download-artifact@8a338493df3d275e4a7a63bcff3b8fe97e51a927 # v19
|
||||
uses: dawidd6/action-download-artifact@8305c0f1062bb0d184d09ef4493ecb9288447732 # v20
|
||||
with:
|
||||
github_token: ${{secrets.GITHUB_TOKEN}}
|
||||
repo: OHF-Voice/intents-package
|
||||
@@ -344,13 +344,13 @@ jobs:
|
||||
|
||||
- name: Login to DockerHub
|
||||
if: matrix.registry == 'docker.io/homeassistant'
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
@@ -499,7 +499,7 @@ jobs:
|
||||
python -m build
|
||||
|
||||
- name: Upload package to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@ed0c53931b1dc9bd32cbe73a98c7f6766f8a527e # v1.13.0
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
@@ -523,7 +523,7 @@ jobs:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Login to GitHub Container Registry
|
||||
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.repository_owner }}
|
||||
|
||||
@@ -1 +1 @@
|
||||
3.14.2
|
||||
3.14.3
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -418,6 +418,8 @@ CLAUDE.md @home-assistant/core
|
||||
/tests/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna
|
||||
/homeassistant/components/duckdns/ @tr4nt0r
|
||||
/tests/components/duckdns/ @tr4nt0r
|
||||
/homeassistant/components/duco/ @ronaldvdmeer
|
||||
/tests/components/duco/ @ronaldvdmeer
|
||||
/homeassistant/components/duotecno/ @cereal2nd
|
||||
/tests/components/duotecno/ @cereal2nd
|
||||
/homeassistant/components/dwd_weather_warnings/ @runningman84 @stephan192
|
||||
|
||||
@@ -54,15 +54,9 @@ rules:
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: This integration does not use entity categories.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: This integration does not use entity device classes.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: Not required for this integration at this stage.
|
||||
entity-category: done
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: todo
|
||||
exception-translations: done
|
||||
icon-translations: todo
|
||||
|
||||
@@ -54,7 +54,16 @@ class AmazonDevicesCoordinator(DataUpdateCoordinator[dict[str, AmazonDevice]]):
|
||||
entry.data[CONF_PASSWORD],
|
||||
entry.data[CONF_LOGIN_DATA],
|
||||
)
|
||||
self.previous_devices: set[str] = set()
|
||||
device_registry = dr.async_get(hass)
|
||||
self.previous_devices: set[str] = {
|
||||
identifier
|
||||
for device in device_registry.devices.get_devices_for_config_entry_id(
|
||||
entry.entry_id
|
||||
)
|
||||
if device.entry_type != dr.DeviceEntryType.SERVICE
|
||||
for identifier_domain, identifier in device.identifiers
|
||||
if identifier_domain == DOMAIN
|
||||
}
|
||||
|
||||
async def _async_update_data(self) -> dict[str, AmazonDevice]:
|
||||
"""Update device data."""
|
||||
|
||||
@@ -37,6 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) ->
|
||||
coordinator = AnthropicCoordinator(hass, entry)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
entry.runtime_data = coordinator
|
||||
LOGGER.debug("Available models: %s", coordinator.data)
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
from collections.abc import Mapping
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
import anthropic
|
||||
@@ -71,6 +70,7 @@ from .const import (
|
||||
WEB_SEARCH_UNSUPPORTED_MODELS,
|
||||
PromptCaching,
|
||||
)
|
||||
from .coordinator import model_alias
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import AnthropicConfigEntry
|
||||
@@ -112,25 +112,13 @@ async def get_model_list(client: anthropic.AsyncAnthropic) -> list[SelectOptionD
|
||||
except anthropic.AnthropicError:
|
||||
models = []
|
||||
_LOGGER.debug("Available models: %s", models)
|
||||
model_options: list[SelectOptionDict] = []
|
||||
short_form = re.compile(r"[^\d]-\d$")
|
||||
for model_info in models:
|
||||
# Resolve alias from versioned model name:
|
||||
model_alias = (
|
||||
model_info.id[:-9]
|
||||
if model_info.id != "claude-3-haiku-20240307"
|
||||
and model_info.id[-2:-1] != "-"
|
||||
else model_info.id
|
||||
return [
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias(model_info.id),
|
||||
)
|
||||
if short_form.search(model_alias):
|
||||
model_alias += "-0"
|
||||
model_options.append(
|
||||
SelectOptionDict(
|
||||
label=model_info.display_name,
|
||||
value=model_alias,
|
||||
)
|
||||
)
|
||||
return model_options
|
||||
for model_info in models
|
||||
]
|
||||
|
||||
|
||||
class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import datetime
|
||||
import re
|
||||
|
||||
import anthropic
|
||||
|
||||
@@ -15,13 +16,28 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, Upda
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
UPDATE_INTERVAL_CONNECTED = timedelta(hours=12)
|
||||
UPDATE_INTERVAL_DISCONNECTED = timedelta(minutes=1)
|
||||
UPDATE_INTERVAL_CONNECTED = datetime.timedelta(hours=12)
|
||||
UPDATE_INTERVAL_DISCONNECTED = datetime.timedelta(minutes=1)
|
||||
|
||||
type AnthropicConfigEntry = ConfigEntry[AnthropicCoordinator]
|
||||
|
||||
|
||||
class AnthropicCoordinator(DataUpdateCoordinator[None]):
|
||||
_model_short_form = re.compile(r"[^\d]-\d$")
|
||||
|
||||
|
||||
@callback
|
||||
def model_alias(model_id: str) -> str:
|
||||
"""Resolve alias from versioned model name."""
|
||||
if model_id == "claude-3-haiku-20240307" or model_id.endswith("-preview"):
|
||||
return model_id
|
||||
if model_id[-2:-1] != "-":
|
||||
model_id = model_id[:-9]
|
||||
if _model_short_form.search(model_id):
|
||||
return model_id + "-0"
|
||||
return model_id
|
||||
|
||||
|
||||
class AnthropicCoordinator(DataUpdateCoordinator[list[anthropic.types.ModelInfo]]):
|
||||
"""DataUpdateCoordinator which uses different intervals after successful and unsuccessful updates."""
|
||||
|
||||
client: anthropic.AsyncAnthropic
|
||||
@@ -42,16 +58,16 @@ class AnthropicCoordinator(DataUpdateCoordinator[None]):
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_set_updated_data(self, data: None) -> None:
|
||||
def async_set_updated_data(self, data: list[anthropic.types.ModelInfo]) -> None:
|
||||
"""Manually update data, notify listeners and update refresh interval."""
|
||||
self.update_interval = UPDATE_INTERVAL_CONNECTED
|
||||
super().async_set_updated_data(data)
|
||||
|
||||
async def async_update_data(self) -> None:
|
||||
async def async_update_data(self) -> list[anthropic.types.ModelInfo]:
|
||||
"""Fetch data from the API."""
|
||||
try:
|
||||
self.update_interval = UPDATE_INTERVAL_DISCONNECTED
|
||||
await self.client.models.list(timeout=10.0)
|
||||
result = await self.client.models.list(timeout=10.0)
|
||||
self.update_interval = UPDATE_INTERVAL_CONNECTED
|
||||
except anthropic.APITimeoutError as err:
|
||||
raise TimeoutError(err.message or str(err)) from err
|
||||
@@ -67,6 +83,7 @@ class AnthropicCoordinator(DataUpdateCoordinator[None]):
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"message": err.message},
|
||||
) from err
|
||||
return result.data
|
||||
|
||||
def mark_connection_error(self) -> None:
|
||||
"""Mark the connection as having an error and reschedule background check."""
|
||||
@@ -76,3 +93,23 @@ class AnthropicCoordinator(DataUpdateCoordinator[None]):
|
||||
self.async_update_listeners()
|
||||
if self._listeners and not self.hass.is_stopping:
|
||||
self._schedule_refresh()
|
||||
|
||||
@callback
|
||||
def get_model_info(self, model_id: str) -> anthropic.types.ModelInfo:
|
||||
"""Get model info for a given model ID."""
|
||||
# First try: exact name match
|
||||
for model in self.data or []:
|
||||
if model.id == model_id:
|
||||
return model
|
||||
# Second try: match by alias
|
||||
alias = model_alias(model_id)
|
||||
for model in self.data or []:
|
||||
if model_alias(model.id) == alias:
|
||||
return model
|
||||
# Model not found, return safe defaults
|
||||
return anthropic.types.ModelInfo(
|
||||
type="model",
|
||||
id=model_id,
|
||||
created_at=datetime.datetime(1970, 1, 1, tzinfo=datetime.UTC),
|
||||
display_name=model_id,
|
||||
)
|
||||
|
||||
@@ -689,12 +689,17 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
super().__init__(entry.runtime_data)
|
||||
self.entry = entry
|
||||
self.subentry = subentry
|
||||
coordinator = entry.runtime_data
|
||||
self.model_info = coordinator.get_model_info(
|
||||
subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL])
|
||||
)
|
||||
self._attr_unique_id = subentry.subentry_id
|
||||
self._attr_device_info = dr.DeviceInfo(
|
||||
identifiers={(DOMAIN, subentry.subentry_id)},
|
||||
name=subentry.title,
|
||||
manufacturer="Anthropic",
|
||||
model=subentry.data.get(CONF_CHAT_MODEL, DEFAULT[CONF_CHAT_MODEL]),
|
||||
model=self.model_info.display_name,
|
||||
model_id=self.model_info.id,
|
||||
entry_type=dr.DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@@ -969,7 +974,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
) from err
|
||||
except anthropic.AnthropicError as err:
|
||||
# Non-connection error, mark connection as healthy
|
||||
coordinator.async_set_updated_data(None)
|
||||
coordinator.async_set_updated_data(coordinator.data)
|
||||
LOGGER.error("Error while talking to Anthropic: %s", err)
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
@@ -982,7 +987,7 @@ class AnthropicBaseLLMEntity(CoordinatorEntity[AnthropicCoordinator]):
|
||||
) from err
|
||||
|
||||
if not chat_log.unresponded_tool_results:
|
||||
coordinator.async_set_updated_data(None)
|
||||
coordinator.async_set_updated_data(coordinator.data)
|
||||
break
|
||||
|
||||
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["anthropic==0.83.0"]
|
||||
"requirements": ["anthropic==0.92.0"]
|
||||
}
|
||||
|
||||
@@ -81,7 +81,10 @@ rules:
|
||||
status: exempt
|
||||
comment: |
|
||||
No entities disabled by default.
|
||||
entity-translations: todo
|
||||
entity-translations:
|
||||
status: exempt
|
||||
comment: |
|
||||
Entities explicitly set `_attr_name` to `None`, so entity name translations are not used.
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: done
|
||||
|
||||
@@ -7,9 +7,9 @@ import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from aurorapy.client import AuroraError, AuroraSerialClient
|
||||
import serial.tools.list_ports
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import ATTR_SERIAL_NUMBER, CONF_ADDRESS, CONF_PORT
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -57,9 +57,11 @@ def validate_and_connect(
|
||||
return ret
|
||||
|
||||
|
||||
def scan_comports() -> tuple[list[str] | None, str | None]:
|
||||
async def async_scan_comports(
|
||||
hass: HomeAssistant,
|
||||
) -> tuple[list[str] | None, str | None]:
|
||||
"""Find and store available com ports for the GUI dropdown."""
|
||||
com_ports = serial.tools.list_ports.comports(include_links=True)
|
||||
com_ports = await usb.async_scan_serial_ports(hass)
|
||||
com_ports_list = []
|
||||
for port in com_ports:
|
||||
com_ports_list.append(port.device)
|
||||
@@ -87,7 +89,7 @@ class AuroraABBConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
errors = {}
|
||||
if self._com_ports_list is None:
|
||||
result = await self.hass.async_add_executor_job(scan_comports)
|
||||
result = await async_scan_comports(self.hass)
|
||||
self._com_ports_list, self._default_com_port = result
|
||||
if self._default_com_port is None:
|
||||
return self.async_abort(reason="no_serial_ports")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "Aurora ABB PowerOne Solar PV",
|
||||
"codeowners": ["@davet2001"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/aurora_abb_powerone",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
|
||||
@@ -8,7 +8,7 @@ from autoskope_client.models import CannotConnect, InvalidAuth
|
||||
|
||||
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_create_clientsession
|
||||
|
||||
from .const import DEFAULT_HOST
|
||||
@@ -31,8 +31,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) ->
|
||||
try:
|
||||
await api.connect()
|
||||
except InvalidAuth as err:
|
||||
# Raise ConfigEntryError until reauth flow is implemented (then ConfigEntryAuthFailed)
|
||||
raise ConfigEntryError(
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Authentication failed, please check credentials"
|
||||
) from err
|
||||
except CannotConnect as err:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
|
||||
from autoskope_client.api import AutoskopeApi
|
||||
@@ -39,12 +40,39 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
STEP_REAUTH_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_PASSWORD): TextSelector(
|
||||
TextSelectorConfig(type=TextSelectorType.PASSWORD)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a config flow for Autoskope."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def _async_validate_credentials(
|
||||
self, host: str, username: str, password: str, errors: dict[str, str]
|
||||
) -> bool:
|
||||
"""Validate credentials against the Autoskope API."""
|
||||
try:
|
||||
async with AutoskopeApi(
|
||||
host=host,
|
||||
username=username,
|
||||
password=password,
|
||||
):
|
||||
pass
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
return True
|
||||
return False
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
@@ -63,18 +91,9 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
await self.async_set_unique_id(f"{username}@{host}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
try:
|
||||
async with AutoskopeApi(
|
||||
host=host,
|
||||
username=username,
|
||||
password=user_input[CONF_PASSWORD],
|
||||
):
|
||||
pass
|
||||
except CannotConnect:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
errors["base"] = "invalid_auth"
|
||||
else:
|
||||
if await self._async_validate_credentials(
|
||||
host, username, user_input[CONF_PASSWORD], errors
|
||||
):
|
||||
return self.async_create_entry(
|
||||
title=f"Autoskope ({username})",
|
||||
data={
|
||||
@@ -87,3 +106,35 @@ class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle initiation of re-authentication."""
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle re-authentication with new credentials."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
reauth_entry = self._get_reauth_entry()
|
||||
|
||||
if await self._async_validate_credentials(
|
||||
reauth_entry.data[CONF_HOST],
|
||||
reauth_entry.data[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
errors,
|
||||
):
|
||||
return self.async_update_reload_and_abort(
|
||||
reauth_entry,
|
||||
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=STEP_REAUTH_DATA_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
@@ -39,10 +39,7 @@ rules:
|
||||
integration-owner: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: todo
|
||||
comment: |
|
||||
Reauthentication flow removed for initial PR, will be added in follow-up.
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
# Gold
|
||||
devices: done
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -10,6 +11,15 @@
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"reauth_confirm": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "The new password for your Autoskope account."
|
||||
},
|
||||
"description": "Please re-enter your password for your Autoskope account."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["brother", "pyasn1", "pysmi", "pysnmp"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["brother==6.0.0"],
|
||||
"requirements": ["brother==6.1.0"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"name": "brother*",
|
||||
|
||||
@@ -112,7 +112,7 @@ class ComelitAlarmEntity(
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if alarm is available."""
|
||||
if self._area.human_status in [AlarmAreaState.ANOMALY, AlarmAreaState.UNKNOWN]:
|
||||
if self._area.human_status == AlarmAreaState.UNKNOWN:
|
||||
return False
|
||||
return super().available
|
||||
|
||||
@@ -151,7 +151,7 @@ class ComelitAlarmEntity(
|
||||
if code != str(self.coordinator.api.device_pin):
|
||||
return
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[DISABLE]
|
||||
self._area.index, ALARM_ACTIONS[DISABLE], self._area.anomaly
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.DISARMED, ALARM_AREA_ARMED_STATUS[DISABLE]
|
||||
@@ -160,7 +160,7 @@ class ComelitAlarmEntity(
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[AWAY]
|
||||
self._area.index, ALARM_ACTIONS[AWAY], self._area.anomaly
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[AWAY]
|
||||
@@ -169,7 +169,7 @@ class ComelitAlarmEntity(
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[HOME]
|
||||
self._area.index, ALARM_ACTIONS[HOME], self._area.anomaly
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[HOME_P1]
|
||||
@@ -178,7 +178,7 @@ class ComelitAlarmEntity(
|
||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||
"""Send arm night command."""
|
||||
await self.coordinator.api.set_zone_status(
|
||||
self._area.index, ALARM_ACTIONS[NIGHT]
|
||||
self._area.index, ALARM_ACTIONS[NIGHT], self._area.anomaly
|
||||
)
|
||||
await self._async_update_state(
|
||||
AlarmAreaState.ARMED, ALARM_AREA_ARMED_STATUS[NIGHT]
|
||||
|
||||
@@ -65,6 +65,7 @@ class ComelitBaseCoordinator(DataUpdateCoordinator[T]):
|
||||
)
|
||||
device_registry = dr.async_get(self.hass)
|
||||
device_registry.async_get_or_create(
|
||||
configuration_url=self.api.base_url,
|
||||
config_entry_id=entry.entry_id,
|
||||
identifiers={(DOMAIN, entry.entry_id)},
|
||||
model=device,
|
||||
|
||||
@@ -10,8 +10,6 @@ from crownstone_cloud.exceptions import (
|
||||
CrownstoneAuthenticationError,
|
||||
CrownstoneUnknownError,
|
||||
)
|
||||
import serial.tools.list_ports
|
||||
from serial.tools.list_ports_common import ListPortInfo
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
@@ -61,9 +59,11 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Set up a Crownstone USB dongle."""
|
||||
list_of_ports = await self.hass.async_add_executor_job(
|
||||
serial.tools.list_ports.comports
|
||||
)
|
||||
list_of_ports = [
|
||||
p
|
||||
for p in await usb.async_scan_serial_ports(self.hass)
|
||||
if isinstance(p, usb.USBDevice)
|
||||
]
|
||||
if self.flow_type == CONFIG_FLOW:
|
||||
ports_as_string = list_ports_as_str(list_of_ports)
|
||||
else:
|
||||
@@ -82,10 +82,8 @@ class BaseCrownstoneFlowHandler(ConfigEntryBaseFlow):
|
||||
else:
|
||||
index = ports_as_string.index(selection) - 1
|
||||
|
||||
selected_port: ListPortInfo = list_of_ports[index]
|
||||
self.usb_path = await self.hass.async_add_executor_job(
|
||||
usb.get_serial_by_id, selected_port.device
|
||||
)
|
||||
selected_port = list_of_ports[index]
|
||||
self.usb_path = selected_port.device
|
||||
return await self.async_step_usb_sphere_config()
|
||||
|
||||
return self.async_show_form(
|
||||
|
||||
@@ -5,15 +5,14 @@ from __future__ import annotations
|
||||
from collections.abc import Sequence
|
||||
import os
|
||||
|
||||
from serial.tools.list_ports_common import ListPortInfo
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.components.usb import USBDevice
|
||||
|
||||
from .const import DONT_USE_USB, MANUAL_PATH, REFRESH_LIST
|
||||
|
||||
|
||||
def list_ports_as_str(
|
||||
serial_ports: Sequence[ListPortInfo], no_usb_option: bool = True
|
||||
serial_ports: Sequence[USBDevice], no_usb_option: bool = True
|
||||
) -> list[str]:
|
||||
"""Represent currently available serial ports as string.
|
||||
|
||||
@@ -31,8 +30,8 @@ def list_ports_as_str(
|
||||
port.serial_number,
|
||||
port.manufacturer,
|
||||
port.description,
|
||||
f"{hex(port.vid)[2:]:0>4}".upper() if port.vid else None,
|
||||
f"{hex(port.pid)[2:]:0>4}".upper() if port.pid else None,
|
||||
port.vid,
|
||||
port.pid,
|
||||
)
|
||||
for port in serial_ports
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"domain": "crownstone",
|
||||
"name": "Crownstone",
|
||||
"after_dependencies": ["usb"],
|
||||
"codeowners": ["@Crownstone", "@RicArch97"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/crownstone",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": [
|
||||
@@ -15,7 +15,6 @@
|
||||
"requirements": [
|
||||
"crownstone-cloud==1.4.11",
|
||||
"crownstone-sse==2.0.5",
|
||||
"crownstone-uart==2.1.0",
|
||||
"pyserial==3.5"
|
||||
"crownstone-uart==2.1.0"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from functools import partial
|
||||
import os
|
||||
from typing import Any
|
||||
|
||||
from dsmr_parser import obis_references as obis_ref
|
||||
@@ -15,9 +14,9 @@ from dsmr_parser.clients.rfxtrx_protocol import (
|
||||
)
|
||||
from dsmr_parser.objects import DSMRObject
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
@@ -229,9 +228,7 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
self._dsmr_version = user_input[CONF_DSMR_VERSION]
|
||||
return await self.async_step_setup_serial_manual_path()
|
||||
|
||||
dev_path = await self.hass.async_add_executor_job(
|
||||
get_serial_by_id, user_selection
|
||||
)
|
||||
dev_path = user_selection
|
||||
|
||||
validate_data = {
|
||||
CONF_PORT: dev_path,
|
||||
@@ -242,9 +239,10 @@ class DSMRFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
if not errors:
|
||||
return self.async_create_entry(title=data[CONF_PORT], data=data)
|
||||
|
||||
ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
|
||||
ports = await usb.async_scan_serial_ports(self.hass)
|
||||
list_of_ports = {
|
||||
port.device: f"{port}, s/n: {port.serial_number or 'n/a'}"
|
||||
port.device: f"{port.device} - {port.description or 'n/a'}"
|
||||
f", s/n: {port.serial_number or 'n/a'}"
|
||||
+ (f" - {port.manufacturer}" if port.manufacturer else "")
|
||||
for port in ports
|
||||
}
|
||||
@@ -335,18 +333,6 @@ class DSMROptionFlowHandler(OptionsFlow):
|
||||
)
|
||||
|
||||
|
||||
def get_serial_by_id(dev_path: str) -> str:
|
||||
"""Return a /dev/serial/by-id match for given device if available."""
|
||||
by_id = "/dev/serial/by-id"
|
||||
if not os.path.isdir(by_id):
|
||||
return dev_path
|
||||
|
||||
for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()):
|
||||
if os.path.realpath(path) == dev_path:
|
||||
return path
|
||||
return dev_path
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"name": "DSMR Smart Meter",
|
||||
"codeowners": ["@Robbie1221"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["usb"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/dsmr",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
|
||||
34
homeassistant/components/duco/__init__.py
Normal file
34
homeassistant/components/duco/__init__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""The Duco integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from duco import DucoClient
|
||||
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
|
||||
from .const import PLATFORMS
|
||||
from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
|
||||
"""Set up Duco from a config entry."""
|
||||
client = DucoClient(
|
||||
session=async_get_clientsession(hass),
|
||||
host=entry.data[CONF_HOST],
|
||||
)
|
||||
|
||||
coordinator = DucoCoordinator(hass, entry, client)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
entry.runtime_data = coordinator
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: DucoConfigEntry) -> bool:
|
||||
"""Unload a Duco config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
74
homeassistant/components/duco/config_flow.py
Normal file
74
homeassistant/components/duco/config_flow.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Config flow for the Duco integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from duco import DucoClient
|
||||
from duco.exceptions import DucoConnectionError, DucoError
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.device_registry import format_mac
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STEP_USER_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_HOST): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class DucoConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Config flow for Duco."""
|
||||
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
box_name, mac = await self._validate_input(user_input[CONF_HOST])
|
||||
except DucoConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except DucoError:
|
||||
_LOGGER.exception("Unexpected error connecting to Duco box")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
await self.async_set_unique_id(format_mac(mac))
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
return self.async_create_entry(
|
||||
title=box_name,
|
||||
data={CONF_HOST: user_input[CONF_HOST]},
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def _validate_input(self, host: str) -> tuple[str, str]:
|
||||
"""Validate the user input by connecting to the Duco box.
|
||||
|
||||
Returns a tuple of (box_name, mac_address).
|
||||
"""
|
||||
client = DucoClient(
|
||||
session=async_get_clientsession(self.hass),
|
||||
host=host,
|
||||
)
|
||||
board_info = await client.async_get_board_info()
|
||||
lan_info = await client.async_get_lan_info()
|
||||
return board_info.box_name, lan_info.mac
|
||||
9
homeassistant/components/duco/const.py
Normal file
9
homeassistant/components/duco/const.py
Normal file
@@ -0,0 +1,9 @@
|
||||
"""Constants for the Duco integration."""
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
from homeassistant.const import Platform
|
||||
|
||||
DOMAIN = "duco"
|
||||
PLATFORMS = [Platform.FAN]
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
75
homeassistant/components/duco/coordinator.py
Normal file
75
homeassistant/components/duco/coordinator.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Data update coordinator for the Duco integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from duco import DucoClient
|
||||
from duco.exceptions import DucoConnectionError, DucoError
|
||||
from duco.models import BoardInfo, Node
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryError
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, SCAN_INTERVAL
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
type DucoConfigEntry = ConfigEntry[DucoCoordinator]
|
||||
type DucoData = dict[int, Node]
|
||||
|
||||
|
||||
class DucoCoordinator(DataUpdateCoordinator[DucoData]):
|
||||
"""Coordinator for the Duco integration."""
|
||||
|
||||
config_entry: DucoConfigEntry
|
||||
board_info: BoardInfo
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
config_entry: DucoConfigEntry,
|
||||
client: DucoClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinator."""
|
||||
super().__init__(
|
||||
hass,
|
||||
_LOGGER,
|
||||
config_entry=config_entry,
|
||||
name=DOMAIN,
|
||||
update_interval=SCAN_INTERVAL,
|
||||
)
|
||||
self.client = client
|
||||
|
||||
async def _async_setup(self) -> None:
|
||||
"""Fetch board info once during initial setup."""
|
||||
try:
|
||||
self.board_info = await self.client.async_get_board_info()
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except DucoError as err:
|
||||
raise ConfigEntryError(f"Duco API error: {err}") from err
|
||||
|
||||
async def _async_update_data(self) -> DucoData:
|
||||
"""Fetch node data from the Duco box."""
|
||||
try:
|
||||
nodes = await self.client.async_get_nodes()
|
||||
except DucoConnectionError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="cannot_connect",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
except DucoError as err:
|
||||
raise UpdateFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="api_error",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
return {node.node_id: node for node in nodes}
|
||||
52
homeassistant/components/duco/entity.py
Normal file
52
homeassistant/components/duco/entity.py
Normal file
@@ -0,0 +1,52 @@
|
||||
"""Base entity for the Duco integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from duco.models import Node
|
||||
|
||||
from homeassistant.const import ATTR_VIA_DEVICE
|
||||
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import CoordinatorEntity
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import DucoCoordinator
|
||||
|
||||
|
||||
class DucoEntity(CoordinatorEntity[DucoCoordinator]):
|
||||
"""Base class for Duco entities."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(self, coordinator: DucoCoordinator, node: Node) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(coordinator)
|
||||
self._node_id = node.node_id
|
||||
mac = coordinator.config_entry.unique_id
|
||||
assert mac is not None
|
||||
device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, f"{mac}_{node.node_id}")},
|
||||
manufacturer="Duco",
|
||||
model=coordinator.board_info.box_name
|
||||
if node.general.node_type == "BOX"
|
||||
else node.general.node_type,
|
||||
name=node.general.name or f"Node {node.node_id}",
|
||||
)
|
||||
device_info.update(
|
||||
{
|
||||
"connections": {(CONNECTION_NETWORK_MAC, mac)},
|
||||
"serial_number": coordinator.board_info.serial_board_box,
|
||||
}
|
||||
if node.general.node_type == "BOX"
|
||||
else {ATTR_VIA_DEVICE: (DOMAIN, f"{mac}_1")}
|
||||
)
|
||||
self._attr_device_info = device_info
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return True if entity is available."""
|
||||
return super().available and self._node_id in self.coordinator.data
|
||||
|
||||
@property
|
||||
def _node(self) -> Node:
|
||||
"""Return the current node data from the coordinator."""
|
||||
return self.coordinator.data[self._node_id]
|
||||
127
homeassistant/components/duco/fan.py
Normal file
127
homeassistant/components/duco/fan.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Fan platform for the Duco integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from duco.exceptions import DucoError
|
||||
from duco.models import Node, VentilationState
|
||||
|
||||
from homeassistant.components.fan import FanEntity, FanEntityFeature
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.util.percentage import percentage_to_ordered_list_item
|
||||
|
||||
from .const import DOMAIN
|
||||
from .coordinator import DucoConfigEntry, DucoCoordinator
|
||||
from .entity import DucoEntity
|
||||
|
||||
PARALLEL_UPDATES = 1
|
||||
|
||||
# Permanent speed states ordered low → high.
|
||||
ORDERED_NAMED_FAN_SPEEDS: list[VentilationState] = [
|
||||
VentilationState.CNT1,
|
||||
VentilationState.CNT2,
|
||||
VentilationState.CNT3,
|
||||
]
|
||||
|
||||
PRESET_AUTO = "auto"
|
||||
|
||||
# Upper-bound percentages for 3 speed levels: 33 / 66 / 100.
|
||||
# Using upper bounds guarantees that reading a percentage back and writing it
|
||||
# again always round-trips to the same Duco state.
|
||||
_SPEED_LEVEL_PERCENTAGES: list[int] = [
|
||||
(i + 1) * 100 // len(ORDERED_NAMED_FAN_SPEEDS)
|
||||
for i in range(len(ORDERED_NAMED_FAN_SPEEDS))
|
||||
]
|
||||
|
||||
# Maps every active Duco state (including timed MAN variants) to its
|
||||
# display percentage so externally-set timed modes show the correct level.
|
||||
_STATE_TO_PERCENTAGE: dict[VentilationState, int] = {
|
||||
VentilationState.CNT1: _SPEED_LEVEL_PERCENTAGES[0],
|
||||
VentilationState.MAN1: _SPEED_LEVEL_PERCENTAGES[0],
|
||||
VentilationState.MAN1x2: _SPEED_LEVEL_PERCENTAGES[0],
|
||||
VentilationState.MAN1x3: _SPEED_LEVEL_PERCENTAGES[0],
|
||||
VentilationState.CNT2: _SPEED_LEVEL_PERCENTAGES[1],
|
||||
VentilationState.MAN2: _SPEED_LEVEL_PERCENTAGES[1],
|
||||
VentilationState.MAN2x2: _SPEED_LEVEL_PERCENTAGES[1],
|
||||
VentilationState.MAN2x3: _SPEED_LEVEL_PERCENTAGES[1],
|
||||
VentilationState.CNT3: _SPEED_LEVEL_PERCENTAGES[2],
|
||||
VentilationState.MAN3: _SPEED_LEVEL_PERCENTAGES[2],
|
||||
VentilationState.MAN3x2: _SPEED_LEVEL_PERCENTAGES[2],
|
||||
VentilationState.MAN3x3: _SPEED_LEVEL_PERCENTAGES[2],
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: DucoConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Duco fan entities."""
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
async_add_entities(
|
||||
DucoVentilationFanEntity(coordinator, node)
|
||||
for node in coordinator.data.values()
|
||||
if node.general.node_type == "BOX"
|
||||
)
|
||||
|
||||
|
||||
class DucoVentilationFanEntity(DucoEntity, FanEntity):
|
||||
"""Fan entity for the ventilation control of a Duco node."""
|
||||
|
||||
_attr_translation_key = "ventilation"
|
||||
_attr_name = None
|
||||
_attr_supported_features = FanEntityFeature.SET_SPEED | FanEntityFeature.PRESET_MODE
|
||||
_attr_preset_modes = [PRESET_AUTO]
|
||||
_attr_speed_count = len(ORDERED_NAMED_FAN_SPEEDS)
|
||||
|
||||
def __init__(self, coordinator: DucoCoordinator, node: Node) -> None:
|
||||
"""Initialize the fan entity."""
|
||||
super().__init__(coordinator, node)
|
||||
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{node.node_id}"
|
||||
|
||||
@property
|
||||
def percentage(self) -> int | None:
|
||||
"""Return the current speed as a percentage, or None when in AUTO mode."""
|
||||
node = self._node
|
||||
if node.ventilation is None:
|
||||
return None
|
||||
return _STATE_TO_PERCENTAGE.get(node.ventilation.state)
|
||||
|
||||
@property
|
||||
def preset_mode(self) -> str | None:
|
||||
"""Return the current preset mode (auto when Duco controls, else None)."""
|
||||
node = self._node
|
||||
if node.ventilation is None:
|
||||
return None
|
||||
if node.ventilation.state not in _STATE_TO_PERCENTAGE:
|
||||
return PRESET_AUTO
|
||||
return None
|
||||
|
||||
async def async_set_preset_mode(self, preset_mode: str) -> None:
|
||||
"""Set preset mode: 'auto' hands control back to Duco."""
|
||||
self._valid_preset_mode_or_raise(preset_mode)
|
||||
await self._async_set_state(VentilationState.AUTO)
|
||||
|
||||
async def async_set_percentage(self, percentage: int) -> None:
|
||||
"""Set the fan speed as a percentage (maps to low/medium/high)."""
|
||||
if percentage == 0:
|
||||
await self._async_set_state(VentilationState.AUTO)
|
||||
return
|
||||
state = percentage_to_ordered_list_item(ORDERED_NAMED_FAN_SPEEDS, percentage)
|
||||
await self._async_set_state(state)
|
||||
|
||||
async def _async_set_state(self, state: VentilationState) -> None:
|
||||
"""Send the ventilation state to the device and refresh coordinator."""
|
||||
try:
|
||||
await self.coordinator.client.async_set_ventilation_state(
|
||||
self._node_id, state
|
||||
)
|
||||
except DucoError as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="failed_to_set_state",
|
||||
translation_placeholders={"error": repr(err)},
|
||||
) from err
|
||||
await self.coordinator.async_refresh()
|
||||
12
homeassistant/components/duco/manifest.json
Normal file
12
homeassistant/components/duco/manifest.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"domain": "duco",
|
||||
"name": "Duco",
|
||||
"codeowners": ["@ronaldvdmeer"],
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/duco",
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["duco"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["python-duco-client==0.2.0"]
|
||||
}
|
||||
89
homeassistant/components/duco/quality_scale.yaml
Normal file
89
homeassistant/components/duco/quality_scale.yaml
Normal file
@@ -0,0 +1,89 @@
|
||||
rules:
|
||||
# Bronze
|
||||
action-setup:
|
||||
status: exempt
|
||||
comment: Integration does not provide service actions.
|
||||
appropriate-polling: done
|
||||
brands: done
|
||||
common-modules: done
|
||||
config-flow-test-coverage: done
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: Integration does not provide service actions.
|
||||
docs-high-level-description: done
|
||||
docs-installation-instructions: done
|
||||
docs-removal-instructions: done
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: Integration uses a coordinator; entities do not subscribe to events directly.
|
||||
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:
|
||||
status: exempt
|
||||
comment: Integration does not provide an option flow.
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable:
|
||||
status: done
|
||||
comment: Handled by the DataUpdateCoordinator.
|
||||
parallel-updates: done
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: Integration uses a local API that requires no credentials.
|
||||
test-coverage: done
|
||||
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: todo
|
||||
discovery-update-info:
|
||||
status: todo
|
||||
comment: >-
|
||||
DHCP host updating to be implemented in a follow-up PR.
|
||||
The device hostname follows the pattern duco_<last 6 chars of MAC>
|
||||
(e.g. duco_061293), which can be used for DHCP hostname matching.
|
||||
discovery:
|
||||
status: todo
|
||||
comment: >-
|
||||
Device can be discovered via DHCP. The hostname follows the pattern
|
||||
duco_<last 6 chars of MAC> (e.g. duco_061293). To be implemented
|
||||
in a follow-up PR together with discovery-update-info.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices:
|
||||
status: todo
|
||||
comment: >-
|
||||
Users can pair new modules (CO2 sensors, humidity sensors, zone valves)
|
||||
to their Duco box. Dynamic device support to be added in a follow-up PR.
|
||||
entity-category: todo
|
||||
entity-device-class: todo
|
||||
entity-disabled-by-default: todo
|
||||
entity-translations: done
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
reconfiguration-flow: todo
|
||||
repair-issues: todo
|
||||
stale-devices:
|
||||
status: todo
|
||||
comment: >-
|
||||
To be implemented together with dynamic device support in a follow-up PR.
|
||||
|
||||
# Platinum
|
||||
async-dependency: done
|
||||
inject-websession: done
|
||||
strict-typing: todo
|
||||
45
homeassistant/components/duco/strings.json
Normal file
45
homeassistant/components/duco/strings.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
"data": {
|
||||
"host": "[%key:common::config_flow::data::host%]"
|
||||
},
|
||||
"data_description": {
|
||||
"host": "IP address or hostname of your Duco ventilation box."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"fan": {
|
||||
"ventilation": {
|
||||
"state_attributes": {
|
||||
"preset_mode": {
|
||||
"state": {
|
||||
"auto": "[%key:common::state::auto%]"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"api_error": {
|
||||
"message": "Unexpected error from the Duco API: {error}"
|
||||
},
|
||||
"cannot_connect": {
|
||||
"message": "An error occurred while trying to connect to the Duco instance: {error}"
|
||||
},
|
||||
"failed_to_set_state": {
|
||||
"message": "Failed to set ventilation state: {error}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@
|
||||
"mqtt": ["esphome/discover/#"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"aioesphomeapi==44.6.2",
|
||||
"aioesphomeapi==44.13.1",
|
||||
"esphome-dashboard-api==1.3.0",
|
||||
"bleak-esphome==3.7.1"
|
||||
],
|
||||
|
||||
@@ -20,7 +20,7 @@ from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN
|
||||
from .const import ATTR_EVENT_TYPE, ATTR_EVENT_TYPES, DOMAIN, DoorbellEventType
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
DATA_COMPONENT: HassKey[EntityComponent[EventEntity]] = HassKey(DOMAIN)
|
||||
@@ -44,6 +44,7 @@ __all__ = [
|
||||
"DOMAIN",
|
||||
"PLATFORM_SCHEMA",
|
||||
"PLATFORM_SCHEMA_BASE",
|
||||
"DoorbellEventType",
|
||||
"EventDeviceClass",
|
||||
"EventEntity",
|
||||
"EventEntityDescription",
|
||||
@@ -189,6 +190,21 @@ class EventEntity(RestoreEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_)
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Call when the event entity is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
|
||||
if (
|
||||
self.device_class == EventDeviceClass.DOORBELL
|
||||
and DoorbellEventType.RING not in self.event_types
|
||||
):
|
||||
report_issue = self._suggest_report_issue()
|
||||
_LOGGER.warning(
|
||||
"Entity %s is a doorbell event entity but does not support "
|
||||
"the '%s' event type. This will stop working in "
|
||||
"Home Assistant 2027.4, please %s",
|
||||
self.entity_id,
|
||||
DoorbellEventType.RING,
|
||||
report_issue,
|
||||
)
|
||||
|
||||
if (
|
||||
(state := await self.async_get_last_state())
|
||||
and state.state is not None
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
"""Provides the constants needed for the component."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
DOMAIN = "event"
|
||||
ATTR_EVENT_TYPE = "event_type"
|
||||
ATTR_EVENT_TYPES = "event_types"
|
||||
|
||||
|
||||
class DoorbellEventType(StrEnum):
|
||||
"""Standard event types for doorbell device class."""
|
||||
|
||||
RING = "ring"
|
||||
|
||||
@@ -15,7 +15,14 @@
|
||||
"name": "Button"
|
||||
},
|
||||
"doorbell": {
|
||||
"name": "Doorbell"
|
||||
"name": "Doorbell",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"ring": "Ring"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"motion": {
|
||||
"name": "Motion"
|
||||
|
||||
@@ -189,25 +189,25 @@ class EvoZone(EvoChild, EvoClimateEntity):
|
||||
)
|
||||
|
||||
async def async_clear_zone_override(self) -> None:
|
||||
"""Clear the zone's override, if any."""
|
||||
"""Clear the zone override (if any) and return to following its schedule."""
|
||||
await self.coordinator.call_client_api(self._evo_device.reset())
|
||||
|
||||
async def async_set_zone_override(
|
||||
self, setpoint: float, duration: timedelta | None = None
|
||||
) -> None:
|
||||
"""Set the zone's override (mode/setpoint)."""
|
||||
"""Override the zone's setpoint, either permanently or for a duration."""
|
||||
temperature = max(min(setpoint, self.max_temp), self.min_temp)
|
||||
|
||||
if duration is not None:
|
||||
if duration.total_seconds() == 0:
|
||||
await self._update_schedule()
|
||||
until = self.setpoints.get("next_sp_from")
|
||||
else:
|
||||
until = dt_util.now() + duration
|
||||
else:
|
||||
if duration is None:
|
||||
until = None # indefinitely
|
||||
elif duration.total_seconds() == 0:
|
||||
await self._update_schedule()
|
||||
until = self.setpoints.get("next_sp_from")
|
||||
else:
|
||||
until = dt_util.now() + duration
|
||||
|
||||
until = dt_util.as_utc(until) if until else None
|
||||
|
||||
await self.coordinator.call_client_api(
|
||||
self._evo_device.set_temperature(temperature, until=until)
|
||||
)
|
||||
|
||||
@@ -27,7 +27,7 @@ from .coordinator import EvoDataUpdateCoordinator
|
||||
# System service schemas (registered as domain services)
|
||||
SET_SYSTEM_MODE_SCHEMA: Final[dict[str | vol.Marker, Any]] = {
|
||||
# unsupported modes are rejected at runtime with ServiceValidationError
|
||||
vol.Required(ATTR_MODE): cv.string, # avoid vol.In(SystemMode)
|
||||
vol.Required(ATTR_MODE): cv.string, # ... so, don't use SystemMode enum here
|
||||
vol.Exclusive(ATTR_DURATION, "temporary"): vol.All(
|
||||
cv.time_period,
|
||||
vol.Range(min=timedelta(hours=0), max=timedelta(hours=24)),
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
set_system_mode:
|
||||
fields:
|
||||
mode:
|
||||
required: true
|
||||
default: Auto
|
||||
example: Away
|
||||
selector:
|
||||
select:
|
||||
@@ -19,9 +21,10 @@ set_system_mode:
|
||||
selector:
|
||||
object:
|
||||
duration:
|
||||
example: '{"hours": 18}'
|
||||
example: "18:00"
|
||||
selector:
|
||||
object:
|
||||
duration:
|
||||
enable_second: false
|
||||
|
||||
reset_system:
|
||||
|
||||
@@ -32,6 +35,8 @@ set_zone_override:
|
||||
entity:
|
||||
integration: evohome
|
||||
domain: climate
|
||||
supported_features:
|
||||
- climate.ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
fields:
|
||||
setpoint:
|
||||
required: true
|
||||
@@ -41,12 +46,15 @@ set_zone_override:
|
||||
max: 35.0
|
||||
step: 0.1
|
||||
duration:
|
||||
example: '{"minutes": 135}'
|
||||
example: "02:15"
|
||||
selector:
|
||||
object:
|
||||
duration:
|
||||
enable_second: false
|
||||
|
||||
clear_zone_override:
|
||||
target:
|
||||
entity:
|
||||
integration: evohome
|
||||
domain: climate
|
||||
supported_features:
|
||||
- climate.ClimateEntityFeature.TARGET_TEMPERATURE
|
||||
|
||||
@@ -4,13 +4,13 @@
|
||||
"message": "The requested system mode is not supported: {error}"
|
||||
},
|
||||
"mode_cant_be_temporary": {
|
||||
"message": "The mode `{mode}` does not support `duration` or `period`"
|
||||
"message": "The mode `{mode}` does not support 'Duration' or 'Period'"
|
||||
},
|
||||
"mode_cant_have_duration": {
|
||||
"message": "The mode `{mode}` does not support `duration`; use `period` instead"
|
||||
"message": "The mode `{mode}` does not support 'Duration'; use 'Period' instead"
|
||||
},
|
||||
"mode_cant_have_period": {
|
||||
"message": "The mode `{mode}` does not support `period`; use `duration` instead"
|
||||
"message": "The mode `{mode}` does not support 'Period'; use 'Duration' instead"
|
||||
},
|
||||
"mode_not_supported": {
|
||||
"message": "The mode `{mode}` is not supported by this controller"
|
||||
@@ -29,14 +29,14 @@
|
||||
"name": "Refresh system"
|
||||
},
|
||||
"reset_system": {
|
||||
"description": "Sets the system to Auto mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. AutoWithReset mode).",
|
||||
"description": "Sets the system to `Auto` mode and resets all the zones to follow their schedules. Not all Evohome systems support this feature (i.e. `AutoWithReset` mode).",
|
||||
"name": "Reset system"
|
||||
},
|
||||
"set_system_mode": {
|
||||
"description": "Sets the system mode, either indefinitely, or for a specified period of time, after which it will revert to Auto. Not all systems support all modes.",
|
||||
"description": "Sets the system mode, either indefinitely, or for a specified period of time, after which it will revert to `Auto`. Not all systems support all modes.",
|
||||
"fields": {
|
||||
"duration": {
|
||||
"description": "The duration in hours; used only with AutoWithEco mode (up to 24 hours).",
|
||||
"description": "The duration in hours; used only with `AutoWithEco` mode (up to 24 hours).",
|
||||
"name": "Duration"
|
||||
},
|
||||
"mode": {
|
||||
@@ -44,14 +44,14 @@
|
||||
"name": "[%key:common::config_flow::data::mode%]"
|
||||
},
|
||||
"period": {
|
||||
"description": "A period of time in days; used only with Away, DayOff, or Custom mode. The system will revert to Auto mode at midnight (up to 99 days, today is day 1).",
|
||||
"description": "A period of time in days; used only with `Away`, `DayOff`, or `Custom` mode. The system will revert to `Auto` mode at midnight (up to 99 days, today is day 1).",
|
||||
"name": "Period"
|
||||
}
|
||||
},
|
||||
"name": "Set system mode"
|
||||
},
|
||||
"set_zone_override": {
|
||||
"description": "Overrides a zone's setpoint, either indefinitely, or for a specified period of time, after which it will revert to following its schedule.",
|
||||
"description": "Overrides the zone's setpoint, either indefinitely, or for a specified period of time, after which it will revert to following its schedule.",
|
||||
"fields": {
|
||||
"duration": {
|
||||
"description": "The zone will revert to its schedule after this time. If 0 the change is until the next scheduled setpoint.",
|
||||
|
||||
@@ -69,7 +69,6 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
|
||||
"""Base for any evohome-compatible DHW controller."""
|
||||
|
||||
_attr_name = "DHW controller"
|
||||
_attr_icon = "mdi:thermometer-lines"
|
||||
_attr_operation_list = list(HA_STATE_TO_EVO)
|
||||
_attr_supported_features = (
|
||||
WaterHeaterEntityFeature.AWAY_MODE
|
||||
|
||||
@@ -453,10 +453,13 @@ class FritzBoxTools(DataUpdateCoordinator[UpdateCoordinatorDataType]):
|
||||
if not attributes.get("MACAddress"):
|
||||
continue
|
||||
|
||||
wan_access_result = None
|
||||
if (wan_access := attributes.get("X_AVM-DE_WANAccess")) is not None:
|
||||
wan_access_result = "granted" in wan_access
|
||||
else:
|
||||
wan_access_result = None
|
||||
# wan_access can be "granted", "denied", "unknown" or "error"
|
||||
if "granted" in wan_access:
|
||||
wan_access_result = True
|
||||
elif "denied" in wan_access:
|
||||
wan_access_result = False
|
||||
|
||||
hosts[attributes["MACAddress"]] = Device(
|
||||
name=attributes["HostName"],
|
||||
|
||||
@@ -34,15 +34,23 @@ rules:
|
||||
# Gold
|
||||
devices: done
|
||||
diagnostics: done
|
||||
discovery-update-info: todo
|
||||
discovery: todo
|
||||
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
|
||||
discovery-update-info:
|
||||
status: exempt
|
||||
comment: >-
|
||||
Growatt data loggers use a generic OUI and serial-number DHCP hostname,
|
||||
making reliable local discovery not implementable.
|
||||
discovery:
|
||||
status: exempt
|
||||
comment: >-
|
||||
Growatt data loggers use a generic OUI and serial-number DHCP hostname,
|
||||
making reliable local discovery not implementable.
|
||||
docs-data-update: done
|
||||
docs-examples: done
|
||||
docs-known-limitations: done
|
||||
docs-supported-devices: done
|
||||
docs-supported-functions: done
|
||||
docs-troubleshooting: done
|
||||
docs-use-cases: done
|
||||
dynamic-devices: todo
|
||||
entity-category: done
|
||||
entity-device-class: done
|
||||
|
||||
@@ -98,7 +98,9 @@ class HabiticaCalendarEntity(HabiticaBase, CalendarEntity):
|
||||
start_date, end_date - timedelta(days=1), inc=True
|
||||
)
|
||||
# if no end_date is given, return only the next recurrence
|
||||
return [recurrences.after(start_date, inc=True)]
|
||||
if (next_date := recurrences.after(start_date, inc=True)) is None:
|
||||
return []
|
||||
return [next_date]
|
||||
|
||||
|
||||
class HabiticaTodosCalendarEntity(HabiticaCalendarEntity):
|
||||
|
||||
@@ -42,6 +42,7 @@ BSH_EVENT_PRESENT_STATE_CONFIRMED = "BSH.Common.EnumType.EventPresentState.Confi
|
||||
BSH_EVENT_PRESENT_STATE_OFF = "BSH.Common.EnumType.EventPresentState.Off"
|
||||
|
||||
|
||||
BSH_OPERATION_STATE_DELAYED_START = "BSH.Common.EnumType.OperationState.DelayedStart"
|
||||
BSH_OPERATION_STATE_RUN = "BSH.Common.EnumType.OperationState.Run"
|
||||
BSH_OPERATION_STATE_PAUSE = "BSH.Common.EnumType.OperationState.Pause"
|
||||
BSH_OPERATION_STATE_FINISHED = "BSH.Common.EnumType.OperationState.Finished"
|
||||
|
||||
@@ -21,6 +21,7 @@ from homeassistant.util import dt as dt_util, slugify
|
||||
from .common import setup_home_connect_entry
|
||||
from .const import (
|
||||
APPLIANCES_WITH_PROGRAMS,
|
||||
BSH_OPERATION_STATE_DELAYED_START,
|
||||
BSH_OPERATION_STATE_FINISHED,
|
||||
BSH_OPERATION_STATE_PAUSE,
|
||||
BSH_OPERATION_STATE_RUN,
|
||||
@@ -624,6 +625,7 @@ class HomeConnectProgramSensor(HomeConnectSensor):
|
||||
"""Return whether a program is running, paused or finished."""
|
||||
status = self.appliance.status.get(StatusKey.BSH_COMMON_OPERATION_STATE)
|
||||
return status is not None and status.value in [
|
||||
BSH_OPERATION_STATE_DELAYED_START,
|
||||
BSH_OPERATION_STATE_RUN,
|
||||
BSH_OPERATION_STATE_PAUSE,
|
||||
BSH_OPERATION_STATE_FINISHED,
|
||||
|
||||
@@ -68,8 +68,16 @@ PROGRAM_OPTIONS = {
|
||||
),
|
||||
OptionKey.COOKING_OVEN_SETPOINT_TEMPERATURE: vol.All(int, vol.Range(min=0)),
|
||||
OptionKey.COOKING_OVEN_FAST_PRE_HEAT: bool,
|
||||
OptionKey.LAUNDRY_CARE_COMMON_SILENT_MODE: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_1_ACTIVE: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_I_DOS_2_ACTIVE: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_INTENSIVE_PLUS: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_LESS_IRONING: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_MINI_LOAD: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_PREWASH: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_RINSE_HOLD: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_SOAK: bool,
|
||||
OptionKey.LAUNDRY_CARE_WASHER_WATER_PLUS: bool,
|
||||
}.items()
|
||||
}
|
||||
|
||||
|
||||
@@ -119,7 +119,7 @@ set_program_and_options:
|
||||
- cooking_common_program_hood_automatic
|
||||
- cooking_common_program_hood_venting
|
||||
- cooking_common_program_hood_delayed_shut_off
|
||||
- cooking_oven_program_heating_mode_3_d_heating
|
||||
- cooking_oven_program_heating_mode_3_d_hot_air
|
||||
- cooking_oven_program_heating_mode_air_fry
|
||||
- cooking_oven_program_heating_mode_grill_large_area
|
||||
- cooking_oven_program_heating_mode_grill_small_area
|
||||
@@ -210,6 +210,7 @@ set_program_and_options:
|
||||
mode: box
|
||||
unit_of_measurement: "%"
|
||||
heating_ventilation_air_conditioning_air_conditioner_option_fan_speed_mode:
|
||||
example: heating_ventilation_air_conditioning_air_conditioner_enum_type_fan_speed_mode_automatic
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
@@ -222,7 +223,7 @@ set_program_and_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
consumer_products_cleaning_robot_option_reference_map_id:
|
||||
example: consumer_products_cleaning_robot_enum_type_available_maps_map1
|
||||
example: consumer_products_cleaning_robot_enum_type_available_maps_map_1
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
@@ -230,9 +231,9 @@ set_program_and_options:
|
||||
translation_key: available_maps
|
||||
options:
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_temp_map
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_map1
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_map2
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_map3
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_map_1
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_map_2
|
||||
- consumer_products_cleaning_robot_enum_type_available_maps_map_3
|
||||
consumer_products_cleaning_robot_option_cleaning_mode:
|
||||
example: consumer_products_cleaning_robot_enum_type_cleaning_modes_standard
|
||||
required: false
|
||||
@@ -310,7 +311,7 @@ set_program_and_options:
|
||||
- consumer_products_coffee_maker_enum_type_coffee_temperature_94_c
|
||||
- consumer_products_coffee_maker_enum_type_coffee_temperature_95_c
|
||||
- consumer_products_coffee_maker_enum_type_coffee_temperature_96_c
|
||||
consumer_products_coffee_maker_option_bean_container:
|
||||
consumer_products_coffee_maker_option_bean_container_selection:
|
||||
example: consumer_products_coffee_maker_enum_type_bean_container_selection_right
|
||||
required: false
|
||||
selector:
|
||||
@@ -468,8 +469,8 @@ set_program_and_options:
|
||||
hood_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
cooking_hood_option_venting_level:
|
||||
example: cooking_hood_enum_type_stage_fan_stage01
|
||||
cooking_common_option_hood_venting_level:
|
||||
example: cooking_hood_enum_type_stage_fan_stage_01
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
@@ -482,8 +483,8 @@ set_program_and_options:
|
||||
- cooking_hood_enum_type_stage_fan_stage_03
|
||||
- cooking_hood_enum_type_stage_fan_stage_04
|
||||
- cooking_hood_enum_type_stage_fan_stage_05
|
||||
cooking_hood_option_intensive_level:
|
||||
example: cooking_hood_enum_type_intensive_stage_intensive_stage1
|
||||
cooking_common_option_hood_intensive_level:
|
||||
example: cooking_hood_enum_type_intensive_stage_intensive_stage_1
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
@@ -491,8 +492,8 @@ set_program_and_options:
|
||||
translation_key: intensive_level
|
||||
options:
|
||||
- cooking_hood_enum_type_intensive_stage_intensive_stage_off
|
||||
- cooking_hood_enum_type_intensive_stage_intensive_stage1
|
||||
- cooking_hood_enum_type_intensive_stage_intensive_stage2
|
||||
- cooking_hood_enum_type_intensive_stage_intensive_stage_1
|
||||
- cooking_hood_enum_type_intensive_stage_intensive_stage_2
|
||||
oven_options:
|
||||
collapsed: true
|
||||
fields:
|
||||
@@ -567,7 +568,7 @@ set_program_and_options:
|
||||
- laundry_care_washer_enum_type_temperature_ul_hot
|
||||
- laundry_care_washer_enum_type_temperature_ul_extra_hot
|
||||
laundry_care_washer_option_spin_speed:
|
||||
example: laundry_care_washer_enum_type_spin_speed_r_p_m800
|
||||
example: laundry_care_washer_enum_type_spin_speed_r_p_m_800
|
||||
required: false
|
||||
selector:
|
||||
select:
|
||||
@@ -611,12 +612,12 @@ set_program_and_options:
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_i_dos1_active:
|
||||
laundry_care_washer_option_i_dos_1_active:
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_i_dos2_active:
|
||||
laundry_care_washer_option_i_dos_2_active:
|
||||
example: false
|
||||
required: false
|
||||
selector:
|
||||
@@ -656,7 +657,7 @@ set_program_and_options:
|
||||
required: false
|
||||
selector:
|
||||
boolean:
|
||||
laundry_care_washer_option_vario_perfect:
|
||||
laundry_care_common_option_vario_perfect:
|
||||
example: laundry_care_common_enum_type_vario_perfect_eco_perfect
|
||||
required: false
|
||||
selector:
|
||||
|
||||
@@ -260,7 +260,7 @@
|
||||
"cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
|
||||
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
|
||||
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
|
||||
"cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]",
|
||||
"cooking_oven_program_heating_mode_3_d_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_hot_air%]",
|
||||
"cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
|
||||
@@ -431,7 +431,7 @@
|
||||
}
|
||||
},
|
||||
"bean_container": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container::name%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_coffee_maker_option_bean_container_selection::name%]",
|
||||
"state": {
|
||||
"consumer_products_coffee_maker_enum_type_bean_container_selection_left": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_left%]",
|
||||
"consumer_products_coffee_maker_enum_type_bean_container_selection_right": "[%key:component::home_connect::selector::bean_container::options::consumer_products_coffee_maker_enum_type_bean_container_selection_right%]"
|
||||
@@ -484,9 +484,9 @@
|
||||
"current_map": {
|
||||
"name": "Current map",
|
||||
"state": {
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_1%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_2%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_3%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]"
|
||||
}
|
||||
},
|
||||
@@ -557,19 +557,19 @@
|
||||
}
|
||||
},
|
||||
"intensive_level": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_intensive_level::name%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_common_option_hood_intensive_level::name%]",
|
||||
"state": {
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage1%]",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage2%]",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_1": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_1%]",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_2": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_2%]",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_off": "[%key:component::home_connect::selector::intensive_level::options::cooking_hood_enum_type_intensive_stage_intensive_stage_off%]"
|
||||
}
|
||||
},
|
||||
"reference_map_id": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::consumer_products_cleaning_robot_option_reference_map_id::name%]",
|
||||
"state": {
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map1%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map2%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map3%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_1": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_1%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_2": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_2%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_3": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_map_3%]",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "[%key:component::home_connect::selector::available_maps::options::consumer_products_cleaning_robot_enum_type_available_maps_temp_map%]"
|
||||
}
|
||||
},
|
||||
@@ -620,7 +620,7 @@
|
||||
"cooking_common_program_hood_automatic": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_automatic%]",
|
||||
"cooking_common_program_hood_delayed_shut_off": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_delayed_shut_off%]",
|
||||
"cooking_common_program_hood_venting": "[%key:component::home_connect::selector::programs::options::cooking_common_program_hood_venting%]",
|
||||
"cooking_oven_program_heating_mode_3_d_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_heating%]",
|
||||
"cooking_oven_program_heating_mode_3_d_hot_air": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_3_d_hot_air%]",
|
||||
"cooking_oven_program_heating_mode_air_fry": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_air_fry%]",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bottom_heating%]",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "[%key:component::home_connect::selector::programs::options::cooking_oven_program_heating_mode_bread_baking%]",
|
||||
@@ -786,7 +786,7 @@
|
||||
}
|
||||
},
|
||||
"vario_perfect": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_vario_perfect::name%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_common_option_vario_perfect::name%]",
|
||||
"state": {
|
||||
"laundry_care_common_enum_type_vario_perfect_eco_perfect": "[%key:component::home_connect::selector::vario_perfect::options::laundry_care_common_enum_type_vario_perfect_eco_perfect%]",
|
||||
"laundry_care_common_enum_type_vario_perfect_off": "[%key:common::state::off%]",
|
||||
@@ -794,7 +794,7 @@
|
||||
}
|
||||
},
|
||||
"venting_level": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_hood_option_venting_level::name%]",
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::cooking_common_option_hood_venting_level::name%]",
|
||||
"state": {
|
||||
"cooking_hood_enum_type_stage_fan_off": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_off%]",
|
||||
"cooking_hood_enum_type_stage_fan_stage_01": "[%key:component::home_connect::selector::venting_level::options::cooking_hood_enum_type_stage_fan_stage_01%]",
|
||||
@@ -1272,10 +1272,10 @@
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_hygiene_plus::name%]"
|
||||
},
|
||||
"i_dos1_active": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos1_active::name%]"
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos_1_active::name%]"
|
||||
},
|
||||
"i_dos2_active": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos2_active::name%]"
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::laundry_care_washer_option_i_dos_2_active::name%]"
|
||||
},
|
||||
"intensiv_zone": {
|
||||
"name": "[%key:component::home_connect::services::set_program_and_options::fields::dishcare_dishwasher_option_intensiv_zone::name%]"
|
||||
@@ -1458,9 +1458,9 @@
|
||||
},
|
||||
"available_maps": {
|
||||
"options": {
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map1": "Map 1",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map2": "Map 2",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map3": "Map 3",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_1": "Map 1",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_2": "Map 2",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_map_3": "Map 3",
|
||||
"consumer_products_cleaning_robot_enum_type_available_maps_temp_map": "Temporary map"
|
||||
}
|
||||
},
|
||||
@@ -1584,8 +1584,8 @@
|
||||
},
|
||||
"intensive_level": {
|
||||
"options": {
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage1": "Intensive stage 1",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage2": "Intensive stage 2",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_1": "Intensive stage 1",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_2": "Intensive stage 2",
|
||||
"cooking_hood_enum_type_intensive_stage_intensive_stage_off": "Intensive stage off"
|
||||
}
|
||||
},
|
||||
@@ -1629,7 +1629,7 @@
|
||||
"cooking_common_program_hood_automatic": "Automatic",
|
||||
"cooking_common_program_hood_delayed_shut_off": "Delayed shut off",
|
||||
"cooking_common_program_hood_venting": "Venting",
|
||||
"cooking_oven_program_heating_mode_3_d_heating": "3D heating",
|
||||
"cooking_oven_program_heating_mode_3_d_hot_air": "3D hot air",
|
||||
"cooking_oven_program_heating_mode_air_fry": "Air fry",
|
||||
"cooking_oven_program_heating_mode_bottom_heating": "Bottom heating",
|
||||
"cooking_oven_program_heating_mode_bread_baking": "Bread baking",
|
||||
@@ -1892,7 +1892,7 @@
|
||||
"description": "Describes the amount of coffee beans used in a coffee machine program.",
|
||||
"name": "Bean amount"
|
||||
},
|
||||
"consumer_products_coffee_maker_option_bean_container": {
|
||||
"consumer_products_coffee_maker_option_bean_container_selection": {
|
||||
"description": "Defines the preferred bean container.",
|
||||
"name": "Bean container"
|
||||
},
|
||||
@@ -1920,11 +1920,11 @@
|
||||
"description": "Defines if double dispensing is enabled.",
|
||||
"name": "Multiple beverages"
|
||||
},
|
||||
"cooking_hood_option_intensive_level": {
|
||||
"cooking_common_option_hood_intensive_level": {
|
||||
"description": "Defines the intensive setting.",
|
||||
"name": "Intensive level"
|
||||
},
|
||||
"cooking_hood_option_venting_level": {
|
||||
"cooking_common_option_hood_venting_level": {
|
||||
"description": "Defines the required fan setting.",
|
||||
"name": "Venting level"
|
||||
},
|
||||
@@ -1992,15 +1992,19 @@
|
||||
"description": "Defines if the silent mode is activated.",
|
||||
"name": "Silent mode"
|
||||
},
|
||||
"laundry_care_common_option_vario_perfect": {
|
||||
"description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).",
|
||||
"name": "Vario perfect"
|
||||
},
|
||||
"laundry_care_dryer_option_drying_target": {
|
||||
"description": "Describes the drying target for a dryer program.",
|
||||
"name": "Drying target"
|
||||
},
|
||||
"laundry_care_washer_option_i_dos1_active": {
|
||||
"laundry_care_washer_option_i_dos_1_active": {
|
||||
"description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 1)",
|
||||
"name": "i-Dos 1 Active"
|
||||
},
|
||||
"laundry_care_washer_option_i_dos2_active": {
|
||||
"laundry_care_washer_option_i_dos_2_active": {
|
||||
"description": "Defines if the detergent feed is activated / deactivated. (i-Dos content 2)",
|
||||
"name": "i-Dos 2 Active"
|
||||
},
|
||||
@@ -2044,10 +2048,6 @@
|
||||
"description": "Defines the temperature of the washing program.",
|
||||
"name": "Temperature"
|
||||
},
|
||||
"laundry_care_washer_option_vario_perfect": {
|
||||
"description": "Defines if a cycle saves energy (Eco Perfect) or time (Speed Perfect).",
|
||||
"name": "Vario perfect"
|
||||
},
|
||||
"laundry_care_washer_option_water_plus": {
|
||||
"description": "Defines if the water plus option is activated.",
|
||||
"name": "Water +"
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.components.homeassistant_hardware.util import guess_firmware_
|
||||
from homeassistant.components.usb import (
|
||||
USBDevice,
|
||||
async_register_port_event_callback,
|
||||
scan_serial_ports,
|
||||
async_scan_serial_ports,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -163,7 +163,7 @@ async def async_migrate_entry(
|
||||
key not in config_entry.data
|
||||
for key in (VID, PID, MANUFACTURER, PRODUCT, SERIAL_NUMBER)
|
||||
):
|
||||
serial_ports = await hass.async_add_executor_job(scan_serial_ports)
|
||||
serial_ports = await async_scan_serial_ports(hass)
|
||||
serial_ports_info = {port.device: port for port in serial_ports}
|
||||
device = config_entry.data[DEVICE]
|
||||
|
||||
|
||||
@@ -103,6 +103,7 @@ CHARACTERISTIC_PLATFORMS = {
|
||||
CharacteristicsTypes.THREAD_NODE_CAPABILITIES: "sensor",
|
||||
CharacteristicsTypes.THREAD_CONTROL_POINT: "button",
|
||||
CharacteristicsTypes.MUTE: "switch",
|
||||
CharacteristicsTypes.AIRPLAY_ENABLE: "switch",
|
||||
CharacteristicsTypes.FILTER_LIFE_LEVEL: "sensor",
|
||||
CharacteristicsTypes.VENDOR_AIRVERSA_SLEEP_MODE: "switch",
|
||||
CharacteristicsTypes.TEMPERATURE_UNITS: "select",
|
||||
|
||||
@@ -36,6 +36,9 @@
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"airplay_enable": {
|
||||
"default": "mdi:cast-variant"
|
||||
},
|
||||
"lock_physical_controls": {
|
||||
"default": "mdi:lock-open"
|
||||
},
|
||||
|
||||
@@ -70,6 +70,12 @@ SWITCH_ENTITIES: dict[str, DeclarativeSwitchEntityDescription] = {
|
||||
translation_key="sleep_mode",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
CharacteristicsTypes.AIRPLAY_ENABLE: DeclarativeSwitchEntityDescription(
|
||||
key=CharacteristicsTypes.AIRPLAY_ENABLE,
|
||||
name="AirPlay Enable",
|
||||
translation_key="airplay_enable",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -59,6 +59,43 @@ class HuumConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle reconfiguration of the integration."""
|
||||
errors: dict[str, str] = {}
|
||||
reconfigure_entry = self._get_reconfigure_entry()
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match({CONF_USERNAME: user_input[CONF_USERNAME]})
|
||||
try:
|
||||
huum = Huum(
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
session=async_get_clientsession(self.hass),
|
||||
)
|
||||
await huum.status()
|
||||
except Forbidden, NotAuthenticated:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unknown error")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_update_reload_and_abort(
|
||||
reconfigure_entry,
|
||||
title=user_input[CONF_USERNAME],
|
||||
data_updates=user_input,
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA,
|
||||
{CONF_USERNAME: reconfigure_entry.data[CONF_USERNAME]},
|
||||
),
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
|
||||
@@ -64,7 +64,7 @@ rules:
|
||||
entity-translations: done
|
||||
exception-translations: done
|
||||
icon-translations: done
|
||||
reconfiguration-flow: todo
|
||||
reconfiguration-flow: done
|
||||
repair-issues:
|
||||
status: exempt
|
||||
comment: Integration has no repair scenarios.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
@@ -20,6 +21,16 @@
|
||||
"description": "The authentication for {username} is no longer valid. Please enter the current password.",
|
||||
"title": "[%key:common::config_flow::title::reauth%]"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
"username": "[%key:common::config_flow::data::username%]"
|
||||
},
|
||||
"data_description": {
|
||||
"password": "[%key:component::huum::config::step::user::data_description::password%]",
|
||||
"username": "[%key:component::huum::config::step::user::data_description::username%]"
|
||||
}
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"password": "[%key:common::config_flow::data::password%]",
|
||||
|
||||
@@ -18,12 +18,19 @@ from iaqualink.device import (
|
||||
AqualinkSwitch,
|
||||
AqualinkThermostat,
|
||||
)
|
||||
from iaqualink.exception import AqualinkServiceException
|
||||
from iaqualink.exception import (
|
||||
AqualinkServiceException,
|
||||
AqualinkServiceUnauthorizedException,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import (
|
||||
ConfigEntryAuthFailed,
|
||||
ConfigEntryError,
|
||||
ConfigEntryNotReady,
|
||||
)
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
@@ -74,11 +81,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
|
||||
)
|
||||
try:
|
||||
await aqualink.login()
|
||||
except AqualinkServiceException as login_exception:
|
||||
_LOGGER.error("Failed to login: %s", login_exception)
|
||||
except AqualinkServiceUnauthorizedException as auth_exception:
|
||||
await aqualink.close()
|
||||
return False
|
||||
except (TimeoutError, httpx.HTTPError) as aio_exception:
|
||||
raise ConfigEntryAuthFailed(
|
||||
"Invalid credentials for iAqualink"
|
||||
) from auth_exception
|
||||
except (AqualinkServiceException, TimeoutError, httpx.HTTPError) as aio_exception:
|
||||
await aqualink.close()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Error while attempting login: {aio_exception}"
|
||||
@@ -94,9 +102,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: AqualinkConfigEntry) ->
|
||||
|
||||
systems = list(systems.values())
|
||||
if not systems:
|
||||
_LOGGER.error("No systems detected or supported")
|
||||
await aqualink.close()
|
||||
return False
|
||||
raise ConfigEntryError("No systems detected or supported")
|
||||
|
||||
runtime_data = AqualinkRuntimeData(
|
||||
aqualink, binary_sensors=[], lights=[], sensors=[], switches=[], thermostats=[]
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioimmich"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aioimmich==0.12.1"]
|
||||
"requirements": ["aioimmich==0.14.0"]
|
||||
}
|
||||
|
||||
@@ -124,11 +124,11 @@ class ImmichMediaSource(MediaSource):
|
||||
identifier=f"{identifier.unique_id}|{collection}",
|
||||
media_class=MediaClass.DIRECTORY,
|
||||
media_content_type=MediaClass.IMAGE,
|
||||
title=collection,
|
||||
title=collection.split("|", maxsplit=1)[0],
|
||||
can_play=False,
|
||||
can_expand=True,
|
||||
)
|
||||
for collection in ("albums", "people", "tags")
|
||||
for collection in ("albums", "favorites|favorites", "people", "tags")
|
||||
]
|
||||
|
||||
# --------------------------------------------------------
|
||||
@@ -239,6 +239,12 @@ class ImmichMediaSource(MediaSource):
|
||||
)
|
||||
except ImmichError:
|
||||
return []
|
||||
elif identifier.collection == "favorites":
|
||||
LOGGER.debug("Render all assets for favorites collection")
|
||||
try:
|
||||
assets = await immich_api.search.async_get_all_favorites()
|
||||
except ImmichError:
|
||||
return []
|
||||
|
||||
ret: list[BrowseMediaSource] = []
|
||||
for asset in assets:
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
{
|
||||
"domain": "insteon",
|
||||
"name": "Insteon",
|
||||
"after_dependencies": ["panel_custom", "usb"],
|
||||
"after_dependencies": ["panel_custom"],
|
||||
"codeowners": ["@teharris1"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["http", "websocket_api"],
|
||||
"dependencies": ["http", "usb", "websocket_api"],
|
||||
"dhcp": [
|
||||
{
|
||||
"macaddress": "000EF3*"
|
||||
|
||||
@@ -11,7 +11,6 @@ from pyinsteon.address import Address
|
||||
from pyinsteon.constants import ALDBStatus, DeviceAction
|
||||
from pyinsteon.device_types.device_base import Device
|
||||
from pyinsteon.events import OFF_EVENT, OFF_FAST_EVENT, ON_EVENT, ON_FAST_EVENT, Event
|
||||
from serial.tools import list_ports
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.const import CONF_ADDRESS, Platform
|
||||
@@ -172,35 +171,22 @@ def async_add_insteon_devices(
|
||||
)
|
||||
|
||||
|
||||
def get_usb_ports() -> dict[str, str]:
|
||||
async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]:
|
||||
"""Return a dict of USB ports and their friendly names."""
|
||||
ports = list_ports.comports()
|
||||
port_descriptions = {}
|
||||
for port in ports:
|
||||
vid: str | None = None
|
||||
pid: str | None = None
|
||||
if port.vid is not None and port.pid is not None:
|
||||
usb_device = usb.usb_device_from_port(port)
|
||||
vid = usb_device.vid
|
||||
pid = usb_device.pid
|
||||
dev_path = usb.get_serial_by_id(port.device)
|
||||
for port in await usb.async_scan_serial_ports(hass):
|
||||
human_name = usb.human_readable_device_name(
|
||||
dev_path,
|
||||
port.device,
|
||||
port.serial_number,
|
||||
port.manufacturer,
|
||||
port.description,
|
||||
vid,
|
||||
pid,
|
||||
port.vid if isinstance(port, usb.USBDevice) else None,
|
||||
port.pid if isinstance(port, usb.USBDevice) else None,
|
||||
)
|
||||
port_descriptions[dev_path] = human_name
|
||||
port_descriptions[port.device] = human_name
|
||||
return port_descriptions
|
||||
|
||||
|
||||
async def async_get_usb_ports(hass: HomeAssistant) -> dict[str, str]:
|
||||
"""Return a dict of USB ports and their friendly names."""
|
||||
return await hass.async_add_executor_job(get_usb_ports)
|
||||
|
||||
|
||||
def compute_device_name(ha_device) -> str:
|
||||
"""Return the HA device name."""
|
||||
return ha_device.name_by_user or ha_device.name
|
||||
|
||||
@@ -143,7 +143,8 @@ async def async_setup_entry(hass: HomeAssistant, entry: IntellifireConfigEntry)
|
||||
try:
|
||||
fireplace: UnifiedFireplace = (
|
||||
await UnifiedFireplace.build_fireplace_from_common(
|
||||
_construct_common_data(entry)
|
||||
_construct_common_data(entry),
|
||||
polling_enabled=False,
|
||||
)
|
||||
)
|
||||
LOGGER.debug("Waiting for Fireplace to Initialize")
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
|
||||
import aiohttp
|
||||
from intellifire4py import UnifiedFireplace
|
||||
from intellifire4py.control import IntelliFireController
|
||||
from intellifire4py.model import IntelliFirePollData
|
||||
@@ -11,8 +12,9 @@ from intellifire4py.read import IntelliFireDataProvider
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN, LOGGER
|
||||
|
||||
@@ -52,6 +54,14 @@ class IntellifireDataUpdateCoordinator(DataUpdateCoordinator[IntelliFirePollData
|
||||
return self.fireplace.control_api
|
||||
|
||||
async def _async_update_data(self) -> IntelliFirePollData:
|
||||
try:
|
||||
await self.fireplace.perform_poll()
|
||||
except aiohttp.ClientResponseError as err:
|
||||
if err.status == 403:
|
||||
raise ConfigEntryAuthFailed("Authentication failed") from err
|
||||
raise UpdateFailed(f"Error communicating with fireplace: {err}") from err
|
||||
except (aiohttp.ClientError, TimeoutError) as err:
|
||||
raise UpdateFailed(f"Error communicating with fireplace: {err}") from err
|
||||
return self.fireplace.data
|
||||
|
||||
@property
|
||||
|
||||
@@ -123,6 +123,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
knx_module.ui_time_server_controller.start(
|
||||
knx_module.xknx, knx_module.config_store.get_time_server_config()
|
||||
)
|
||||
knx_module.ui_expose_controller.start(
|
||||
hass, knx_module.xknx, knx_module.config_store.get_exposes()
|
||||
)
|
||||
if CONF_KNX_EXPOSE in config:
|
||||
knx_module.yaml_exposures.extend(
|
||||
create_combined_knx_exposure(hass, knx_module.xknx, config[CONF_KNX_EXPOSE])
|
||||
@@ -157,6 +160,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
for exposure in knx_module.service_exposures.values():
|
||||
exposure.async_remove()
|
||||
knx_module.ui_time_server_controller.stop()
|
||||
knx_module.ui_expose_controller.stop()
|
||||
|
||||
configured_platforms_yaml = {
|
||||
platform
|
||||
|
||||
@@ -58,6 +58,7 @@ from .expose import KnxExposeEntity, KnxExposeTime
|
||||
from .project import KNXProject
|
||||
from .repairs import data_secure_group_key_issue_dispatcher
|
||||
from .storage.config_store import KNXConfigStore
|
||||
from .storage.expose_controller import ExposeController
|
||||
from .storage.time_server import TimeServerController
|
||||
from .telegrams import Telegrams
|
||||
|
||||
@@ -76,6 +77,7 @@ class KNXModule:
|
||||
self.connected = False
|
||||
self.yaml_exposures: list[KnxExposeEntity | KnxExposeTime] = []
|
||||
self.service_exposures: dict[str, KnxExposeEntity | KnxExposeTime] = {}
|
||||
self.ui_expose_controller = ExposeController()
|
||||
self.ui_time_server_controller = TimeServerController()
|
||||
self.entry = entry
|
||||
|
||||
|
||||
@@ -11,15 +11,16 @@ from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.storage import Store
|
||||
from homeassistant.util.ulid import ulid_now
|
||||
|
||||
from ..const import DOMAIN
|
||||
from ..const import DOMAIN, KNX_MODULE_KEY
|
||||
from . import migration
|
||||
from .const import CONF_DATA
|
||||
from .expose_controller import KNXExposeStoreModel, KNXExposeStoreOptionModel
|
||||
from .time_server import KNXTimeServerStoreModel
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
STORAGE_VERSION: Final = 2
|
||||
STORAGE_VERSION_MINOR: Final = 3
|
||||
STORAGE_VERSION_MINOR: Final = 4
|
||||
STORAGE_KEY: Final = f"{DOMAIN}/config_store.json"
|
||||
|
||||
type KNXPlatformStoreModel = dict[str, dict[str, Any]] # unique_id: configuration
|
||||
@@ -32,6 +33,7 @@ class KNXConfigStoreModel(TypedDict):
|
||||
"""Represent KNX configuration store data."""
|
||||
|
||||
entities: KNXEntityStoreModel
|
||||
expose: KNXExposeStoreModel
|
||||
time_server: KNXTimeServerStoreModel
|
||||
|
||||
|
||||
@@ -68,6 +70,10 @@ class _KNXConfigStoreStorage(Store[KNXConfigStoreModel]):
|
||||
# version 2.3 introduced in 2026.3
|
||||
migration.migrate_2_2_to_2_3(old_data)
|
||||
|
||||
if old_major_version <= 2 and old_minor_version < 4:
|
||||
# version 2.4 introduced in 2026.5
|
||||
migration.migrate_2_3_to_2_4(old_data)
|
||||
|
||||
return old_data
|
||||
|
||||
|
||||
@@ -87,6 +93,7 @@ class KNXConfigStore:
|
||||
)
|
||||
self.data = KNXConfigStoreModel( # initialize with default structure
|
||||
entities={},
|
||||
expose={},
|
||||
time_server={},
|
||||
)
|
||||
self._platform_controllers: dict[Platform, PlatformControllerBase] = {}
|
||||
@@ -99,6 +106,10 @@ class KNXConfigStore:
|
||||
"Loaded KNX config data from storage. %s entity platforms",
|
||||
len(self.data["entities"]),
|
||||
)
|
||||
_LOGGER.debug(
|
||||
"Loaded KNX config data from storage. %s exposes",
|
||||
len(self.data["expose"]),
|
||||
)
|
||||
|
||||
def add_platform(
|
||||
self, platform: Platform, controller: PlatformControllerBase
|
||||
@@ -183,6 +194,48 @@ class KNXConfigStore:
|
||||
if registry_entry.unique_id in unique_ids
|
||||
]
|
||||
|
||||
def get_exposes(self) -> KNXExposeStoreModel:
|
||||
"""Return KNX entity state expose configuration."""
|
||||
return self.data["expose"]
|
||||
|
||||
def get_expose_groups(self) -> dict[str, list[str]]:
|
||||
"""Return KNX entity state exposes and their group addresses."""
|
||||
return {
|
||||
entity_id: [option["ga"]["write"] for option in config]
|
||||
for entity_id, config in self.data["expose"].items()
|
||||
}
|
||||
|
||||
def get_expose_config(self, entity_id: str) -> list[KNXExposeStoreOptionModel]:
|
||||
"""Return KNX entity state expose configuration for an entity."""
|
||||
return self.data["expose"].get(entity_id, [])
|
||||
|
||||
async def update_expose(
|
||||
self, entity_id: str, expose_config: list[KNXExposeStoreOptionModel]
|
||||
) -> None:
|
||||
"""Update KNX expose configuration for an entity."""
|
||||
knx_module = self.hass.data[KNX_MODULE_KEY]
|
||||
expose_controller = knx_module.ui_expose_controller
|
||||
expose_controller.update_entity_expose(
|
||||
self.hass, knx_module.xknx, entity_id, expose_config
|
||||
)
|
||||
|
||||
self.data["expose"][entity_id] = expose_config
|
||||
await self._store.async_save(self.data)
|
||||
|
||||
async def delete_expose(self, entity_id: str) -> None:
|
||||
"""Delete KNX expose configuration for an entity."""
|
||||
knx_module = self.hass.data[KNX_MODULE_KEY]
|
||||
expose_controller = knx_module.ui_expose_controller
|
||||
expose_controller.remove_entity_expose(entity_id)
|
||||
|
||||
try:
|
||||
del self.data["expose"][entity_id]
|
||||
except KeyError as err:
|
||||
raise ConfigStoreException(
|
||||
f"Entity not found in expose configuration: {entity_id}"
|
||||
) from err
|
||||
await self._store.async_save(self.data)
|
||||
|
||||
@callback
|
||||
def get_time_server_config(self) -> KNXTimeServerStoreModel:
|
||||
"""Return KNX time server configuration."""
|
||||
@@ -191,7 +244,7 @@ class KNXConfigStore:
|
||||
async def update_time_server_config(self, config: KNXTimeServerStoreModel) -> None:
|
||||
"""Update time server configuration."""
|
||||
self.data["time_server"] = config
|
||||
knx_module = self.hass.data.get(DOMAIN)
|
||||
knx_module = self.hass.data[KNX_MODULE_KEY]
|
||||
if knx_module:
|
||||
knx_module.ui_time_server_controller.start(knx_module.xknx, config)
|
||||
await self._store.async_save(self.data)
|
||||
|
||||
154
homeassistant/components/knx/storage/expose_controller.py
Normal file
154
homeassistant/components/knx/storage/expose_controller.py
Normal file
@@ -0,0 +1,154 @@
|
||||
"""KNX configuration storage for entity state exposes."""
|
||||
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
import voluptuous as vol
|
||||
from xknx import XKNX
|
||||
from xknx.dpt import DPTBase
|
||||
from xknx.telegram.address import parse_device_group_address
|
||||
|
||||
from homeassistant.const import CONF_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
selector,
|
||||
template as template_helper,
|
||||
)
|
||||
|
||||
from ..expose import KnxExposeEntity, KnxExposeOptions
|
||||
from .entity_store_validation import validate_config_store_data
|
||||
from .knx_selector import GASelector
|
||||
|
||||
type KNXExposeStoreModel = dict[
|
||||
str, list[KNXExposeStoreOptionModel] # entity_id: configuration
|
||||
]
|
||||
|
||||
|
||||
class KNXExposeStoreOptionModel(TypedDict):
|
||||
"""Represent KNX entity state expose configuration for an entity."""
|
||||
|
||||
ga: dict[str, Any] # group address configuration with write and dpt
|
||||
attribute: NotRequired[str]
|
||||
cooldown: NotRequired[float]
|
||||
default: NotRequired[Any]
|
||||
periodic_send: NotRequired[float]
|
||||
respond_to_read: NotRequired[bool]
|
||||
value_template: NotRequired[str]
|
||||
|
||||
|
||||
class KNXExposeDataModel(TypedDict):
|
||||
"""Represent a loaded KNX expose config for validation."""
|
||||
|
||||
entity_id: str
|
||||
options: list[KNXExposeStoreOptionModel]
|
||||
|
||||
|
||||
def validate_expose_template_no_coerce(value: str) -> str:
|
||||
"""Validate a value is a valid expose template without coercing it to a Template object."""
|
||||
temp = cv.template(value) # validate template
|
||||
if temp.is_static:
|
||||
raise vol.Invalid(
|
||||
"Static templates are not supported. Template should start with '{{' and end with '}}'"
|
||||
)
|
||||
return value # return original string for storage and later template creation
|
||||
|
||||
|
||||
EXPOSE_OPTION_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required("ga"): GASelector(
|
||||
state=False,
|
||||
passive=False,
|
||||
write_required=True,
|
||||
dpt=["numeric", "enum", "complex", "string"],
|
||||
),
|
||||
vol.Optional("attribute"): str,
|
||||
vol.Optional("default"): object,
|
||||
vol.Optional("cooldown"): cv.positive_float, # frontend renders to duration
|
||||
vol.Optional("periodic_send"): cv.positive_float,
|
||||
vol.Optional("respond_to_read"): bool,
|
||||
vol.Optional("value_template"): validate_expose_template_no_coerce,
|
||||
}
|
||||
)
|
||||
|
||||
EXPOSE_CONFIG_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_ENTITY_ID): selector.EntitySelector(),
|
||||
vol.Required("options"): [EXPOSE_OPTION_SCHEMA],
|
||||
},
|
||||
extra=vol.REMOVE_EXTRA,
|
||||
)
|
||||
|
||||
|
||||
def validate_expose_data(data: dict) -> KNXExposeDataModel:
|
||||
"""Validate and convert expose configuration data."""
|
||||
return validate_config_store_data(EXPOSE_CONFIG_SCHEMA, data) # type: ignore[return-value]
|
||||
|
||||
|
||||
def _store_to_expose_option(
|
||||
hass: HomeAssistant, config: KNXExposeStoreOptionModel
|
||||
) -> KnxExposeOptions:
|
||||
"""Convert config store option model to expose options."""
|
||||
ga = parse_device_group_address(config["ga"]["write"])
|
||||
dpt: type[DPTBase] = DPTBase.parse_transcoder(config["ga"]["dpt"]) # type: ignore[assignment]
|
||||
value_template = None
|
||||
if (_value_template_config := config.get("value_template")) is not None:
|
||||
value_template = template_helper.Template(_value_template_config, hass)
|
||||
return KnxExposeOptions(
|
||||
group_address=ga,
|
||||
dpt=dpt,
|
||||
attribute=config.get("attribute"),
|
||||
cooldown=config.get("cooldown", 0),
|
||||
default=config.get("default"),
|
||||
periodic_send=config.get("periodic_send", 0),
|
||||
respond_to_read=config.get("respond_to_read", True),
|
||||
value_template=value_template,
|
||||
)
|
||||
|
||||
|
||||
class ExposeController:
|
||||
"""Controller class for UI entity exposures."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize entity expose controller."""
|
||||
self._entity_exposes: dict[str, KnxExposeEntity] = {}
|
||||
|
||||
@callback
|
||||
def stop(self) -> None:
|
||||
"""Shutdown entity expose controller."""
|
||||
for expose in self._entity_exposes.values():
|
||||
expose.async_remove()
|
||||
self._entity_exposes.clear()
|
||||
|
||||
@callback
|
||||
def start(
|
||||
self, hass: HomeAssistant, xknx: XKNX, config: KNXExposeStoreModel
|
||||
) -> None:
|
||||
"""Update entity expose configuration."""
|
||||
if self._entity_exposes:
|
||||
self.stop()
|
||||
for entity_id, options in config.items():
|
||||
self.update_entity_expose(hass, xknx, entity_id, options)
|
||||
|
||||
@callback
|
||||
def update_entity_expose(
|
||||
self,
|
||||
hass: HomeAssistant,
|
||||
xknx: XKNX,
|
||||
entity_id: str,
|
||||
expose_config: list[KNXExposeStoreOptionModel],
|
||||
) -> None:
|
||||
"""Update entity expose configuration for an entity."""
|
||||
self.remove_entity_expose(entity_id)
|
||||
|
||||
expose_options = [
|
||||
_store_to_expose_option(hass, config) for config in expose_config
|
||||
]
|
||||
expose = KnxExposeEntity(hass, xknx, entity_id, expose_options)
|
||||
self._entity_exposes[entity_id] = expose
|
||||
expose.async_register()
|
||||
|
||||
@callback
|
||||
def remove_entity_expose(self, entity_id: str) -> None:
|
||||
"""Remove entity expose configuration for an entity."""
|
||||
if entity_id in self._entity_exposes:
|
||||
self._entity_exposes.pop(entity_id).async_remove()
|
||||
@@ -55,3 +55,8 @@ def migrate_2_1_to_2_2(data: dict[str, Any]) -> None:
|
||||
def migrate_2_2_to_2_3(data: dict[str, Any]) -> None:
|
||||
"""Migrate from schema 2.2 to schema 2.3."""
|
||||
data.setdefault("time_server", {})
|
||||
|
||||
|
||||
def migrate_2_3_to_2_4(data: dict[str, Any]) -> None:
|
||||
"""Migrate from schema 2.3 to schema 2.4."""
|
||||
data.setdefault("expose", {})
|
||||
|
||||
@@ -950,6 +950,48 @@
|
||||
"description": "Add and manage KNX entities",
|
||||
"title": "Entities"
|
||||
},
|
||||
"expose": {
|
||||
"create": {
|
||||
"add_expose": "Add expose",
|
||||
"attribute": {
|
||||
"description": "Expose changes of a specific attribute of the entity instead of the state. Optional. If the attribute is not set, the entity state is exposed."
|
||||
},
|
||||
"cooldown": {
|
||||
"description": "Minimum time between consecutive sends. This can be used to prevent high traffic on the KNX bus when values change very frequently. Only the most recent value during the cooldown period is sent.",
|
||||
"label": "Cooldown"
|
||||
},
|
||||
"default": {
|
||||
"description": "The value to send if the entity state is `unavailable` or `unknown`, or if the attribute is not set. If `default` is omitted, nothing is sent in these cases, but the last known value remains available for read requests.",
|
||||
"label": "Default value"
|
||||
},
|
||||
"entity": {
|
||||
"description": "Home Assistant entity to expose state changes to the KNX bus.",
|
||||
"label": "Entity"
|
||||
},
|
||||
"ga": {
|
||||
"label": "Group address"
|
||||
},
|
||||
"periodic_send": {
|
||||
"description": "Time interval to automatically resend the current value to the KNX bus, even if it hasn’t changed.",
|
||||
"label": "Periodic send interval"
|
||||
},
|
||||
"respond_to_read": {
|
||||
"description": "[%key:component::knx::config_panel::entities::create::_::knx::respond_to_read::description%]",
|
||||
"label": "[%key:component::knx::config_panel::entities::create::_::knx::respond_to_read::label%]"
|
||||
},
|
||||
"section_advanced_options": {
|
||||
"title": "Advanced options"
|
||||
},
|
||||
"show_raw_values": "Show raw values",
|
||||
"title": "Add exposure",
|
||||
"value_template": {
|
||||
"description": "Optionally transform the entity state or attribute value before sending it to KNX using a template. The template receives the entity state or attribute value as `value` variable.",
|
||||
"label": "Value template"
|
||||
}
|
||||
},
|
||||
"description": "Expose Home Assistant entity states to the KNX bus",
|
||||
"title": "Expose"
|
||||
},
|
||||
"group_monitor": {
|
||||
"description": "Monitor KNX group communication",
|
||||
"title": "Group monitor"
|
||||
|
||||
@@ -35,6 +35,7 @@ from .storage.entity_store_validation import (
|
||||
EntityStoreValidationSuccess,
|
||||
validate_entity_data,
|
||||
)
|
||||
from .storage.expose_controller import validate_expose_data
|
||||
from .storage.serialize import get_serialized_schema
|
||||
from .storage.time_server import validate_time_server_data
|
||||
from .telegrams import (
|
||||
@@ -68,6 +69,11 @@ async def register_panel(hass: HomeAssistant) -> None:
|
||||
websocket_api.async_register_command(hass, ws_get_schema)
|
||||
websocket_api.async_register_command(hass, ws_get_time_server_config)
|
||||
websocket_api.async_register_command(hass, ws_update_time_server_config)
|
||||
websocket_api.async_register_command(hass, ws_get_expose_groups)
|
||||
websocket_api.async_register_command(hass, ws_get_expose_config)
|
||||
websocket_api.async_register_command(hass, ws_update_expose)
|
||||
websocket_api.async_register_command(hass, ws_delete_expose)
|
||||
websocket_api.async_register_command(hass, ws_validate_expose)
|
||||
|
||||
if DOMAIN not in hass.data.get("frontend_panels", {}):
|
||||
await hass.http.async_register_static_paths(
|
||||
@@ -588,6 +594,142 @@ def ws_create_device(
|
||||
connection.send_result(msg["id"], _device.dict_repr)
|
||||
|
||||
|
||||
########
|
||||
# Expose
|
||||
########
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "knx/get_expose_groups",
|
||||
}
|
||||
)
|
||||
@provide_knx
|
||||
@callback
|
||||
def ws_get_expose_groups(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXModule,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
) -> None:
|
||||
"""Get exposes from config store."""
|
||||
connection.send_result(msg["id"], knx.config_store.get_expose_groups())
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "knx/get_expose_config",
|
||||
vol.Required("entity_id"): str,
|
||||
}
|
||||
)
|
||||
@provide_knx
|
||||
@callback
|
||||
def ws_get_expose_config(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXModule,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
) -> None:
|
||||
"""Get expose configuration from config store."""
|
||||
connection.send_result(
|
||||
msg["id"], knx.config_store.get_expose_config(msg["entity_id"])
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "knx/update_expose",
|
||||
vol.Required("entity_id"): str,
|
||||
vol.Required("options"): list, # validation done in handler
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@provide_knx
|
||||
async def ws_update_expose(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXModule,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
) -> None:
|
||||
"""Update expose configuration in config store."""
|
||||
try:
|
||||
validated_data = validate_expose_data(msg)
|
||||
except EntityStoreValidationException as exc:
|
||||
connection.send_result(msg["id"], exc.validation_error)
|
||||
return
|
||||
try:
|
||||
await knx.config_store.update_expose(
|
||||
validated_data["entity_id"], validated_data["options"]
|
||||
)
|
||||
except ConfigStoreException as err:
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err)
|
||||
)
|
||||
return
|
||||
connection.send_result(
|
||||
msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None)
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "knx/delete_expose",
|
||||
vol.Required("entity_id"): str,
|
||||
}
|
||||
)
|
||||
@websocket_api.async_response
|
||||
@provide_knx
|
||||
async def ws_delete_expose(
|
||||
hass: HomeAssistant,
|
||||
knx: KNXModule,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
) -> None:
|
||||
"""Delete expose configuration from config store."""
|
||||
try:
|
||||
await knx.config_store.delete_expose(msg["entity_id"])
|
||||
except ConfigStoreException as err:
|
||||
connection.send_error(
|
||||
msg["id"], websocket_api.const.ERR_HOME_ASSISTANT_ERROR, str(err)
|
||||
)
|
||||
return
|
||||
connection.send_result(msg["id"])
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "knx/validate_expose",
|
||||
vol.Required("entity_id"): str,
|
||||
vol.Required("options"): list, # validation done in handler
|
||||
}
|
||||
)
|
||||
@callback
|
||||
def ws_validate_expose(
|
||||
hass: HomeAssistant,
|
||||
connection: websocket_api.ActiveConnection,
|
||||
msg: dict,
|
||||
) -> None:
|
||||
"""Validate expose data."""
|
||||
try:
|
||||
validate_expose_data(msg)
|
||||
except EntityStoreValidationException as exc:
|
||||
connection.send_result(msg["id"], exc.validation_error)
|
||||
return
|
||||
connection.send_result(
|
||||
msg["id"], EntityStoreValidationSuccess(success=True, entity_id=None)
|
||||
)
|
||||
|
||||
|
||||
#############
|
||||
# Time server
|
||||
#############
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
|
||||
@@ -7,7 +7,6 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
import serial
|
||||
from serial.tools import list_ports
|
||||
import ultraheat_api
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -45,9 +44,7 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_input[CONF_DEVICE] == CONF_MANUAL_PATH:
|
||||
return await self.async_step_setup_serial_manual_path()
|
||||
|
||||
dev_path = await self.hass.async_add_executor_job(
|
||||
usb.get_serial_by_id, user_input[CONF_DEVICE]
|
||||
)
|
||||
dev_path = user_input[CONF_DEVICE]
|
||||
_LOGGER.debug("Using this path : %s", dev_path)
|
||||
|
||||
try:
|
||||
@@ -118,23 +115,19 @@ class LandisgyrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def get_usb_ports(hass: HomeAssistant) -> dict[str, str]:
|
||||
"""Return a dict of USB ports and their friendly names."""
|
||||
ports = await hass.async_add_executor_job(list_ports.comports)
|
||||
ports = await usb.async_scan_serial_ports(hass)
|
||||
port_descriptions = {}
|
||||
for port in ports:
|
||||
# this prevents an issue with usb_device_from_port
|
||||
# not working for ports without vid on RPi
|
||||
if port.vid:
|
||||
usb_device = usb.usb_device_from_port(port)
|
||||
dev_path = usb.get_serial_by_id(usb_device.device)
|
||||
if isinstance(port, usb.USBDevice):
|
||||
human_name = usb.human_readable_device_name(
|
||||
dev_path,
|
||||
usb_device.serial_number,
|
||||
usb_device.manufacturer,
|
||||
usb_device.description,
|
||||
usb_device.vid,
|
||||
usb_device.pid,
|
||||
port.device,
|
||||
port.serial_number,
|
||||
port.manufacturer,
|
||||
port.description,
|
||||
port.vid,
|
||||
port.pid,
|
||||
)
|
||||
port_descriptions[dev_path] = human_name
|
||||
port_descriptions[port.device] = human_name
|
||||
|
||||
return port_descriptions
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: LunatoneConfigEntry) ->
|
||||
"""Set up Lunatone from a config entry."""
|
||||
auth_api = Auth(async_get_clientsession(hass), entry.data[CONF_URL])
|
||||
info_api = Info(auth_api)
|
||||
devices_api = Devices(auth_api)
|
||||
devices_api = Devices(info_api)
|
||||
|
||||
coordinator_info = LunatoneInfoDataUpdateCoordinator(hass, entry, info_api)
|
||||
await coordinator_info.async_config_entry_first_refresh()
|
||||
|
||||
@@ -5,15 +5,17 @@ from typing import Any, Final
|
||||
import aiohttp
|
||||
from lunatone_rest_api_client import Auth, Info
|
||||
import voluptuous as vol
|
||||
from yarl import URL
|
||||
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_RECONFIGURE,
|
||||
ConfigFlow,
|
||||
ConfigFlowResult,
|
||||
)
|
||||
from homeassistant.const import CONF_URL
|
||||
from homeassistant.const import CONF_NAME, CONF_URL
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
@@ -28,13 +30,17 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
VERSION = 1
|
||||
MINOR_VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._data: dict[str, Any] = {}
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by the user."""
|
||||
errors: dict[str, str] = {}
|
||||
if user_input is not None:
|
||||
url = user_input[CONF_URL]
|
||||
url = URL(user_input[CONF_URL]).human_repr()[:-1]
|
||||
data = {CONF_URL: url}
|
||||
self._async_abort_entries_match(data)
|
||||
auth_api = Auth(
|
||||
@@ -64,13 +70,58 @@ class LunatoneConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title=url, data=data)
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=DATA_SCHEMA,
|
||||
errors=errors,
|
||||
step_id="user", data_schema=DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_zeroconf(
|
||||
self, discovery_info: ZeroconfServiceInfo
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a flow initialized by zeroconf discovery."""
|
||||
url = URL.build(scheme="http", host=discovery_info.host).human_repr()[:-1]
|
||||
uid = discovery_info.properties["uid"]
|
||||
await self.async_set_unique_id(uid.replace("-", ""))
|
||||
self._abort_if_unique_id_configured(updates={CONF_URL: url})
|
||||
|
||||
auth_api = Auth(
|
||||
session=async_get_clientsession(self.hass),
|
||||
base_url=url,
|
||||
)
|
||||
info_api = Info(auth_api)
|
||||
|
||||
try:
|
||||
await info_api.async_update()
|
||||
except aiohttp.InvalidUrlClientError:
|
||||
return self.async_abort(reason="invalid_url")
|
||||
except aiohttp.ClientConnectionError:
|
||||
return self.async_abort(reason="cannot_connect")
|
||||
|
||||
self._data[CONF_URL] = url
|
||||
|
||||
return await self.async_step_discovery_confirm()
|
||||
|
||||
async def async_step_discovery_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Confirm the discovered device."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title=self._data[CONF_URL], data=self._data)
|
||||
return self.async_show_form(
|
||||
step_id="discovery_confirm",
|
||||
description_placeholders=self._data,
|
||||
)
|
||||
|
||||
async def async_step_reconfigure(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle a reconfiguration flow initialized by the user."""
|
||||
return await self.async_step_user(user_input)
|
||||
if user_input is not None:
|
||||
return await self.async_step_user(user_input)
|
||||
|
||||
entry = self._get_reconfigure_entry()
|
||||
return self.async_show_form(
|
||||
step_id="reconfigure",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_URL, default=entry.data[CONF_URL]): cv.string},
|
||||
),
|
||||
description_placeholders={CONF_NAME: entry.title},
|
||||
)
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any
|
||||
|
||||
from lunatone_rest_api_client import DALIBroadcast
|
||||
@@ -10,6 +9,9 @@ from lunatone_rest_api_client.models import LineStatus
|
||||
|
||||
from homeassistant.components.light import (
|
||||
ATTR_BRIGHTNESS,
|
||||
ATTR_COLOR_TEMP_KELVIN,
|
||||
ATTR_RGB_COLOR,
|
||||
ATTR_RGBW_COLOR,
|
||||
ColorMode,
|
||||
LightEntity,
|
||||
brightness_supported,
|
||||
@@ -28,7 +30,6 @@ from .coordinator import (
|
||||
)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
STATUS_UPDATE_DELAY = 0.04
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
@@ -74,6 +75,8 @@ class LunatoneLight(
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_should_poll = False
|
||||
_attr_min_color_temp_kelvin = 1000
|
||||
_attr_max_color_temp_kelvin = 10000
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -123,7 +126,13 @@ class LunatoneLight(
|
||||
@property
|
||||
def color_mode(self) -> ColorMode:
|
||||
"""Return the color mode of the light."""
|
||||
if self._device is not None and self._device.brightness is not None:
|
||||
if self._device.rgbw_color is not None:
|
||||
return ColorMode.RGBW
|
||||
if self._device.rgb_color is not None:
|
||||
return ColorMode.RGB
|
||||
if self._device.color_temperature is not None:
|
||||
return ColorMode.COLOR_TEMP
|
||||
if self._device.brightness is not None:
|
||||
return ColorMode.BRIGHTNESS
|
||||
return ColorMode.ONOFF
|
||||
|
||||
@@ -132,6 +141,32 @@ class LunatoneLight(
|
||||
"""Return the supported color modes."""
|
||||
return {self.color_mode}
|
||||
|
||||
@property
|
||||
def color_temp_kelvin(self) -> int | None:
|
||||
"""Return the color temp of this light in kelvin."""
|
||||
return self._device.color_temperature
|
||||
|
||||
@property
|
||||
def rgb_color(self) -> tuple[int, int, int] | None:
|
||||
"""Return the RGB color of this light."""
|
||||
rgb_color = self._device.rgb_color
|
||||
return rgb_color and (
|
||||
round(rgb_color[0] * 255),
|
||||
round(rgb_color[1] * 255),
|
||||
round(rgb_color[2] * 255),
|
||||
)
|
||||
|
||||
@property
|
||||
def rgbw_color(self) -> tuple[int, int, int, int] | None:
|
||||
"""Return the RGBW color of this light."""
|
||||
rgbw_color = self._device.rgbw_color
|
||||
return rgbw_color and (
|
||||
round(rgbw_color[0] * 255),
|
||||
round(rgbw_color[1] * 255),
|
||||
round(rgbw_color[2] * 255),
|
||||
round(rgbw_color[3] * 255),
|
||||
)
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Handle updated data from the coordinator."""
|
||||
@@ -141,16 +176,26 @@ class LunatoneLight(
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Instruct the light to turn on."""
|
||||
if brightness_supported(self.supported_color_modes):
|
||||
await self._device.fade_to_brightness(
|
||||
brightness_to_value(
|
||||
self.BRIGHTNESS_SCALE,
|
||||
kwargs.get(ATTR_BRIGHTNESS, self._last_brightness),
|
||||
if ATTR_COLOR_TEMP_KELVIN in kwargs:
|
||||
await self._device.fade_to_color_temperature(
|
||||
kwargs[ATTR_COLOR_TEMP_KELVIN]
|
||||
)
|
||||
if ATTR_RGB_COLOR in kwargs:
|
||||
await self._device.fade_to_rgbw_color(
|
||||
tuple(color / 255 for color in kwargs[ATTR_RGB_COLOR])
|
||||
)
|
||||
if ATTR_RGBW_COLOR in kwargs:
|
||||
rgbw_color = tuple(color / 255 for color in kwargs[ATTR_RGBW_COLOR])
|
||||
await self._device.fade_to_rgbw_color(rgbw_color[:-1], rgbw_color[-1])
|
||||
if ATTR_BRIGHTNESS in kwargs or not self.is_on:
|
||||
await self._device.fade_to_brightness(
|
||||
brightness_to_value(
|
||||
self.BRIGHTNESS_SCALE,
|
||||
kwargs.get(ATTR_BRIGHTNESS, self._last_brightness),
|
||||
)
|
||||
)
|
||||
)
|
||||
else:
|
||||
await self._device.switch_on()
|
||||
|
||||
await asyncio.sleep(STATUS_UPDATE_DELAY)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
@@ -161,8 +206,6 @@ class LunatoneLight(
|
||||
await self._device.fade_to_brightness(0)
|
||||
else:
|
||||
await self._device.switch_off()
|
||||
|
||||
await asyncio.sleep(STATUS_UPDATE_DELAY)
|
||||
await self.coordinator.async_refresh()
|
||||
|
||||
|
||||
@@ -221,13 +264,9 @@ class LunatoneLineBroadcastLight(
|
||||
await self._broadcast.fade_to_brightness(
|
||||
brightness_to_value(self.BRIGHTNESS_SCALE, kwargs.get(ATTR_BRIGHTNESS, 255))
|
||||
)
|
||||
|
||||
await asyncio.sleep(STATUS_UPDATE_DELAY)
|
||||
await self._coordinator_devices.async_refresh()
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Instruct the line to turn off."""
|
||||
await self._broadcast.fade_to_brightness(0)
|
||||
|
||||
await asyncio.sleep(STATUS_UPDATE_DELAY)
|
||||
await self._coordinator_devices.async_refresh()
|
||||
|
||||
@@ -7,5 +7,15 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["lunatone-rest-api-client==0.7.0"]
|
||||
"requirements": ["lunatone-rest-api-client==0.9.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"properties": {
|
||||
"manufacturer": "lunatone industrielle elektronik gmbh",
|
||||
"type": "dali-2-*",
|
||||
"uid": "*"
|
||||
},
|
||||
"type": "_http._tcp.local."
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -2,17 +2,19 @@
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
|
||||
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
|
||||
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
|
||||
},
|
||||
"error": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_url": "Failed to connect. Check the URL and if the device is connected to power",
|
||||
"missing_device_info": "Failed to read device information. Check the network connection of the device"
|
||||
},
|
||||
"step": {
|
||||
"confirm": {
|
||||
"description": "[%key:common::config_flow::description::confirm_setup%]"
|
||||
"discovery_confirm": {
|
||||
"description": "Do you want to setup the Lunatone device with {url}?"
|
||||
},
|
||||
"reconfigure": {
|
||||
"data": {
|
||||
@@ -21,16 +23,16 @@
|
||||
"data_description": {
|
||||
"url": "[%key:component::lunatone::config::step::user::data_description::url%]"
|
||||
},
|
||||
"description": "Update the URL."
|
||||
"description": "Update configuration for {name}."
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"url": "[%key:common::config_flow::data::url%]"
|
||||
},
|
||||
"data_description": {
|
||||
"url": "The URL of the Lunatone gateway device."
|
||||
"url": "The URL of the Lunatone device to connect to."
|
||||
},
|
||||
"description": "Connect to the API of your Lunatone DALI IoT Gateway."
|
||||
"description": "Enter the URL of your Lunatone device.\nHome Assistant will use this address to connect to the device API."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ from __future__ import annotations
|
||||
from typing import Any
|
||||
|
||||
from phone_modem import PhoneModem
|
||||
import serial.tools.list_ports
|
||||
from serial.tools.list_ports_common import ListPortInfo
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
@@ -19,9 +17,11 @@ from .const import DEFAULT_NAME, DOMAIN, EXCEPTIONS
|
||||
DATA_SCHEMA = vol.Schema({"name": str, "device": str})
|
||||
|
||||
|
||||
def _generate_unique_id(port: ListPortInfo) -> str:
|
||||
def _generate_unique_id(port: usb.USBDevice | usb.SerialDevice) -> str:
|
||||
"""Generate unique id from usb attributes."""
|
||||
return f"{port.vid}:{port.pid}_{port.serial_number}_{port.manufacturer}_{port.description}"
|
||||
vid = port.vid if isinstance(port, usb.USBDevice) else None
|
||||
pid = port.pid if isinstance(port, usb.USBDevice) else None
|
||||
return f"{vid}:{pid}_{port.serial_number}_{port.manufacturer}_{port.description}"
|
||||
|
||||
|
||||
class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
@@ -62,30 +62,28 @@ class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
errors: dict[str, str] | None = {}
|
||||
if self._async_in_progress():
|
||||
return self.async_abort(reason="already_in_progress")
|
||||
ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
|
||||
ports = await usb.async_scan_serial_ports(self.hass)
|
||||
existing_devices = [
|
||||
entry.data[CONF_DEVICE] for entry in self._async_current_entries()
|
||||
]
|
||||
unused_ports = [
|
||||
port_map = {
|
||||
usb.human_readable_device_name(
|
||||
port.device,
|
||||
port.serial_number,
|
||||
port.manufacturer,
|
||||
port.description,
|
||||
port.vid,
|
||||
port.pid,
|
||||
)
|
||||
port.vid if isinstance(port, usb.USBDevice) else None,
|
||||
port.pid if isinstance(port, usb.USBDevice) else None,
|
||||
): port
|
||||
for port in ports
|
||||
if port.device not in existing_devices
|
||||
]
|
||||
if not unused_ports:
|
||||
}
|
||||
if not port_map:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
if user_input is not None:
|
||||
port = ports[unused_ports.index(str(user_input.get(CONF_DEVICE)))]
|
||||
dev_path = await self.hass.async_add_executor_job(
|
||||
usb.get_serial_by_id, port.device
|
||||
)
|
||||
port = port_map[user_input[CONF_DEVICE]]
|
||||
dev_path = port.device
|
||||
errors = await self.validate_device_errors(
|
||||
dev_path=dev_path, unique_id=_generate_unique_id(port)
|
||||
)
|
||||
@@ -95,7 +93,7 @@ class PhoneModemFlowHandler(ConfigFlow, domain=DOMAIN):
|
||||
data={CONF_DEVICE: dev_path},
|
||||
)
|
||||
user_input = user_input or {}
|
||||
schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)})
|
||||
schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(list(port_map))})
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
async def validate_device_errors(
|
||||
|
||||
@@ -53,6 +53,8 @@ PLATFORMS = [
|
||||
Platform.BUTTON,
|
||||
Platform.MEDIA_PLAYER,
|
||||
Platform.NUMBER,
|
||||
Platform.SWITCH,
|
||||
Platform.TEXT,
|
||||
]
|
||||
|
||||
CONNECT_TIMEOUT = 10
|
||||
|
||||
@@ -82,3 +82,4 @@ ATTR_CONF_EXPOSE_PLAYER_TO_HA = "expose_player_to_ha"
|
||||
LOGGER = logging.getLogger(__package__)
|
||||
|
||||
PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX = "player_options."
|
||||
SOUND_MODES_TRANSLATION_KEY_PREFIX = "player_sound_mode."
|
||||
|
||||
@@ -60,6 +60,7 @@ from .const import (
|
||||
ATTR_REPEAT_MODE,
|
||||
ATTR_SHUFFLE_ENABLED,
|
||||
DOMAIN,
|
||||
SOUND_MODES_TRANSLATION_KEY_PREFIX,
|
||||
)
|
||||
from .entity import MusicAssistantEntity
|
||||
from .helpers import catch_musicassistant_error
|
||||
@@ -131,6 +132,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
_attr_name = None
|
||||
_attr_media_image_remotely_accessible = True
|
||||
_attr_media_content_type = HAMediaType.MUSIC
|
||||
_attr_translation_key = "ma_media_player"
|
||||
|
||||
def __init__(self, mass: MusicAssistantClient, player_id: str) -> None:
|
||||
"""Initialize MediaPlayer entity."""
|
||||
@@ -140,6 +142,7 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
self._attr_device_class = MediaPlayerDeviceClass.SPEAKER
|
||||
self._prev_time: float = 0
|
||||
self._source_list_mapping: dict[str, str] = {}
|
||||
self._sound_mode_list_mapping: dict[str, str] = {}
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Register callbacks."""
|
||||
@@ -218,6 +221,29 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
self._source_list_mapping = source_mappings
|
||||
self._attr_source = active_source_name
|
||||
|
||||
# same for sound modes
|
||||
sound_mode_mappings: dict[str, str] = {}
|
||||
for sound_mode in player.sound_mode_list:
|
||||
if sound_mode.passive:
|
||||
# ignore passive sound_mode because HA does not differentiate between
|
||||
# active and passive sound mode
|
||||
continue
|
||||
if (
|
||||
sound_mode.translation_key is None
|
||||
or SOUND_MODES_TRANSLATION_KEY_PREFIX not in sound_mode.translation_key
|
||||
):
|
||||
# MA's data class initializes the translation_key to
|
||||
# player_sound_mode.<id> automatically if it is not given, so we should
|
||||
# always have a non None value
|
||||
continue
|
||||
translation_key = sound_mode.translation_key[
|
||||
len(SOUND_MODES_TRANSLATION_KEY_PREFIX) :
|
||||
]
|
||||
sound_mode_mappings[translation_key] = sound_mode.id
|
||||
self._attr_sound_mode_list = list(sound_mode_mappings.keys())
|
||||
self._sound_mode_list_mapping = sound_mode_mappings
|
||||
self._attr_sound_mode = player.active_sound_mode
|
||||
|
||||
group_members: list[str] = []
|
||||
if player.group_members:
|
||||
group_members = player.group_members
|
||||
@@ -397,6 +423,16 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
)
|
||||
await self.mass.players.player_command_select_source(self.player_id, source_id)
|
||||
|
||||
@catch_musicassistant_error
|
||||
async def async_select_sound_mode(self, sound_mode: str) -> None:
|
||||
"""Select sound mode."""
|
||||
sound_mode_id = self._sound_mode_list_mapping.get(sound_mode)
|
||||
if sound_mode_id is None:
|
||||
raise ServiceValidationError(
|
||||
f"Sound mode '{sound_mode}' not found for player {self.name}"
|
||||
)
|
||||
await self.mass.players.select_sound_mode(self.player_id, sound_mode_id)
|
||||
|
||||
@catch_musicassistant_error
|
||||
async def _async_handle_play_media(
|
||||
self,
|
||||
@@ -682,4 +718,6 @@ class MusicAssistantPlayer(MusicAssistantEntity, MediaPlayerEntity):
|
||||
supported_features |= MediaPlayerEntityFeature.TURN_OFF
|
||||
if PlayerFeature.SELECT_SOURCE in self.player.supported_features:
|
||||
supported_features |= MediaPlayerEntityFeature.SELECT_SOURCE
|
||||
if PlayerFeature.SELECT_SOUND_MODE in self.player.supported_features:
|
||||
supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
|
||||
self._attr_supported_features = supported_features
|
||||
|
||||
@@ -54,6 +54,69 @@
|
||||
"name": "Favorite current song"
|
||||
}
|
||||
},
|
||||
"media_player": {
|
||||
"ma_media_player": {
|
||||
"state_attributes": {
|
||||
"sound_mode": {
|
||||
"state": {
|
||||
"2ch_stereo": "2ch stereo",
|
||||
"5ch_stereo": "5ch stereo",
|
||||
"7ch_stereo": "7ch stereo",
|
||||
"9ch_stereo": "9ch stereo",
|
||||
"11ch_stereo": "11ch stereo",
|
||||
"action_game": "Action game",
|
||||
"adventure": "Adventure",
|
||||
"all_ch_stereo": "All ch stereo",
|
||||
"amsterdam": "Hall in Amsterdam",
|
||||
"arena": "Arena",
|
||||
"bottom_line": "The Bottom Line",
|
||||
"cellar_club": "Cellar club",
|
||||
"chamber": "Chamber",
|
||||
"concert": "Live concert",
|
||||
"disco": "Disco",
|
||||
"drama": "Drama",
|
||||
"enhanced": "Enhanced",
|
||||
"frankfurt": "Hall in Frankfurt",
|
||||
"freiburg": "Church in Freiburg",
|
||||
"game": "Game",
|
||||
"jazz_club": "Jazz club",
|
||||
"mono_movie": "Mono movie",
|
||||
"movie": "Movie",
|
||||
"munich": "Hall in Munich",
|
||||
"munich_a": "Hall in Munich A",
|
||||
"munich_b": "Hall in Munich B",
|
||||
"music": "Music",
|
||||
"music_video": "Music video",
|
||||
"my_surround": "My surround",
|
||||
"off": "[%key:common::state::off%]",
|
||||
"pavilion": "Pavilion",
|
||||
"recital_opera": "Recital/opera",
|
||||
"roleplaying_game": "Roleplaying game",
|
||||
"roxy_theatre": "The Roxy Theatre",
|
||||
"royaumont": "Church in Royaumont",
|
||||
"sci-fi": "Sci-fi",
|
||||
"spectacle": "Spectacle",
|
||||
"sports": "Sports",
|
||||
"standard": "Standard",
|
||||
"stereo": "Stereo",
|
||||
"straight": "Straight",
|
||||
"stuttgart": "Hall in Stuttgart",
|
||||
"surr_decoder": "Surround decoder",
|
||||
"talk_show": "Talk show",
|
||||
"target": "Target",
|
||||
"tokyo": "Church in Tokyo",
|
||||
"tv_program": "TV program",
|
||||
"usa_a": "Hall in USA A",
|
||||
"usa_b": "Hall in USA B",
|
||||
"vienna": "Hall in Vienna",
|
||||
"village_gate": "Village Gate",
|
||||
"village_vanguard": "Village Vanguard",
|
||||
"warehouse_loft": "Warehouse loft"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"number": {
|
||||
"bass": {
|
||||
"name": "Bass"
|
||||
@@ -82,6 +145,43 @@
|
||||
"treble": {
|
||||
"name": "Treble"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"adaptive_drc": {
|
||||
"name": "Adaptive DRC"
|
||||
},
|
||||
"bass_extension": {
|
||||
"name": "Bass extension"
|
||||
},
|
||||
"clear_voice": {
|
||||
"name": "Clear voice"
|
||||
},
|
||||
"enhancer": {
|
||||
"name": "Enhancer"
|
||||
},
|
||||
"extra_bass": {
|
||||
"name": "Extra bass"
|
||||
},
|
||||
"party_mode": {
|
||||
"name": "Party mode"
|
||||
},
|
||||
"pure_direct": {
|
||||
"name": "Pure direct"
|
||||
},
|
||||
"speaker_a": {
|
||||
"name": "Speaker A"
|
||||
},
|
||||
"speaker_b": {
|
||||
"name": "Speaker B"
|
||||
},
|
||||
"surround_3d": {
|
||||
"name": "Surround 3D"
|
||||
}
|
||||
},
|
||||
"text": {
|
||||
"network_name": {
|
||||
"name": "Network name"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
118
homeassistant/components/music_assistant/switch.py
Normal file
118
homeassistant/components/music_assistant/switch.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""Music Assistant Switch platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Final
|
||||
|
||||
from music_assistant_client.client import MusicAssistantClient
|
||||
from music_assistant_models.player import PlayerOption, PlayerOptionType
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import MusicAssistantConfigEntry
|
||||
from .const import PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX
|
||||
from .entity import MusicAssistantPlayerOptionEntity
|
||||
from .helpers import catch_musicassistant_error
|
||||
|
||||
PLAYER_OPTIONS_SWITCH: Final[dict[str, bool]] = {
|
||||
# translation_key: enabled_by_default
|
||||
"adaptive_drc": False,
|
||||
"bass_extension": False,
|
||||
"clear_voice": False,
|
||||
"enhancer": True,
|
||||
"extra_bass": False,
|
||||
"party_mode": False,
|
||||
"pure_direct": True,
|
||||
"speaker_a": True,
|
||||
"speaker_b": True,
|
||||
"surround_3d": False,
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MusicAssistantConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Music Assistant Switch Entities (Player Options) from Config Entry."""
|
||||
mass = entry.runtime_data.mass
|
||||
|
||||
def add_player(player_id: str) -> None:
|
||||
"""Handle add player."""
|
||||
player = mass.players.get(player_id)
|
||||
if player is None:
|
||||
return
|
||||
entities: list[MusicAssistantPlayerConfigSwitch] = []
|
||||
for player_option in player.options:
|
||||
if (
|
||||
not player_option.read_only
|
||||
and player_option.type == PlayerOptionType.BOOLEAN
|
||||
):
|
||||
# the MA translation key must have the format player_options.<translation key>
|
||||
# we ignore entities with unknown translation keys.
|
||||
if (
|
||||
player_option.translation_key is None
|
||||
or not player_option.translation_key.startswith(
|
||||
PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX
|
||||
)
|
||||
):
|
||||
continue
|
||||
translation_key = player_option.translation_key[
|
||||
len(PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX) :
|
||||
]
|
||||
if translation_key not in PLAYER_OPTIONS_SWITCH:
|
||||
continue
|
||||
|
||||
entities.append(
|
||||
MusicAssistantPlayerConfigSwitch(
|
||||
mass,
|
||||
player_id,
|
||||
player_option=player_option,
|
||||
entity_description=SwitchEntityDescription(
|
||||
key=player_option.key,
|
||||
translation_key=translation_key,
|
||||
entity_registry_enabled_default=PLAYER_OPTIONS_SWITCH[
|
||||
translation_key
|
||||
],
|
||||
),
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
# register callback to add players when they are discovered
|
||||
entry.runtime_data.platform_handlers.setdefault(Platform.SWITCH, add_player)
|
||||
|
||||
|
||||
class MusicAssistantPlayerConfigSwitch(MusicAssistantPlayerOptionEntity, SwitchEntity):
|
||||
"""Representation of a Switch entity to control player provider dependent settings."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mass: MusicAssistantClient,
|
||||
player_id: str,
|
||||
player_option: PlayerOption,
|
||||
entity_description: SwitchEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize MusicAssistantPlayerConfigSwitch."""
|
||||
super().__init__(mass, player_id, player_option)
|
||||
|
||||
self.entity_description = entity_description
|
||||
|
||||
@catch_musicassistant_error
|
||||
async def async_turn_on(self, **kwargs: Any) -> None:
|
||||
"""Handle turn on command."""
|
||||
await self.mass.players.set_option(self.player_id, self.mass_option_key, True)
|
||||
|
||||
@catch_musicassistant_error
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
"""Handle turn off command."""
|
||||
await self.mass.players.set_option(self.player_id, self.mass_option_key, False)
|
||||
|
||||
def on_player_option_update(self, player_option: PlayerOption) -> None:
|
||||
"""Update on player option update."""
|
||||
self._attr_is_on = (
|
||||
player_option.value if isinstance(player_option.value, bool) else None
|
||||
)
|
||||
101
homeassistant/components/music_assistant/text.py
Normal file
101
homeassistant/components/music_assistant/text.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Music Assistant text platform."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Final
|
||||
|
||||
from music_assistant_client.client import MusicAssistantClient
|
||||
from music_assistant_models.player import PlayerOption, PlayerOptionType
|
||||
|
||||
from homeassistant.components.text import TextEntity, TextEntityDescription
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import MusicAssistantConfigEntry
|
||||
from .const import PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX
|
||||
from .entity import MusicAssistantPlayerOptionEntity
|
||||
from .helpers import catch_musicassistant_error
|
||||
|
||||
PLAYER_OPTIONS_TRANSLATION_KEYS_TEXT: Final[list[str]] = [
|
||||
"network_name",
|
||||
]
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: MusicAssistantConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up Music Assistant text Entities (Player Options) from Config Entry."""
|
||||
mass = entry.runtime_data.mass
|
||||
|
||||
def add_player(player_id: str) -> None:
|
||||
"""Handle add player."""
|
||||
player = mass.players.get(player_id)
|
||||
if player is None:
|
||||
return
|
||||
entities: list[MusicAssistantPlayerConfigText] = []
|
||||
for player_option in player.options:
|
||||
if (
|
||||
not player_option.read_only
|
||||
and player_option.type == PlayerOptionType.STRING
|
||||
and not player_option.options # these we map to select
|
||||
):
|
||||
# the MA translation key must have the format player_options.<translation key>
|
||||
# we ignore entities with unknown translation keys.
|
||||
if (
|
||||
player_option.translation_key is None
|
||||
or not player_option.translation_key.startswith(
|
||||
PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX
|
||||
)
|
||||
):
|
||||
continue
|
||||
translation_key = player_option.translation_key[
|
||||
len(PLAYER_OPTIONS_TRANSLATION_KEY_PREFIX) :
|
||||
]
|
||||
if translation_key not in PLAYER_OPTIONS_TRANSLATION_KEYS_TEXT:
|
||||
continue
|
||||
|
||||
entities.append(
|
||||
MusicAssistantPlayerConfigText(
|
||||
mass,
|
||||
player_id,
|
||||
player_option=player_option,
|
||||
entity_description=TextEntityDescription(
|
||||
key=player_option.key,
|
||||
translation_key=translation_key,
|
||||
),
|
||||
)
|
||||
)
|
||||
async_add_entities(entities)
|
||||
|
||||
# register callback to add players when they are discovered
|
||||
entry.runtime_data.platform_handlers.setdefault(Platform.TEXT, add_player)
|
||||
|
||||
|
||||
class MusicAssistantPlayerConfigText(MusicAssistantPlayerOptionEntity, TextEntity):
|
||||
"""Representation of a text entity to control player provider dependent settings."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mass: MusicAssistantClient,
|
||||
player_id: str,
|
||||
player_option: PlayerOption,
|
||||
entity_description: TextEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize MusicAssistantPlayerConfigtext."""
|
||||
super().__init__(mass, player_id, player_option)
|
||||
|
||||
self.entity_description = entity_description
|
||||
|
||||
@catch_musicassistant_error
|
||||
async def async_set_value(self, value: str) -> None:
|
||||
"""Set text value."""
|
||||
await self.mass.players.set_option(self.player_id, self.mass_option_key, value)
|
||||
|
||||
def on_player_option_update(self, player_option: PlayerOption) -> None:
|
||||
"""Update on player option update."""
|
||||
self._attr_native_value = (
|
||||
player_option.value if isinstance(player_option.value, str) else None
|
||||
)
|
||||
@@ -8,5 +8,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pybotvac"],
|
||||
"requirements": ["pybotvac==0.0.28"]
|
||||
"requirements": ["pybotvac==0.0.29"]
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ from homeassistant.util.unit_conversion import (
|
||||
ElectricPotentialConverter,
|
||||
EnergyConverter,
|
||||
EnergyDistanceConverter,
|
||||
FrequencyConverter,
|
||||
InformationConverter,
|
||||
MassConverter,
|
||||
MassVolumeConcentrationConverter,
|
||||
@@ -629,6 +630,7 @@ UNIT_CONVERTERS: dict[NumberDeviceClass, type[BaseUnitConverter]] = {
|
||||
NumberDeviceClass.ENERGY: EnergyConverter,
|
||||
NumberDeviceClass.ENERGY_DISTANCE: EnergyDistanceConverter,
|
||||
NumberDeviceClass.ENERGY_STORAGE: EnergyConverter,
|
||||
NumberDeviceClass.FREQUENCY: FrequencyConverter,
|
||||
NumberDeviceClass.GAS: VolumeConverter,
|
||||
NumberDeviceClass.NITROGEN_DIOXIDE: NitrogenDioxideConcentrationConverter,
|
||||
NumberDeviceClass.NITROGEN_MONOXIDE: NitrogenMonoxideConcentrationConverter,
|
||||
|
||||
@@ -36,7 +36,7 @@ from .services import async_setup_services
|
||||
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
|
||||
|
||||
_BASE_PLATFORMS: list[Platform] = []
|
||||
_FLEX_PLATFORMS = [Platform.SENSOR]
|
||||
_FLEX_PLATFORMS = [Platform.EVENT, Platform.SENSOR]
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -2,11 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, field
|
||||
import logging
|
||||
|
||||
from opendisplay import MANUFACTURER_ID, parse_advertisement
|
||||
from opendisplay.models.advertisement import AdvertisementData
|
||||
from opendisplay import MANUFACTURER_ID, AdvertisementTracker, parse_advertisement
|
||||
from opendisplay.models.advertisement import AdvertisementData, ButtonChangeEvent
|
||||
|
||||
from homeassistant.components.bluetooth import (
|
||||
BluetoothChange,
|
||||
@@ -27,6 +27,7 @@ class OpenDisplayUpdate:
|
||||
|
||||
address: str
|
||||
advertisement: AdvertisementData
|
||||
button_events: list[ButtonChangeEvent] = field(default_factory=list)
|
||||
|
||||
|
||||
class OpenDisplayCoordinator(PassiveBluetoothDataUpdateCoordinator):
|
||||
@@ -42,6 +43,7 @@ class OpenDisplayCoordinator(PassiveBluetoothDataUpdateCoordinator):
|
||||
connectable=True,
|
||||
)
|
||||
self.data: OpenDisplayUpdate | None = None
|
||||
self._tracker: AdvertisementTracker = AdvertisementTracker()
|
||||
|
||||
@callback
|
||||
def _async_handle_unavailable(
|
||||
@@ -78,9 +80,11 @@ class OpenDisplayCoordinator(PassiveBluetoothDataUpdateCoordinator):
|
||||
exc_info=True,
|
||||
)
|
||||
else:
|
||||
button_events = self._tracker.update(service_info.address, advertisement)
|
||||
self.data = OpenDisplayUpdate(
|
||||
address=service_info.address,
|
||||
advertisement=advertisement,
|
||||
button_events=button_events,
|
||||
)
|
||||
|
||||
super()._async_handle_bluetooth_event(service_info, change)
|
||||
|
||||
93
homeassistant/components/opendisplay/event.py
Normal file
93
homeassistant/components/opendisplay/event.py
Normal file
@@ -0,0 +1,93 @@
|
||||
"""Event platform for OpenDisplay devices — button press/release events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.event import (
|
||||
EventDeviceClass,
|
||||
EventEntity,
|
||||
EventEntityDescription,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import OpenDisplayConfigEntry
|
||||
from .entity import OpenDisplayEntity
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class OpenDisplayEventEntityDescription(EventEntityDescription):
|
||||
"""Describes an OpenDisplay button event entity."""
|
||||
|
||||
byte_index: int
|
||||
button_id: int
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: OpenDisplayConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up OpenDisplay event entities from binary_inputs device config."""
|
||||
coordinator = entry.runtime_data.coordinator
|
||||
|
||||
descriptions: list[OpenDisplayEventEntityDescription] = []
|
||||
button_number = 0
|
||||
for bi in entry.runtime_data.device_config.binary_inputs:
|
||||
for button_id in range(8): # input_flags is a bitmask over 8 pin slots
|
||||
if bi.input_flags & (1 << button_id):
|
||||
button_number += 1
|
||||
descriptions.append(
|
||||
OpenDisplayEventEntityDescription(
|
||||
key=f"button_{bi.instance_number}_{button_id}",
|
||||
translation_key="button",
|
||||
translation_placeholders={"number": str(button_number)},
|
||||
device_class=EventDeviceClass.BUTTON,
|
||||
event_types=["button_down", "button_up"],
|
||||
byte_index=bi.button_data_byte_index,
|
||||
button_id=button_id,
|
||||
)
|
||||
)
|
||||
|
||||
active_unique_ids = {f"{coordinator.address}-{d.key}" for d in descriptions}
|
||||
button_unique_id_prefix = f"{coordinator.address}-button_"
|
||||
entity_registry = er.async_get(hass)
|
||||
for entity_entry in er.async_entries_for_config_entry(
|
||||
entity_registry, entry.entry_id
|
||||
):
|
||||
if (
|
||||
entity_entry.domain == "event"
|
||||
and entity_entry.unique_id.startswith(button_unique_id_prefix)
|
||||
and entity_entry.unique_id not in active_unique_ids
|
||||
):
|
||||
entity_registry.async_remove(entity_entry.entity_id)
|
||||
|
||||
async_add_entities(
|
||||
OpenDisplayEventEntity(coordinator, description) for description in descriptions
|
||||
)
|
||||
|
||||
|
||||
class OpenDisplayEventEntity(OpenDisplayEntity, EventEntity):
|
||||
"""A button event entity for an OpenDisplay device."""
|
||||
|
||||
entity_description: OpenDisplayEventEntityDescription
|
||||
_last_processed_data: object | None = None
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Fire events for button transitions reported by this coordinator update."""
|
||||
data = self.coordinator.data
|
||||
if data is not None and data is not self._last_processed_data:
|
||||
for event in data.button_events:
|
||||
if (
|
||||
event.byte_index == self.entity_description.byte_index
|
||||
and event.button_id == self.entity_description.button_id
|
||||
and event.event_type in self.event_types
|
||||
):
|
||||
self._trigger_event(event.event_type)
|
||||
self._last_processed_data = data
|
||||
self.async_write_ha_state()
|
||||
@@ -51,6 +51,19 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"event": {
|
||||
"button": {
|
||||
"name": "Button {number}",
|
||||
"state_attributes": {
|
||||
"event_type": {
|
||||
"state": {
|
||||
"button_down": "Button down",
|
||||
"button_up": "Button up"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"battery_voltage": {
|
||||
"name": "Battery voltage"
|
||||
|
||||
@@ -7,7 +7,11 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from python_picnic_api2 import PicnicAPI
|
||||
from python_picnic_api2.session import PicnicAuthError
|
||||
from python_picnic_api2.session import (
|
||||
Picnic2FAError,
|
||||
Picnic2FARequired,
|
||||
PicnicAuthError,
|
||||
)
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -18,13 +22,19 @@ from homeassistant.const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.selector import (
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import COUNTRY_CODES, DOMAIN
|
||||
from .const import COUNTRY_CODES, DOMAIN, TWO_FA_CHANNELS
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
CONF_2FA_CODE = "two_fa_code"
|
||||
CONF_2FA_CHANNEL = "two_fa_channel"
|
||||
|
||||
STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
@@ -35,45 +45,23 @@ STEP_USER_DATA_SCHEMA = vol.Schema(
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PicnicHub:
|
||||
"""Hub class to test user authentication."""
|
||||
|
||||
@staticmethod
|
||||
def authenticate(username, password, country_code) -> tuple[str, dict]:
|
||||
"""Test if we can authenticate with the Picnic API."""
|
||||
picnic = PicnicAPI(username, password, country_code)
|
||||
return picnic.session.auth_token, picnic.get_user()
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data):
|
||||
"""Validate the user input allows us to connect.
|
||||
|
||||
Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user.
|
||||
"""
|
||||
hub = PicnicHub()
|
||||
|
||||
try:
|
||||
auth_token, user_data = await hass.async_add_executor_job(
|
||||
hub.authenticate,
|
||||
data[CONF_USERNAME],
|
||||
data[CONF_PASSWORD],
|
||||
data[CONF_COUNTRY_CODE],
|
||||
)
|
||||
except requests.exceptions.ConnectionError as error:
|
||||
raise CannotConnect from error
|
||||
except PicnicAuthError as error:
|
||||
raise InvalidAuth from error
|
||||
|
||||
# Return the validation result
|
||||
address = (
|
||||
f"{user_data['address']['street']} {user_data['address']['house_number']}"
|
||||
f"{user_data['address']['house_number_ext']}"
|
||||
)
|
||||
return auth_token, {
|
||||
"title": address,
|
||||
"unique_id": user_data["user_id"],
|
||||
STEP_2FA_CHANNEL_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_2FA_CHANNEL, default=TWO_FA_CHANNELS[0]): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=TWO_FA_CHANNELS,
|
||||
mode=SelectSelectorMode.LIST,
|
||||
translation_key="two_fa_channel",
|
||||
)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
STEP_2FA_SCHEMA = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_2FA_CODE): str,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class PicnicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
@@ -81,6 +69,11 @@ class PicnicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the config flow."""
|
||||
self._picnic: PicnicAPI | None = None
|
||||
self._user_input: dict[str, Any] = {}
|
||||
|
||||
async def async_step_reauth(
|
||||
self, entry_data: Mapping[str, Any]
|
||||
) -> ConfigFlowResult:
|
||||
@@ -90,7 +83,7 @@ class PicnicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the authentication step, this is the generic step for both `step_user` and `step_reauth`."""
|
||||
"""Handle the authentication step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
|
||||
@@ -99,43 +92,122 @@ class PicnicConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
auth_token, info = await validate_input(self.hass, user_input)
|
||||
except CannotConnect:
|
||||
await self.hass.async_add_executor_job(
|
||||
self._start_login,
|
||||
user_input[CONF_USERNAME],
|
||||
user_input[CONF_PASSWORD],
|
||||
user_input[CONF_COUNTRY_CODE],
|
||||
)
|
||||
except Picnic2FARequired:
|
||||
self._user_input = user_input
|
||||
return await self.async_step_2fa_channel()
|
||||
except requests.exceptions.ConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except InvalidAuth:
|
||||
except PicnicAuthError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
data = {
|
||||
CONF_ACCESS_TOKEN: auth_token,
|
||||
CONF_COUNTRY_CODE: user_input[CONF_COUNTRY_CODE],
|
||||
}
|
||||
existing_entry = await self.async_set_unique_id(info["unique_id"])
|
||||
|
||||
# Abort if we're adding a new config and the unique id is already in use, else create the entry
|
||||
if self.source != SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title="Picnic", data=data)
|
||||
|
||||
# In case of re-auth, only continue if an exiting account exists with the same unique id
|
||||
if existing_entry:
|
||||
self.hass.config_entries.async_update_entry(existing_entry, data=data)
|
||||
await self.hass.config_entries.async_reload(existing_entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
# Set the error because the account is different
|
||||
errors["base"] = "different_account"
|
||||
return await self._async_finish(user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
def _start_login(self, username: str, password: str, country_code: str) -> None:
|
||||
self._picnic = PicnicAPI(country_code=country_code)
|
||||
self._picnic.login(username, password)
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
async def async_step_2fa_channel(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Let the user pick the 2FA delivery channel."""
|
||||
assert self._picnic is not None
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="2fa_channel", data_schema=STEP_2FA_CHANNEL_SCHEMA
|
||||
)
|
||||
|
||||
class InvalidAuth(HomeAssistantError):
|
||||
"""Error to indicate there is invalid auth."""
|
||||
errors = {}
|
||||
channel = user_input[CONF_2FA_CHANNEL].upper()
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self._picnic.generate_2fa_code, channel
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Failed to request 2FA code via %s", channel)
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self.async_step_2fa()
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="2fa_channel",
|
||||
data_schema=STEP_2FA_CHANNEL_SCHEMA,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
async def async_step_2fa(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the 2FA verification step."""
|
||||
assert self._picnic is not None
|
||||
|
||||
if user_input is None:
|
||||
return self.async_show_form(step_id="2fa", data_schema=STEP_2FA_SCHEMA)
|
||||
|
||||
errors = {}
|
||||
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
self._picnic.verify_2fa_code, user_input[CONF_2FA_CODE]
|
||||
)
|
||||
except Picnic2FAError:
|
||||
errors["base"] = "invalid_2fa_code"
|
||||
except requests.exceptions.ConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception during 2FA verification")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return await self._async_finish(self._user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="2fa", data_schema=STEP_2FA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
async def _async_finish(
|
||||
self,
|
||||
user_input: dict[str, Any],
|
||||
) -> ConfigFlowResult:
|
||||
"""Finalize the config entry after successful authentication."""
|
||||
assert self._picnic is not None
|
||||
|
||||
auth_token = self._picnic.session.auth_token
|
||||
user_data = await self.hass.async_add_executor_job(self._picnic.get_user)
|
||||
|
||||
data = {
|
||||
CONF_ACCESS_TOKEN: auth_token,
|
||||
CONF_COUNTRY_CODE: user_input[CONF_COUNTRY_CODE],
|
||||
}
|
||||
existing_entry = await self.async_set_unique_id(user_data["user_id"])
|
||||
|
||||
# Abort if we're adding a new config and the unique id is already in use, else create the entry
|
||||
if self.source != SOURCE_REAUTH:
|
||||
self._abort_if_unique_id_configured()
|
||||
return self.async_create_entry(title="Picnic", data=data)
|
||||
|
||||
# In case of re-auth, only continue if an exiting account exists with the same unique id
|
||||
if existing_entry:
|
||||
self.hass.config_entries.async_update_entry(existing_entry, data=data)
|
||||
await self.hass.config_entries.async_reload(existing_entry.entry_id)
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=STEP_USER_DATA_SCHEMA,
|
||||
errors={"base": "different_account"},
|
||||
)
|
||||
|
||||
@@ -12,6 +12,7 @@ ATTR_AMOUNT = "amount"
|
||||
ATTR_PRODUCT_IDENTIFIERS = "product_identifiers"
|
||||
|
||||
COUNTRY_CODES = ["NL", "DE", "BE", "FR"]
|
||||
TWO_FA_CHANNELS = ["sms", "email"]
|
||||
ATTRIBUTION = "Data provided by Picnic"
|
||||
ADDRESS = "address"
|
||||
CART_DATA = "cart_data"
|
||||
|
||||
@@ -7,10 +7,25 @@
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"different_account": "Account should be the same as used for setting up the integration",
|
||||
"invalid_2fa_code": "The verification code is incorrect or has expired.",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]"
|
||||
},
|
||||
"step": {
|
||||
"2fa": {
|
||||
"data": {
|
||||
"two_fa_code": "Verification code"
|
||||
},
|
||||
"description": "A verification code has been sent to you via your selected channel.",
|
||||
"title": "Two-factor authentication"
|
||||
},
|
||||
"2fa_channel": {
|
||||
"data": {
|
||||
"two_fa_channel": "Channel"
|
||||
},
|
||||
"description": "A second factor is required to complete the login. Select the channel through which you want to receive your second factor.",
|
||||
"title": "Two-factor authentication"
|
||||
},
|
||||
"user": {
|
||||
"data": {
|
||||
"country_code": "Country code",
|
||||
@@ -77,6 +92,14 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"two_fa_channel": {
|
||||
"options": {
|
||||
"email": "Email",
|
||||
"sms": "Text message (SMS)"
|
||||
}
|
||||
}
|
||||
},
|
||||
"services": {
|
||||
"add_product": {
|
||||
"description": "Adds a product to the cart based on a search string or product ID. The search string and product ID are exclusive.",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"domain": "pilight",
|
||||
"name": "Pilight",
|
||||
"codeowners": [],
|
||||
"disabled": "Pilight relies on setuptools.pkg_resources, which is no longer available in setuptools 82.0.0 and later.",
|
||||
"documentation": "https://www.home-assistant.io/integrations/pilight",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["pilight"],
|
||||
|
||||
@@ -8,8 +8,6 @@ from typing import Any
|
||||
from aioraven.data import MeterType
|
||||
from aioraven.device import RAVEnConnectionError
|
||||
from aioraven.serial import RAVEnSerialDevice
|
||||
import serial.tools.list_ports
|
||||
from serial.tools.list_ports_common import ListPortInfo
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
@@ -25,16 +23,19 @@ from homeassistant.helpers.service_info.usb import UsbServiceInfo
|
||||
from .const import DEFAULT_NAME, DOMAIN
|
||||
|
||||
|
||||
def _format_id(value: str | int) -> str:
|
||||
def _format_id(value: str | int | None) -> str:
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return f"{value or 0:04X}"
|
||||
|
||||
|
||||
def _generate_unique_id(info: ListPortInfo | UsbServiceInfo) -> str:
|
||||
def _generate_unique_id(info: usb.USBDevice | usb.SerialDevice | UsbServiceInfo) -> str:
|
||||
"""Generate unique id from usb attributes."""
|
||||
vid = info.vid if isinstance(info, (usb.USBDevice, UsbServiceInfo)) else None
|
||||
pid = info.pid if isinstance(info, (usb.USBDevice, UsbServiceInfo)) else None
|
||||
|
||||
return (
|
||||
f"{_format_id(info.vid)}:{_format_id(info.pid)}_{info.serial_number}"
|
||||
f"{_format_id(vid)}:{_format_id(pid)}_{info.serial_number}"
|
||||
f"_{info.manufacturer}_{info.description}"
|
||||
)
|
||||
|
||||
@@ -101,8 +102,7 @@ class RainforestRavenConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
|
||||
"""Handle USB Discovery."""
|
||||
device = discovery_info.device
|
||||
dev_path = await self.hass.async_add_executor_job(usb.get_serial_by_id, device)
|
||||
dev_path = discovery_info.device
|
||||
unique_id = _generate_unique_id(discovery_info)
|
||||
await self.async_set_unique_id(unique_id)
|
||||
try:
|
||||
@@ -119,31 +119,29 @@ class RainforestRavenConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle a flow initiated by the user."""
|
||||
if self._async_in_progress():
|
||||
return self.async_abort(reason="already_in_progress")
|
||||
ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
|
||||
ports = await usb.async_scan_serial_ports(self.hass)
|
||||
existing_devices = [
|
||||
entry.data[CONF_DEVICE] for entry in self._async_current_entries()
|
||||
]
|
||||
unused_ports = [
|
||||
port_map = {
|
||||
usb.human_readable_device_name(
|
||||
port.device,
|
||||
port.serial_number,
|
||||
port.manufacturer,
|
||||
port.description,
|
||||
port.vid,
|
||||
port.pid,
|
||||
)
|
||||
port.vid if isinstance(port, usb.USBDevice) else None,
|
||||
port.pid if isinstance(port, usb.USBDevice) else None,
|
||||
): port
|
||||
for port in ports
|
||||
if port.device not in existing_devices
|
||||
]
|
||||
if not unused_ports:
|
||||
}
|
||||
if not port_map:
|
||||
return self.async_abort(reason="no_devices_found")
|
||||
|
||||
errors = {}
|
||||
if user_input is not None and user_input.get(CONF_DEVICE, "").strip():
|
||||
port = ports[unused_ports.index(str(user_input[CONF_DEVICE]))]
|
||||
dev_path = await self.hass.async_add_executor_job(
|
||||
usb.get_serial_by_id, port.device
|
||||
)
|
||||
port = port_map[user_input[CONF_DEVICE]]
|
||||
dev_path = port.device
|
||||
unique_id = _generate_unique_id(port)
|
||||
await self.async_set_unique_id(unique_id)
|
||||
try:
|
||||
@@ -155,5 +153,5 @@ class RainforestRavenConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
else:
|
||||
return await self.async_step_meters()
|
||||
|
||||
schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(unused_ports)})
|
||||
schema = vol.Schema({vol.Required(CONF_DEVICE): vol.In(list(port_map))})
|
||||
return self.async_show_form(step_id="user", data_schema=schema, errors=errors)
|
||||
|
||||
@@ -57,6 +57,7 @@ from homeassistant.util.unit_conversion import (
|
||||
ElectricPotentialConverter,
|
||||
EnergyConverter,
|
||||
EnergyDistanceConverter,
|
||||
FrequencyConverter,
|
||||
InformationConverter,
|
||||
MassConverter,
|
||||
MassVolumeConcentrationConverter,
|
||||
@@ -214,6 +215,7 @@ _PRIMARY_UNIT_CONVERTERS: list[type[BaseUnitConverter]] = [
|
||||
ElectricPotentialConverter,
|
||||
EnergyConverter,
|
||||
EnergyDistanceConverter,
|
||||
FrequencyConverter,
|
||||
InformationConverter,
|
||||
MassConverter,
|
||||
MassVolumeConcentrationConverter,
|
||||
|
||||
@@ -30,6 +30,7 @@ from homeassistant.util.unit_conversion import (
|
||||
ElectricPotentialConverter,
|
||||
EnergyConverter,
|
||||
EnergyDistanceConverter,
|
||||
FrequencyConverter,
|
||||
InformationConverter,
|
||||
MassConverter,
|
||||
MassVolumeConcentrationConverter,
|
||||
@@ -90,6 +91,7 @@ UNIT_SCHEMA = vol.Schema(
|
||||
vol.Optional("electric_current"): vol.In(ElectricCurrentConverter.VALID_UNITS),
|
||||
vol.Optional("energy"): vol.In(EnergyConverter.VALID_UNITS),
|
||||
vol.Optional("energy_distance"): vol.In(EnergyDistanceConverter.VALID_UNITS),
|
||||
vol.Optional("frequency"): vol.In(FrequencyConverter.VALID_UNITS),
|
||||
vol.Optional("information"): vol.In(InformationConverter.VALID_UNITS),
|
||||
vol.Optional("mass"): vol.In(MassConverter.VALID_UNITS),
|
||||
vol.Optional("nitrogen_dioxide"): vol.In(
|
||||
|
||||
@@ -86,6 +86,7 @@ async def _set_charge_limits(
|
||||
|
||||
entity.coordinator.data.socMin = min_soc
|
||||
entity.coordinator.data.socTarget = target_soc
|
||||
entity.coordinator.assumed_state = True
|
||||
entity.coordinator.async_set_updated_data(entity.coordinator.data)
|
||||
|
||||
|
||||
|
||||
@@ -6,14 +6,12 @@ import asyncio
|
||||
from contextlib import suppress
|
||||
import copy
|
||||
import itertools
|
||||
import os
|
||||
from typing import Any, TypedDict, cast
|
||||
|
||||
import RFXtrx as rfxtrxmod
|
||||
import serial
|
||||
import serial.tools.list_ports
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components import usb
|
||||
from homeassistant.config_entries import (
|
||||
ConfigEntry,
|
||||
ConfigFlow,
|
||||
@@ -556,9 +554,7 @@ class RfxtrxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if user_selection == CONF_MANUAL_PATH:
|
||||
return await self.async_step_setup_serial_manual_path()
|
||||
|
||||
dev_path = await self.hass.async_add_executor_job(
|
||||
get_serial_by_id, user_selection
|
||||
)
|
||||
dev_path = user_selection
|
||||
|
||||
try:
|
||||
data = await self.async_validate_rfx(device=dev_path)
|
||||
@@ -568,11 +564,12 @@ class RfxtrxConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
if not errors:
|
||||
return self.async_create_entry(title="RFXTRX", data=data)
|
||||
|
||||
ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
|
||||
ports = await usb.async_scan_serial_ports(self.hass)
|
||||
list_of_ports = {}
|
||||
for port in ports:
|
||||
list_of_ports[port.device] = (
|
||||
f"{port}, s/n: {port.serial_number or 'n/a'}"
|
||||
f"{port.device} - {port.description or 'n/a'}"
|
||||
f", s/n: {port.serial_number or 'n/a'}"
|
||||
+ (f" - {port.manufacturer}" if port.manufacturer else "")
|
||||
)
|
||||
list_of_ports[CONF_MANUAL_PATH] = CONF_MANUAL_PATH
|
||||
@@ -653,17 +650,5 @@ def _test_transport(host: str | None, port: int | None, device: str | None) -> b
|
||||
return True
|
||||
|
||||
|
||||
def get_serial_by_id(dev_path: str) -> str:
|
||||
"""Return a /dev/serial/by-id match for given device if available."""
|
||||
by_id = "/dev/serial/by-id"
|
||||
if not os.path.isdir(by_id):
|
||||
return dev_path
|
||||
|
||||
for path in (entry.path for entry in os.scandir(by_id) if entry.is_symlink()):
|
||||
if os.path.realpath(path) == dev_path:
|
||||
return path
|
||||
return dev_path
|
||||
|
||||
|
||||
class CannotConnect(HomeAssistantError):
|
||||
"""Error to indicate we cannot connect."""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user