Compare commits

...

79 Commits

Author SHA1 Message Date
Stefan Agner
8474aa0d51 Update encrypted backup streamer test for SecureTar v3
SecureTar v3 uses libsodium's crypto_secretstream which generates
random headers internally (not through os.urandom), making encrypted
output non-deterministic. Replace the fixture byte-comparison approach
with an encrypt-then-decrypt round-trip test that verifies output size,
padding, and tar content integrity.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-23 18:06:13 +01:00
Stefan Agner
d46723c443 Make SecureTar v3 the default for backup creation
Update SECURETAR_CREATE_VERSION from 2 to 3 to use the latest
SecureTar format when creating backups.
2026-03-23 15:41:48 +01:00
Erik Montnemery
e151c9c78c Adjust temperature trigger translations (#166260) 2026-03-23 14:52:30 +01:00
Erik Montnemery
7287c847f4 Remove redundant humidity trigger test (#166257) 2026-03-23 14:31:24 +01:00
Petro31
152e17aee7 Update state template framework to support options other than state (#162737) 2026-03-23 14:26:21 +01:00
Petro31
c53adcb73b Correct validation of scripts in template entities (#165226) 2026-03-23 14:08:11 +01:00
Abílio Costa
dab4a72128 Add copilot-specific instructions (#166254)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-23 13:06:48 +00:00
hanwg
c94e10efa7 Improve subentry error handling for Telegram bot (#165863)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-23 13:46:13 +01:00
Bram Kragten
ca5ea9ea35 Update frontend to 20260312.1 (#166251) 2026-03-23 13:38:04 +01:00
Mike Degatano
63a09d8e28 Replace calls to set options in Supervisor with aiohasupervisor (#165872)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-23 13:18:37 +01:00
Martin Hjelmare
b5a3c2c014 Fix trane for Python 3.14.3 (#166252) 2026-03-23 13:17:49 +01:00
puddly
ef887c8edc Use device-specific firmware flashers for Yellow/ZBT-1/ZBT-2 (#164695) 2026-03-23 13:01:10 +01:00
epenet
d0eb90274d Cleanup deprecated YAML import from vera (#165659) 2026-03-23 13:00:20 +01:00
Paulus Schoutsen
cac375dafb Only start Assist Pipeline debug thread when capturing audio (#166190) 2026-03-23 12:46:23 +01:00
AlCalzone
2c20b62229 Create repair issue for legacy Z-Wave Door state sensors that are still in use (#165363)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-23 12:00:47 +01:00
Tommy Goode
b5c84b6b7a Fix zwave_js fan speed mapping for GE/Jasco Enbrighten 55258 / ZW4002 (#166169) 2026-03-23 11:55:46 +01:00
Raphael Hehl
e5f9668ded Bump py-unifi-access to 1.1.3 (#166177)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-23 11:54:03 +01:00
Martin Hjelmare
e214ce690a Refactor Z-Wave discovery schemas for sensor platform (#165254)
Co-authored-by: AlCalzone <d.griesel@gmx.net>
2026-03-23 11:43:30 +01:00
Erik Montnemery
a2c64f65e1 Add support for input_boolean to switch triggers (#166242) 2026-03-23 11:42:29 +01:00
Matrix
8bad30234a Add YoLink YS7A06 support (#165987) 2026-03-23 11:19:27 +01:00
dependabot[bot]
c4545b42d8 Bump dorny/paths-filter from 3.0.2 to 4.0.1 (#166237)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 11:08:25 +01:00
Eli Sand
b0a60d1c42 Fixes generic_thermostat config flow validation (#165680) 2026-03-23 10:18:45 +01:00
Norbert Rittel
e1e14bee10 Clarify description of motion_blinds.set_absolute_position action (#166243) 2026-03-23 10:10:10 +01:00
Erik Montnemery
3529aff4b1 Revert "Add turned off and turned on triggers to input boolean (#158824)" (#166240) 2026-03-23 08:46:03 +01:00
Matrix
16e314ccf1 Bump yolink-api to 0.6.3 (#166232) 2026-03-23 08:34:37 +01:00
Erik Montnemery
d634fbcad7 Add unit of measurement handling to numeric climate triggers (#166211) 2026-03-23 08:29:01 +01:00
Ray Xue
b84ca80d55 Add Linptech PS1BB pressure sensor support to xiaomi_ble (#166095) 2026-03-23 00:21:28 +01:00
David Bonnes
41c2c621f0 Bump evohome-async to 1.2.0 (#166227) 2026-03-23 00:10:38 +01:00
Peter Grauvogel
b230e62868 Bump greenplanet-energy-api from 0.1.4 to 0.1.10 (#166217) 2026-03-22 22:08:06 +01:00
Ludovic BOUÉ
12528ec128 Update python-roborock to 5.0.0 (#166219) 2026-03-22 13:38:31 -07:00
Manu
7f4a7670a2 Bump pyrate-limiter to 4.1.0 (#166221) 2026-03-22 13:38:19 -07:00
Erwin Douna
9bdc1b777e Add async_setup and yarl to Immich coordinator (#165900)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-22 21:36:16 +01:00
Stathogon
995e982d7f Add shutdown button for VMs in ProxmoxVE (#165890) 2026-03-22 21:29:59 +01:00
MarkGodwin
b92698e3d5 Bump tplink-omada-client to fix breaking changes in Omada API (#166206) 2026-03-22 19:44:38 +01:00
Ludovic BOUÉ
225052b932 feat(roborock): Remove unnecessary type check for Q10 update coordinator in button setup (#166214) 2026-03-22 19:42:18 +01:00
Raphael Hehl
34ae51677f Add a reauthentication flow to the UniFi Access integration (#165859)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-22 18:09:46 +01:00
Erik Montnemery
3616a52b37 Add temperature triggers (#165247) 2026-03-22 15:24:53 +01:00
Ludovic BOUÉ
0128372258 Update python-roborock to 4.26.3 (#166178) 2026-03-22 14:01:23 +01:00
EnjoyingM
21863cd9d7 Bump wolf_comm to 0.0.48 (#166144) 2026-03-22 10:27:18 +01:00
Sean O'Keeffe
d67caec5c1 Add additional miele oven programs (#166100) 2026-03-22 09:04:07 +01:00
J. Nick Koston
8286014ae1 Bump habluetooth to 5.11.1 (#166161) 2026-03-21 18:22:53 -10:00
J. Nick Koston
1ff8d2279a Bump oralb-ble to 1.1.0 (#166165) 2026-03-21 18:22:21 -10:00
Ludovic BOUÉ
5dcbc1d5d9 feat(roborock): Add Q10 empty dustbin button entity (#166149) 2026-03-22 00:36:43 +01:00
Ludovic BOUÉ
3068653cc7 Update python-roborock to 4.26.2 (#166152) 2026-03-21 23:44:02 +01:00
Paulus Schoutsen
61b1a45889 Add logger to OpenDisplay (#166146) 2026-03-21 22:30:01 +01:00
Ray Xue
573d4eba02 Bump xiaomi-ble to 1.10.0 (#166099) 2026-03-21 20:40:54 +01:00
Ludovic BOUÉ
09895aa601 Update python-roborock to 4.26.1 (#166138) 2026-03-21 20:25:24 +01:00
Joost Lekkerkerker
aa6a4c7eab Add binary sensor for stick cleaner status to SmartThings (#166122) 2026-03-21 20:24:53 +01:00
Michael
662c44b125 Fix reload of FRITZ!Box Tools in case of connection issues (#166111) 2026-03-21 20:24:21 +01:00
Josef Zweck
5a80087cf4 Bump aiotedee to 0.2.27 (#166101)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-21 20:23:15 +01:00
TimL
c28dc32168 Add PSRAM sensor for SMLIGHT integration (#166104) 2026-03-21 20:20:33 +01:00
Matthias Alphart
eef3472c43 KNX: Clean up internal setting of name, unique_id and entity_category for YAML entities (#160265) 2026-03-21 20:12:10 +01:00
tronikos
f9bd9f4982 Add diagnostics in Google Weather (#166105) 2026-03-21 18:43:45 +01:00
Jack Boswell
e4620a208d Update starlink-grpc-core to 1.2.4 (#165882) 2026-03-21 18:30:04 +01:00
Ingmar Stein
c6c5661b4b Add Identify button to Velux integration (#163893)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:20:02 +01:00
Joost Lekkerkerker
d0154e5019 Add stick cleaner fixture to SmartThings (#166121) 2026-03-21 16:57:26 +01:00
Joost Lekkerkerker
16fb7ed21e Bump TRMNL to platinum (#166066) 2026-03-21 06:49:50 +01:00
tronikos
d0a751abe4 Bump python-google-weather-api to 0.0.6 (#166085) 2026-03-20 17:02:22 -07:00
J. Nick Koston
a04b168a19 Bump aioesphomeapi to 44.6.2 (#166080) 2026-03-20 22:53:06 +01:00
Tom
e9576452b2 Improve ProxmoxVE permissions validation (#164770) 2026-03-20 20:41:31 +01:00
Alex Merkel
c8c6815efd LG Soundbar: Fix incorrect state and outdated track information (#165148) 2026-03-20 20:40:12 +01:00
Joost Lekkerkerker
60ef69c21d Don't create fridge setpoint if no range in SmartThings (#166018) 2026-03-20 20:38:38 +01:00
Allen Porter
d5b7792208 Add Roborock Q10 vacuum support (#165624)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-20 18:03:16 +01:00
Michael
fdfc2f4845 Fix FRITZ!Box Tools "the test opens sockets" issue (#165596) 2026-03-20 17:43:42 +01:00
Michael
184d834a91 Fix enable/disable device tracking feature during setup of FRITZ!Box Tools (#166027) 2026-03-20 17:29:33 +01:00
mettolen
0c98bf2676 Implement stale devices and update Liebherr to gold (#164666) 2026-03-20 16:31:09 +01:00
mettolen
229e1ee26b Pump pyliebherrhomeapi to 0.4.0 (#165973) 2026-03-20 16:02:49 +01:00
TimL
fdd2db6f23 Bump Pysmlight 0.3.1 (#166060) 2026-03-20 15:54:03 +01:00
TimL
2886863000 Properly handle buttons of SMLIGHT SLZB-MRxU devices (#166058) 2026-03-20 15:44:59 +01:00
Renat Sibgatulin
bf4170938c Add diagnostics platform to air-Q integration (#166065)
Co-authored-by: Claw <claw@theeggeadventure.com>
2026-03-20 15:25:27 +01:00
Mike O'Driscoll
6b84815c57 Add Casper Glow integration (#164536)
Signed-off-by: Mike O'Driscoll <mike@unusedbytes.ca>
2026-03-20 15:21:07 +01:00
aryanhasgithub
01b873f3bc Add Lichess Integration (#166051) 2026-03-20 12:35:51 +01:00
mettolen
66b1728c13 Implement reauth for Huum integration (#165971) 2026-03-20 10:03:23 +01:00
Erik Montnemery
d11668b868 Remove useless string split from mqtt diagnostics (#166035) 2026-03-20 09:51:59 +01:00
tronikos
ed3f70bc3f Bump androidtvremote2 to 0.3.1 (#166045) 2026-03-20 08:15:04 +01:00
tronikos
008eb39c3b Bump opower to 0.17.1 (#166044) 2026-03-20 08:14:22 +01:00
Erik Montnemery
a085d91a0d Remove useless string split from triggers (#166034) 2026-03-20 07:56:55 +01:00
Logan Rosen
6395a0abd0 Reject entity/number price for external statistics in energy config (#165582)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-20 08:34:40 +02:00
Erwin Douna
0de2e689f1 Add pause/resume buttons to Portainer (#166028) 2026-03-19 22:35:53 +01:00
290 changed files with 13992 additions and 1889 deletions

View File

@@ -1,6 +1,12 @@
<!-- Automatically generated by gen_copilot_instructions.py, do not edit -->
# Copilot code review instructions
- Start review comments with a short, one-sentence summary of the suggested fix.
- Do not add comments about code style, formatting or linting issues.
# GitHub Copilot & Claude Code Instructions
This repository contains the core of Home Assistant, a Python 3 based home automation application.

View File

@@ -120,7 +120,7 @@ jobs:
run: |
echo "key=$(lsb_release -rs)-apt-${CACHE_VERSION}-${HA_SHORT_VERSION}" >> $GITHUB_OUTPUT
- name: Filter for core changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: core
with:
filters: .core_files.yaml
@@ -135,7 +135,7 @@ jobs:
echo "Result:"
cat .integration_paths.yaml
- name: Filter for integration changes
uses: dorny/paths-filter@de90cc6fb38fc0963ad72b210f1f284cd68cea36 # v3.0.2
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
id: integrations
with:
filters: .integration_paths.yaml

View File

@@ -137,6 +137,7 @@ homeassistant.components.calendar.*
homeassistant.components.cambridge_audio.*
homeassistant.components.camera.*
homeassistant.components.canary.*
homeassistant.components.casper_glow.*
homeassistant.components.cert_expiry.*
homeassistant.components.clickatell.*
homeassistant.components.clicksend.*

6
CODEOWNERS generated
View File

@@ -273,6 +273,8 @@ build.json @home-assistant/supervisor
/tests/components/cambridge_audio/ @noahhusby
/homeassistant/components/camera/ @home-assistant/core
/tests/components/camera/ @home-assistant/core
/homeassistant/components/casper_glow/ @mikeodr
/tests/components/casper_glow/ @mikeodr
/homeassistant/components/cast/ @emontnemery
/tests/components/cast/ @emontnemery
/homeassistant/components/ccm15/ @ocalvo
@@ -947,6 +949,8 @@ build.json @home-assistant/supervisor
/tests/components/lg_thinq/ @LG-ThinQ-Integration
/homeassistant/components/libre_hardware_monitor/ @Sab44
/tests/components/libre_hardware_monitor/ @Sab44
/homeassistant/components/lichess/ @aryanhasgithub
/tests/components/lichess/ @aryanhasgithub
/homeassistant/components/lidarr/ @tkdrob
/tests/components/lidarr/ @tkdrob
/homeassistant/components/liebherr/ @mettolen
@@ -1699,6 +1703,8 @@ build.json @home-assistant/supervisor
/tests/components/tellduslive/ @fredrike
/homeassistant/components/teltonika/ @karlbeecken
/tests/components/teltonika/ @karlbeecken
/homeassistant/components/temperature/ @home-assistant/core
/tests/components/temperature/ @home-assistant/core
/homeassistant/components/template/ @Petro31 @home-assistant/core
/tests/components/template/ @Petro31 @home-assistant/core
/homeassistant/components/tesla_fleet/ @Bre77

View File

@@ -247,6 +247,7 @@ DEFAULT_INTEGRATIONS = {
"humidity",
"motion",
"occupancy",
"temperature",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {

View File

@@ -0,0 +1,36 @@
"""Diagnostics support for air-Q."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_IP_ADDRESS, CONF_PASSWORD, CONF_UNIQUE_ID
from homeassistant.core import HomeAssistant
from . import AirQConfigEntry
REDACT_CONFIG = {CONF_PASSWORD, CONF_UNIQUE_ID, CONF_IP_ADDRESS, "title"}
REDACT_DEVICE_INFO = {"identifiers", "name"}
REDACT_COORDINATOR_DATA = {"DeviceID"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: AirQConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
return {
"config_entry": async_redact_data(entry.as_dict(), REDACT_CONFIG),
"device_info": async_redact_data(
dict(coordinator.device_info), REDACT_DEVICE_INFO
),
"coordinator_data": async_redact_data(
coordinator.data, REDACT_COORDINATOR_DATA
),
"options": {
"clip_negative": coordinator.clip_negative,
"return_average": coordinator.return_average,
},
}

View File

@@ -8,6 +8,6 @@
"iot_class": "local_push",
"loggers": ["androidtvremote2"],
"quality_scale": "platinum",
"requirements": ["androidtvremote2==0.2.3"],
"requirements": ["androidtvremote2==0.3.1"],
"zeroconf": ["_androidtvremote2._tcp.local."]
}

View File

@@ -662,7 +662,8 @@ class PipelineRun:
"""Emit run start event."""
self._device_id = device_id
self._satellite_id = satellite_id
self._start_debug_recording_thread()
if self.start_stage in (PipelineStage.WAKE_WORD, PipelineStage.STT):
self._start_debug_recording_thread()
data: dict[str, Any] = {
"pipeline": self.pipeline.id,
@@ -1504,9 +1505,7 @@ class PipelineRun:
def _start_debug_recording_thread(self) -> None:
"""Start thread to record wake/stt audio if debug_recording_dir is set."""
if self.debug_recording_thread is not None:
# Already started
return
assert self.debug_recording_thread is None
# Directory to save audio for each pipeline run.
# Configured in YAML for assist_pipeline.

View File

@@ -155,7 +155,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"gate",
"humidifier",
"humidity",
"input_boolean",
"lawn_mower",
"light",
"lock",
@@ -169,6 +168,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"select",
"siren",
"switch",
"temperature",
"text",
"update",
"vacuum",

View File

@@ -34,4 +34,4 @@ EXCLUDE_DATABASE_FROM_BACKUP = [
"home-assistant_v2.db-wal",
]
SECURETAR_CREATE_VERSION = 2
SECURETAR_CREATE_VERSION = 3

View File

@@ -21,6 +21,6 @@
"bluetooth-auto-recovery==1.5.3",
"bluetooth-data-tools==1.28.4",
"dbus-fast==3.1.2",
"habluetooth==5.10.2"
"habluetooth==5.11.1"
]
}

View File

@@ -0,0 +1,39 @@
"""The Casper Glow integration."""
from __future__ import annotations
from pycasperglow import CasperGlow
from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
PLATFORMS: list[Platform] = [Platform.LIGHT]
async def async_setup_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:
"""Set up Casper Glow from a config entry."""
address: str = entry.data[CONF_ADDRESS]
ble_device = bluetooth.async_ble_device_from_address(hass, address.upper(), True)
if not ble_device:
raise ConfigEntryNotReady(
f"Could not find Casper Glow device with address {address}"
)
glow = CasperGlow(ble_device)
coordinator = CasperGlowCoordinator(hass, glow, entry.title)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
entry.async_on_unload(coordinator.async_start())
return True
async def async_unload_entry(hass: HomeAssistant, entry: CasperGlowConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,151 @@
"""Config flow for Casper Glow integration."""
from __future__ import annotations
import logging
from typing import Any
from bluetooth_data_tools import human_readable_name
from pycasperglow import CasperGlow, CasperGlowError
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ADDRESS
from homeassistant.helpers.device_registry import format_mac
from .const import DOMAIN, LOCAL_NAMES
_LOGGER = logging.getLogger(__name__)
class CasperGlowConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Casper Glow."""
VERSION = 1
MINOR_VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovery_info: BluetoothServiceInfoBleak | None = None
self._discovered_devices: dict[str, BluetoothServiceInfoBleak] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
await self.async_set_unique_id(format_mac(discovery_info.address))
self._abort_if_unique_id_configured()
self._discovery_info = discovery_info
self.context["title_placeholders"] = {
"name": human_readable_name(
None, discovery_info.name, discovery_info.address
)
}
return await self.async_step_bluetooth_confirm()
async def async_step_bluetooth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm a discovered Casper Glow device."""
assert self._discovery_info is not None
if user_input is not None:
return self.async_create_entry(
title=self.context["title_placeholders"]["name"],
data={CONF_ADDRESS: self._discovery_info.address},
)
glow = CasperGlow(self._discovery_info.device)
try:
await glow.handshake()
except CasperGlowError:
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception(
"Unexpected error during Casper Glow config flow "
"(step=bluetooth_confirm, address=%s)",
self._discovery_info.address,
)
return self.async_abort(reason="unknown")
self._set_confirm_only()
return self.async_show_form(
step_id="bluetooth_confirm",
description_placeholders=self.context["title_placeholders"],
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the user step to pick discovered device."""
errors: dict[str, str] = {}
if user_input is not None:
address = user_input[CONF_ADDRESS]
discovery_info = self._discovered_devices[address]
await self.async_set_unique_id(
format_mac(discovery_info.address), raise_on_progress=False
)
self._abort_if_unique_id_configured()
glow = CasperGlow(discovery_info.device)
try:
await glow.handshake()
except CasperGlowError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception(
"Unexpected error during Casper Glow config flow "
"(step=user, address=%s)",
discovery_info.address,
)
errors["base"] = "unknown"
else:
return self.async_create_entry(
title=human_readable_name(
None, discovery_info.name, discovery_info.address
),
data={
CONF_ADDRESS: discovery_info.address,
},
)
if discovery := self._discovery_info:
self._discovered_devices[discovery.address] = discovery
else:
current_addresses = self._async_current_ids(include_ignore=False)
for discovery in async_discovered_service_info(self.hass):
if (
format_mac(discovery.address) in current_addresses
or discovery.address in self._discovered_devices
or not (
discovery.name
and any(
discovery.name.startswith(local_name)
for local_name in LOCAL_NAMES
)
)
):
continue
self._discovered_devices[discovery.address] = discovery
if not self._discovered_devices:
return self.async_abort(reason="no_devices_found")
data_schema = vol.Schema(
{
vol.Required(CONF_ADDRESS): vol.In(
{
service_info.address: human_readable_name(
None, service_info.name, service_info.address
)
for service_info in self._discovered_devices.values()
}
),
}
)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
errors=errors,
)

View File

@@ -0,0 +1,16 @@
"""Constants for the Casper Glow integration."""
from datetime import timedelta
from pycasperglow import BRIGHTNESS_LEVELS, DEVICE_NAME_PREFIX, DIMMING_TIME_MINUTES
DOMAIN = "casper_glow"
LOCAL_NAMES = {DEVICE_NAME_PREFIX}
SORTED_BRIGHTNESS_LEVELS = sorted(BRIGHTNESS_LEVELS)
DEFAULT_DIMMING_TIME_MINUTES: int = DIMMING_TIME_MINUTES[0]
# Interval between periodic state polls to catch externally-triggered changes.
STATE_POLL_INTERVAL = timedelta(seconds=30)

View File

@@ -0,0 +1,103 @@
"""Coordinator for the Casper Glow integration."""
from __future__ import annotations
import logging
from bleak import BleakError
from bluetooth_data_tools import monotonic_time_coarse
from pycasperglow import CasperGlow
from homeassistant.components.bluetooth import (
BluetoothChange,
BluetoothScanningMode,
BluetoothServiceInfoBleak,
)
from homeassistant.components.bluetooth.active_update_coordinator import (
ActiveBluetoothDataUpdateCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from .const import STATE_POLL_INTERVAL
_LOGGER = logging.getLogger(__name__)
type CasperGlowConfigEntry = ConfigEntry[CasperGlowCoordinator]
class CasperGlowCoordinator(ActiveBluetoothDataUpdateCoordinator[None]):
"""Coordinator for Casper Glow BLE devices."""
def __init__(
self,
hass: HomeAssistant,
device: CasperGlow,
title: str,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass=hass,
logger=_LOGGER,
address=device.address,
mode=BluetoothScanningMode.PASSIVE,
needs_poll_method=self._needs_poll,
poll_method=self._async_update,
connectable=True,
)
self.device = device
self.last_dimming_time_minutes: int | None = (
device.state.configured_dimming_time_minutes
)
self.title = title
@callback
def _needs_poll(
self,
service_info: BluetoothServiceInfoBleak,
seconds_since_last_poll: float | None,
) -> bool:
"""Return True if a poll is needed."""
return (
seconds_since_last_poll is None
or seconds_since_last_poll >= STATE_POLL_INTERVAL.total_seconds()
)
async def _async_update(self, service_info: BluetoothServiceInfoBleak) -> None:
"""Poll device state."""
await self.device.query_state()
async def _async_poll(self) -> None:
"""Poll the device and log availability changes."""
assert self._last_service_info
try:
await self._async_poll_data(self._last_service_info)
except BleakError as exc:
if self.last_poll_successful:
_LOGGER.info("%s is unavailable: %s", self.title, exc)
self.last_poll_successful = False
return
except Exception:
if self.last_poll_successful:
_LOGGER.exception("%s: unexpected error while polling", self.title)
self.last_poll_successful = False
return
finally:
self._last_poll = monotonic_time_coarse()
if not self.last_poll_successful:
_LOGGER.info("%s is back online", self.title)
self.last_poll_successful = True
self._async_handle_bluetooth_poll()
@callback
def _async_handle_bluetooth_event(
self,
service_info: BluetoothServiceInfoBleak,
change: BluetoothChange,
) -> None:
"""Update BLE device reference on each advertisement."""
self.device.set_ble_device(service_info.device)
super()._async_handle_bluetooth_event(service_info, change)

View File

@@ -0,0 +1,47 @@
"""Base entity for the Casper Glow integration."""
from __future__ import annotations
from collections.abc import Awaitable
from pycasperglow import CasperGlowError
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,
)
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo, format_mac
from .const import DOMAIN
from .coordinator import CasperGlowCoordinator
class CasperGlowEntity(PassiveBluetoothCoordinatorEntity[CasperGlowCoordinator]):
"""Base class for Casper Glow entities."""
_attr_has_entity_name = True
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize a Casper Glow entity."""
super().__init__(coordinator)
self._device = coordinator.device
self._attr_device_info = DeviceInfo(
manufacturer="Casper",
model="Glow",
model_id="G01",
connections={
(dr.CONNECTION_BLUETOOTH, format_mac(coordinator.device.address))
},
)
async def _async_command(self, coro: Awaitable[None]) -> None:
"""Execute a device command."""
try:
await coro
except CasperGlowError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="communication_error",
translation_placeholders={"error": str(err)},
) from err

View File

@@ -0,0 +1,104 @@
"""Casper Glow integration light platform."""
from __future__ import annotations
from typing import Any
from pycasperglow import GlowState
from homeassistant.components.light import ATTR_BRIGHTNESS, ColorMode, LightEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
ordered_list_item_to_percentage,
percentage_to_ordered_list_item,
)
from .const import DEFAULT_DIMMING_TIME_MINUTES, SORTED_BRIGHTNESS_LEVELS
from .coordinator import CasperGlowConfigEntry, CasperGlowCoordinator
from .entity import CasperGlowEntity
PARALLEL_UPDATES = 1
def _ha_brightness_to_device_pct(brightness: int) -> int:
"""Convert HA brightness (1-255) to device percentage by snapping to nearest."""
return percentage_to_ordered_list_item(
SORTED_BRIGHTNESS_LEVELS, round(brightness * 100 / 255)
)
def _device_pct_to_ha_brightness(pct: int) -> int:
"""Convert device brightness percentage (60-100) to HA brightness (1-255)."""
percent = ordered_list_item_to_percentage(SORTED_BRIGHTNESS_LEVELS, pct)
return round(percent * 255 / 100)
async def async_setup_entry(
hass: HomeAssistant,
entry: CasperGlowConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the light platform for Casper Glow."""
async_add_entities([CasperGlowLight(entry.runtime_data)])
class CasperGlowLight(CasperGlowEntity, LightEntity):
"""Representation of a Casper Glow light."""
_attr_supported_color_modes = {ColorMode.BRIGHTNESS}
_attr_name = None
def __init__(self, coordinator: CasperGlowCoordinator) -> None:
"""Initialize a Casper Glow light."""
super().__init__(coordinator)
self._attr_unique_id = format_mac(coordinator.device.address)
self._update_from_state(coordinator.device.state)
async def async_added_to_hass(self) -> None:
"""Register state update callback when entity is added."""
await super().async_added_to_hass()
self.async_on_remove(
self._device.register_callback(self._async_handle_state_update)
)
@callback
def _update_from_state(self, state: GlowState) -> None:
"""Update entity attributes from device state."""
if state.is_on is not None:
self._attr_is_on = state.is_on
self._attr_color_mode = ColorMode.BRIGHTNESS
if state.brightness_level is not None:
self._attr_brightness = _device_pct_to_ha_brightness(state.brightness_level)
@callback
def _async_handle_state_update(self, state: GlowState) -> None:
"""Handle a state update from the device."""
self._update_from_state(state)
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the light on."""
brightness_pct: int | None = None
if ATTR_BRIGHTNESS in kwargs:
brightness_pct = _ha_brightness_to_device_pct(kwargs[ATTR_BRIGHTNESS])
await self._async_command(self._device.turn_on())
self._attr_is_on = True
self._attr_color_mode = ColorMode.BRIGHTNESS
if brightness_pct is not None:
await self._async_command(
self._device.set_brightness_and_dimming_time(
brightness_pct,
self.coordinator.last_dimming_time_minutes
if self.coordinator.last_dimming_time_minutes is not None
else DEFAULT_DIMMING_TIME_MINUTES,
)
)
self._attr_brightness = _device_pct_to_ha_brightness(brightness_pct)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the light off."""
await self._async_command(self._device.turn_off())
self._attr_is_on = False

View File

@@ -0,0 +1,19 @@
{
"domain": "casper_glow",
"name": "Casper Glow",
"bluetooth": [
{
"connectable": true,
"local_name": "Jar*"
}
],
"codeowners": ["@mikeodr"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/casper_glow",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["pycasperglow"],
"quality_scale": "bronze",
"requirements": ["pycasperglow==1.1.0"]
}

View File

@@ -0,0 +1,74 @@
rules:
# Bronze
action-setup:
status: exempt
comment: No custom services.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: No custom actions/services.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: No network discovery.
discovery: done
docs-data-update: done
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: todo
dynamic-devices: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations:
status: exempt
comment: No entity translations needed.
exception-translations:
status: exempt
comment: No custom services that raise exceptions.
icon-translations:
status: exempt
comment: No icon translations needed.
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession:
status: exempt
comment: No web session is used by this integration.
strict-typing: done

View File

@@ -0,0 +1,34 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"flow_title": "{name}",
"step": {
"bluetooth_confirm": {
"description": "Do you want to set up {name}?"
},
"user": {
"data": {
"address": "Bluetooth address"
},
"data_description": {
"address": "The Bluetooth address of the Casper Glow light"
}
}
}
},
"exceptions": {
"communication_error": {
"message": "An error occurred while communicating with the Casper Glow: {error}"
}
}
}

View File

@@ -462,6 +462,10 @@
"below": {
"description": "Trigger when the target temperature is below this value.",
"name": "Below"
},
"unit": {
"description": "All values will be converted to this unit when evaluating the trigger.",
"name": "Unit of measurement"
}
},
"name": "Climate-control device target temperature changed"
@@ -481,6 +485,10 @@
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"unit": {
"description": "[%key:component::climate::triggers::target_temperature_changed::fields::unit::description%]",
"name": "[%key:component::climate::triggers::target_temperature_changed::fields::unit::name%]"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"

View File

@@ -2,12 +2,15 @@
import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS
from homeassistant.core import HomeAssistant
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerWithUnitBase,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
@@ -16,6 +19,7 @@ from homeassistant.helpers.trigger import (
make_entity_target_state_trigger,
make_entity_transition_trigger,
)
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
@@ -44,6 +48,33 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
self._to_states = set(self._options[CONF_HVAC_MODE])
class _ClimateTargetTemperatureTriggerMixin(EntityNumericalStateTriggerWithUnitBase):
"""Mixin for climate target temperature triggers with unit conversion."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _get_entity_unit(self, state: State) -> str | None:
"""Get the temperature unit of a climate entity from its state."""
# Climate entities convert temperatures to the system unit via show_temp
return self._hass.config.units.temperature_unit
class ClimateTargetTemperatureChangedTrigger(
_ClimateTargetTemperatureTriggerMixin,
EntityNumericalStateChangedTriggerWithUnitBase,
):
"""Trigger for climate target temperature value changes."""
class ClimateTargetTemperatureCrossedThresholdTrigger(
_ClimateTargetTemperatureTriggerMixin,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
):
"""Trigger for climate target temperature value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_trigger(
@@ -53,17 +84,15 @@ TRIGGERS: dict[str, type[Trigger]] = {
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
),
"target_temperature_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
),
"target_temperature_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature_changed": ClimateTargetTemperatureChangedTrigger,
"target_temperature_crossed_threshold": ClimateTargetTemperatureCrossedThresholdTrigger,
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(
DOMAIN,

View File

@@ -14,7 +14,29 @@
- last
- any
.number_or_entity: &number_or_entity
.number_or_entity_humidity: &number_or_entity_humidity
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "%"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "%"
- domain: sensor
device_class: humidity
- domain: number
device_class: humidity
translation_key: number_or_entity
.number_or_entity_temperature: &number_or_entity_temperature
required: false
selector:
choose:
@@ -27,12 +49,24 @@
selector:
entity:
filter:
domain:
- input_number
- number
- sensor
- domain: input_number
unit_of_measurement:
- "°C"
- "°F"
- domain: sensor
device_class: temperature
- domain: number
device_class: temperature
translation_key: number_or_entity
.trigger_unit_temperature: &trigger_unit_temperature
required: false
selector:
select:
options:
- "°C"
- "°F"
.trigger_threshold_type: &trigger_threshold_type
required: true
default: above
@@ -69,27 +103,29 @@ hvac_mode_changed:
target_humidity_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
above: *number_or_entity_humidity
below: *number_or_entity_humidity
target_humidity_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
lower_limit: *number_or_entity_humidity
upper_limit: *number_or_entity_humidity
target_temperature_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
above: *number_or_entity_temperature
below: *number_or_entity_temperature
unit: *trigger_unit_temperature
target_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
lower_limit: *number_or_entity_temperature
upper_limit: *number_or_entity_temperature
unit: *trigger_unit_temperature

View File

@@ -1,7 +1,7 @@
"""Provides triggers for covers."""
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
@@ -13,14 +13,14 @@ class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]):
def _get_value(self, state: State) -> str | bool | None:
"""Extract the relevant value from state based on domain spec."""
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
domain_spec = self._domain_specs[state.domain]
if domain_spec.value_source is not None:
return state.attributes.get(domain_spec.value_source)
return state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the state matches the target cover state."""
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
domain_spec = self._domain_specs[state.domain]
return self._get_value(state) == domain_spec.target_value
def is_valid_transition(self, from_state: State, to_state: State) -> bool:

View File

@@ -9,7 +9,7 @@ from typing import Any, Literal, NotRequired, TypedDict
import voluptuous as vol
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant, callback, valid_entity_id
from homeassistant.helpers import config_validation as cv, singleton, storage
from .const import DOMAIN
@@ -244,6 +244,38 @@ class EnergyPreferencesUpdate(EnergyPreferences, total=False):
"""all types optional."""
def _reject_price_for_external_stat(
*,
stat_key: str,
entity_price_key: str = "entity_energy_price",
number_price_key: str = "number_energy_price",
cost_stat_key: str = "stat_cost",
) -> Callable[[dict[str, Any]], dict[str, Any]]:
"""Return a validator that rejects entity/number price for external statistics.
Only rejects when the cost/compensation stat is not already set, since
price fields are ignored when a cost stat is provided.
"""
def validate(val: dict[str, Any]) -> dict[str, Any]:
stat_id = val.get(stat_key)
if stat_id is not None and not valid_entity_id(stat_id):
if val.get(cost_stat_key) is not None:
# Cost stat is already set; price fields are ignored, so allow.
return val
if (
val.get(entity_price_key) is not None
or val.get(number_price_key) is not None
):
raise vol.Invalid(
"Entity or number price is not supported for external"
f" statistics. Use {cost_stat_key} instead"
)
return val
return validate
def _flow_from_ensure_single_price(
val: FlowFromGridSourceType,
) -> FlowFromGridSourceType:
@@ -268,19 +300,25 @@ FLOW_FROM_GRID_SOURCE_SCHEMA = vol.All(
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
),
_reject_price_for_external_stat(stat_key="stat_energy_from"),
_flow_from_ensure_single_price,
)
FLOW_TO_GRID_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("stat_energy_to"): str,
vol.Optional("stat_compensation"): vol.Any(str, None),
# entity_energy_to was removed in HA Core 2022.10
vol.Remove("entity_energy_to"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
FLOW_TO_GRID_SOURCE_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("stat_energy_to"): str,
vol.Optional("stat_compensation"): vol.Any(str, None),
# entity_energy_to was removed in HA Core 2022.10
vol.Remove("entity_energy_to"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
),
_reject_price_for_external_stat(
stat_key="stat_energy_to", cost_stat_key="stat_compensation"
),
)
@@ -419,6 +457,13 @@ GRID_SOURCE_SCHEMA = vol.All(
vol.Required("cost_adjustment_day"): vol.Coerce(float),
}
),
_reject_price_for_external_stat(stat_key="stat_energy_from"),
_reject_price_for_external_stat(
stat_key="stat_energy_to",
entity_price_key="entity_energy_price_export",
number_price_key="number_energy_price_export",
cost_stat_key="stat_compensation",
),
_grid_ensure_single_price_import,
_grid_ensure_single_price_export,
_grid_ensure_at_least_one_stat,
@@ -442,27 +487,35 @@ BATTERY_SOURCE_SCHEMA = vol.Schema(
vol.Optional("power_config"): POWER_CONFIG_SCHEMA,
}
)
GAS_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "gas",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
# entity_energy_from was removed in HA Core 2022.10
vol.Remove("entity_energy_from"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
GAS_SOURCE_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("type"): "gas",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
# entity_energy_from was removed in HA Core 2022.10
vol.Remove("entity_energy_from"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
),
_reject_price_for_external_stat(stat_key="stat_energy_from"),
)
WATER_SOURCE_SCHEMA = vol.Schema(
{
vol.Required("type"): "water",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
WATER_SOURCE_SCHEMA = vol.All(
vol.Schema(
{
vol.Required("type"): "water",
vol.Required("stat_energy_from"): str,
vol.Optional("stat_rate"): str,
vol.Optional("stat_cost"): vol.Any(str, None),
vol.Optional("entity_energy_price"): vol.Any(str, None),
vol.Optional("number_energy_price"): vol.Any(vol.Coerce(float), None),
}
),
_reject_price_for_external_stat(stat_key="stat_energy_from"),
)

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==44.5.2",
"aioesphomeapi==44.6.2",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.7.1"
],

View File

@@ -6,5 +6,5 @@
"iot_class": "cloud_polling",
"loggers": ["evohomeasync", "evohomeasync2"],
"quality_scale": "legacy",
"requirements": ["evohome-async==1.1.3"]
"requirements": ["evohome-async==1.2.0"]
}

View File

@@ -124,15 +124,17 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
until = dt_util.as_utc(until) if until else None
if operation_mode == STATE_ON:
await self.coordinator.call_client_api(self._evo_device.on(until=until))
await self.coordinator.call_client_api(
self._evo_device.set_on(until=until)
)
else: # STATE_OFF
await self.coordinator.call_client_api(
self._evo_device.off(until=until)
self._evo_device.set_off(until=until)
)
async def async_turn_away_mode_on(self) -> None:
"""Turn away mode on."""
await self.coordinator.call_client_api(self._evo_device.off())
await self.coordinator.call_client_api(self._evo_device.set_off())
async def async_turn_away_mode_off(self) -> None:
"""Turn away mode off."""
@@ -140,8 +142,8 @@ class EvoDHW(EvoChild, WaterHeaterEntity):
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn on."""
await self.coordinator.call_client_api(self._evo_device.on())
await self.coordinator.call_client_api(self._evo_device.set_on())
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off."""
await self.coordinator.call_client_api(self._evo_device.off())
await self.coordinator.call_client_api(self._evo_device.set_off())

View File

@@ -283,6 +283,7 @@ class FritzBoxToolsFlowHandler(ConfigFlow, domain=DOMAIN):
self._username = user_input[CONF_USERNAME]
self._password = user_input[CONF_PASSWORD]
self._use_tls = user_input[CONF_SSL]
self._feature_device_discovery = user_input[CONF_FEATURE_DEVICE_TRACKING]
self._port = self._determine_port(user_input)

View File

@@ -13,6 +13,7 @@ from fritzconnection.core.exceptions import (
FritzSecurityError,
FritzServiceError,
)
from requests.exceptions import ConnectionError
from homeassistant.const import Platform
@@ -68,6 +69,7 @@ BUTTON_TYPE_WOL = "WakeOnLan"
UPTIME_DEVIATION = 5
FRITZ_EXCEPTIONS = (
ConnectionError,
FritzActionError,
FritzActionFailedError,
FritzConnectionException,

View File

@@ -21,5 +21,5 @@
"integration_type": "system",
"preview_features": { "winter_mode": {} },
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20260312.0"]
"requirements": ["home-assistant-frontend==20260312.1"]
}

View File

@@ -116,7 +116,11 @@ async def _validate_config(
CONFIG_FLOW = {
"user": SchemaFlowFormStep(vol.Schema(CONFIG_SCHEMA), next_step="presets"),
"user": SchemaFlowFormStep(
vol.Schema(CONFIG_SCHEMA),
validate_user_input=_validate_config,
next_step="presets",
),
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
}

View File

@@ -1,5 +1,8 @@
{
"config": {
"error": {
"min_max_runtime": "Minimum run time must be less than the maximum run time."
},
"step": {
"presets": {
"data": {
@@ -45,7 +48,7 @@
},
"options": {
"error": {
"min_max_runtime": "Minimum run time must be less than the maximum run time."
"min_max_runtime": "[%key:component::generic_thermostat::config::error::min_max_runtime%]"
},
"step": {
"init": {

View File

@@ -0,0 +1,44 @@
"""Diagnostics support for Google Weather."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.const import CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE
from homeassistant.core import HomeAssistant
from .const import CONF_REFERRER
from .coordinator import GoogleWeatherConfigEntry
TO_REDACT = {
CONF_API_KEY,
CONF_REFERRER,
CONF_LATITUDE,
CONF_LONGITUDE,
}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: GoogleWeatherConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
diag_data: dict[str, Any] = {
"entry": entry.as_dict(),
"subentries": {},
}
for subentry_id, subentry_rt in entry.runtime_data.subentries_runtime_data.items():
diag_data["subentries"][subentry_id] = {
"observation_data": subentry_rt.coordinator_observation.data.to_dict()
if subentry_rt.coordinator_observation.data
else None,
"daily_forecast_data": subentry_rt.coordinator_daily_forecast.data.to_dict()
if subentry_rt.coordinator_daily_forecast.data
else None,
"hourly_forecast_data": subentry_rt.coordinator_hourly_forecast.data.to_dict()
if subentry_rt.coordinator_hourly_forecast.data
else None,
}
return async_redact_data(diag_data, TO_REDACT)

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["google_weather_api"],
"quality_scale": "bronze",
"requirements": ["python-google-weather-api==0.0.4"]
"requirements": ["python-google-weather-api==0.0.6"]
}

View File

@@ -43,7 +43,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: exempt
comment: No discovery.

View File

@@ -7,6 +7,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["greenplanet-energy-api==0.1.4"],
"requirements": ["greenplanet-energy-api==0.1.10"],
"single_config_entry": true
}

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
from contextlib import suppress
from dataclasses import replace
from datetime import datetime
import logging
import os
@@ -15,6 +16,7 @@ from aiohasupervisor import SupervisorError
from aiohasupervisor.models import (
GreenOptions,
HomeAssistantInfo,
HomeAssistantOptions,
HostInfo,
InstalledAddon,
NetworkInfo,
@@ -22,20 +24,28 @@ from aiohasupervisor.models import (
RootInfo,
StoreInfo,
SupervisorInfo,
SupervisorOptions,
YellowOptions,
)
import voluptuous as vol
from homeassistant.auth.const import GROUP_ID_ADMIN
from homeassistant.auth.models import RefreshToken
from homeassistant.components import frontend, panel_custom
from homeassistant.components.homeassistant import async_set_stop_handler
from homeassistant.components.http import StaticPathConfig
from homeassistant.components.http import (
CONF_SERVER_HOST,
CONF_SERVER_PORT,
CONF_SSL_CERTIFICATE,
StaticPathConfig,
)
from homeassistant.config_entries import SOURCE_SYSTEM, ConfigEntry
from homeassistant.const import (
ATTR_DEVICE_ID,
ATTR_NAME,
EVENT_CORE_CONFIG_UPDATE,
HASSIO_USER_NAME,
SERVER_PORT,
Platform,
)
from homeassistant.core import (
@@ -445,8 +455,30 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
require_admin=True,
)
async def update_hass_api(http_config: dict[str, Any], refresh_token: RefreshToken):
"""Update Home Assistant API data on Hass.io."""
options = HomeAssistantOptions(
ssl=CONF_SSL_CERTIFICATE in http_config,
port=http_config.get(CONF_SERVER_PORT) or SERVER_PORT,
refresh_token=refresh_token.token,
)
if http_config.get(CONF_SERVER_HOST) is not None:
options = replace(options, watchdog=False)
_LOGGER.warning(
"Found incompatible HTTP option 'server_host'. Watchdog feature"
" disabled"
)
try:
await supervisor_client.homeassistant.set_options(options)
except SupervisorError as err:
_LOGGER.warning(
"Failed to update Home Assistant options in Supervisor: %s", err
)
update_hass_api_task = hass.async_create_task(
hassio.update_hass_api(config.get("http", {}), refresh_token), eager_start=True
update_hass_api(config.get("http", {}), refresh_token), eager_start=True
)
last_timezone = None
@@ -457,19 +489,25 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
nonlocal last_timezone
nonlocal last_country
new_timezone = str(hass.config.time_zone)
new_country = str(hass.config.country)
new_timezone = hass.config.time_zone
new_country = hass.config.country
if new_timezone != last_timezone or new_country != last_country:
last_timezone = new_timezone
last_country = new_country
await hassio.update_hass_config(new_timezone, new_country)
try:
await supervisor_client.supervisor.set_options(
SupervisorOptions(timezone=new_timezone, country=new_country)
)
except SupervisorError as err:
_LOGGER.warning("Failed to update Supervisor options: %s", err)
hass.bus.async_listen(EVENT_CORE_CONFIG_UPDATE, push_config)
push_config_task = hass.async_create_task(push_config(None), eager_start=True)
# Start listening for problems with supervisor and making issues
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass, hassio)
hass.data[DATA_KEY_SUPERVISOR_ISSUES] = issues = SupervisorIssues(hass)
issues_task = hass.async_create_task(issues.setup(), eager_start=True)
async def async_service_handler(service: ServiceCall) -> None:
@@ -617,7 +655,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
async_set_stop_handler(hass, _async_stop)
# Init discovery Hass.io feature
async_setup_discovery_view(hass, hassio)
async_setup_discovery_view(hass)
# Init auth Hass.io feature
assert user is not None

View File

@@ -21,15 +21,15 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.service_info.hassio import HassioServiceInfo
from .const import ATTR_ADDON, ATTR_UUID, DOMAIN
from .handler import HassIO, get_supervisor_client
from .handler import get_supervisor_client
_LOGGER = logging.getLogger(__name__)
@callback
def async_setup_discovery_view(hass: HomeAssistant, hassio: HassIO) -> None:
def async_setup_discovery_view(hass: HomeAssistant) -> None:
"""Discovery setup."""
hassio_discovery = HassIODiscovery(hass, hassio)
hassio_discovery = HassIODiscovery(hass)
supervisor_client = get_supervisor_client(hass)
hass.http.register_view(hassio_discovery)
@@ -77,10 +77,9 @@ class HassIODiscovery(HomeAssistantView):
name = "api:hassio_push:discovery"
url = "/api/hassio_push/discovery/{uuid}"
def __init__(self, hass: HomeAssistant, hassio: HassIO) -> None:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize WebView."""
self.hass = hass
self.hassio = hassio
self._supervisor_client = get_supervisor_client(hass)
async def post(self, request: web.Request, uuid: str) -> web.Response:

View File

@@ -14,13 +14,6 @@ from aiohasupervisor.models import SupervisorOptions
import aiohttp
from yarl import URL
from homeassistant.auth.models import RefreshToken
from homeassistant.components.http import (
CONF_SERVER_HOST,
CONF_SERVER_PORT,
CONF_SSL_CERTIFICATE,
)
from homeassistant.const import SERVER_PORT
from homeassistant.core import HomeAssistant
from homeassistant.helpers.singleton import singleton
@@ -35,22 +28,6 @@ class HassioAPIError(RuntimeError):
"""Return if a API trow a error."""
def _api_bool[**_P](
funct: Callable[_P, Coroutine[Any, Any, dict[str, Any]]],
) -> Callable[_P, Coroutine[Any, Any, bool]]:
"""Return a boolean."""
async def _wrapper(*argv: _P.args, **kwargs: _P.kwargs) -> bool:
"""Wrap function."""
try:
data = await funct(*argv, **kwargs)
return data["result"] == "ok"
except HassioAPIError:
return False
return _wrapper
def api_data[**_P](
funct: Callable[_P, Coroutine[Any, Any, dict[str, Any]]],
) -> Callable[_P, Coroutine[Any, Any, Any]]:
@@ -95,37 +72,6 @@ class HassIO:
"""
return self.send_command("/ingress/panels", method="get")
@_api_bool
async def update_hass_api(
self, http_config: dict[str, Any], refresh_token: RefreshToken
):
"""Update Home Assistant API data on Hass.io."""
port = http_config.get(CONF_SERVER_PORT) or SERVER_PORT
options = {
"ssl": CONF_SSL_CERTIFICATE in http_config,
"port": port,
"refresh_token": refresh_token.token,
}
if http_config.get(CONF_SERVER_HOST) is not None:
options["watchdog"] = False
_LOGGER.warning(
"Found incompatible HTTP option 'server_host'. Watchdog feature"
" disabled"
)
return await self.send_command("/homeassistant/options", payload=options)
@_api_bool
def update_hass_config(self, timezone: str, country: str | None) -> Coroutine:
"""Update Home-Assistant timezone data on Hass.io.
This method returns a coroutine.
"""
return self.send_command(
"/supervisor/options", payload={"timezone": timezone, "country": country}
)
async def send_command(
self,
command: str,

View File

@@ -63,7 +63,7 @@ from .const import (
UPDATE_KEY_SUPERVISOR,
)
from .coordinator import HassioDataUpdateCoordinator, get_addons_list, get_host_info
from .handler import HassIO, get_supervisor_client
from .handler import get_supervisor_client
ISSUE_KEY_UNHEALTHY = "unhealthy"
ISSUE_KEY_UNSUPPORTED = "unsupported"
@@ -175,10 +175,9 @@ class Issue:
class SupervisorIssues:
"""Create issues from supervisor events."""
def __init__(self, hass: HomeAssistant, client: HassIO) -> None:
def __init__(self, hass: HomeAssistant) -> None:
"""Initialize supervisor issues."""
self._hass = hass
self._client = client
self._unsupported_reasons: set[str] = set()
self._unhealthy_reasons: set[str] = set()
self._issues: dict[UUID, Issue] = {}

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Protocol
from universal_silabs_flasher.flasher import Zbt2Flasher
from homeassistant.components import usb
from homeassistant.components.homeassistant_hardware import firmware_config_flow
from homeassistant.components.homeassistant_hardware.helpers import (
@@ -13,7 +15,6 @@ from homeassistant.components.homeassistant_hardware.helpers import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.usb import (
usb_service_info_from_device,
@@ -76,15 +77,7 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
context: ConfigFlowContext
ZIGBEE_BAUDRATE = 460800
# Early ZBT-2 samples used RTS/DTR to trigger the bootloader, later ones use the
# baudrate method. Since the two are mutually exclusive we just use both.
BOOTLOADER_RESET_METHODS = [ResetTarget.RTS_DTR, ResetTarget.BAUDRATE]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
]
_flasher_cls = Zbt2Flasher
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import logging
from universal_silabs_flasher.flasher import Zbt2Flasher
from homeassistant.components.homeassistant_hardware.coordinator import (
FirmwareUpdateCoordinator,
)
@@ -23,7 +25,6 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantConnectZBT2ConfigEntry
from .config_flow import ZBT2FirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, HARDWARE_NAME, SERIAL_NUMBER
_LOGGER = logging.getLogger(__name__)
@@ -134,8 +135,7 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Connect ZBT-2 firmware update entity."""
BOOTLOADER_RESET_METHODS = ZBT2FirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = ZBT2FirmwareMixin.APPLICATION_PROBE_METHODS
_flasher_cls = Zbt2Flasher
def __init__(
self,

View File

@@ -12,6 +12,7 @@ from aiohttp import ClientError
from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing
from universal_silabs_flasher.common import Version
from universal_silabs_flasher.firmware import NabuCasaMetadata
from universal_silabs_flasher.flasher import DeviceSpecificFlasher
from homeassistant.components.hassio import (
AddonError,
@@ -39,7 +40,6 @@ from .util import (
FirmwareInfo,
OwningAddon,
OwningIntegration,
ResetTarget,
async_firmware_flashing_context,
async_flash_silabs_firmware,
get_otbr_addon_manager,
@@ -81,8 +81,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Base flow to install firmware."""
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
BOOTLOADER_RESET_METHODS: list[ResetTarget] = [] # Default, subclasses may override
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]] = []
_flasher_cls: type[DeviceSpecificFlasher]
_picked_firmware_type: PickedFirmwareType
_zigbee_flow_strategy: ZigbeeFlowStrategy = ZigbeeFlowStrategy.RECOMMENDED
@@ -239,8 +238,7 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# isn't strictly necessary for functionality.
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
flasher_cls=self._flasher_cls,
)
firmware_install_required = self._probed_firmware_info is None or (
@@ -311,9 +309,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
hass=self.hass,
device=self._device,
fw_data=fw_data,
flasher_cls=self._flasher_cls,
expected_installed_firmware_type=expected_installed_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=lambda offset, total: self.async_update_progress(
offset / total
),

View File

@@ -8,7 +8,7 @@
"integration_type": "system",
"requirements": [
"serialx==0.6.2",
"universal-silabs-flasher==0.1.2",
"universal-silabs-flasher==1.0.2",
"ha-silabs-firmware-client==0.3.0"
]
}

View File

@@ -8,6 +8,7 @@ import logging
from typing import Any, cast
from ha_silabs_firmware_client import FirmwareManifest, FirmwareMetadata
from universal_silabs_flasher.flasher import DeviceSpecificFlasher
from yarl import URL
from homeassistant.components.update import (
@@ -25,7 +26,6 @@ from .helpers import async_register_firmware_info_callback
from .util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
async_firmware_flashing_context,
async_flash_silabs_firmware,
)
@@ -87,13 +87,11 @@ class BaseFirmwareUpdateEntity(
# Subclasses provide the mapping between firmware types and entity descriptions
entity_description: FirmwareUpdateEntityDescription
BOOTLOADER_RESET_METHODS: list[ResetTarget]
APPLICATION_PROBE_METHODS: list[tuple[ApplicationType, int]]
_attr_supported_features = (
UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS
)
_attr_has_entity_name = True
_flasher_cls: type[DeviceSpecificFlasher]
def __init__(
self,
@@ -282,9 +280,8 @@ class BaseFirmwareUpdateEntity(
hass=self.hass,
device=self._current_device,
fw_data=fw_data,
flasher_cls=self._flasher_cls,
expected_installed_firmware_type=self.entity_description.expected_firmware_type,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
progress_callback=self._update_progress,
)
finally:

View File

@@ -10,12 +10,9 @@ from dataclasses import dataclass
from enum import StrEnum
import logging
from universal_silabs_flasher.const import (
ApplicationType as FlasherApplicationType,
ResetTarget as FlasherResetTarget,
)
from universal_silabs_flasher.const import ApplicationType as FlasherApplicationType
from universal_silabs_flasher.firmware import parse_firmware_image
from universal_silabs_flasher.flasher import Flasher
from universal_silabs_flasher.flasher import BaseFlasher, DeviceSpecificFlasher, Flasher
from homeassistant.components.hassio import AddonError, AddonManager, AddonState
from homeassistant.config_entries import ConfigEntryState
@@ -63,18 +60,6 @@ class ApplicationType(StrEnum):
return FlasherApplicationType(self.value)
class ResetTarget(StrEnum):
"""Methods to reset a device into bootloader mode."""
RTS_DTR = "rts_dtr"
BAUDRATE = "baudrate"
YELLOW = "yellow"
def as_flasher_reset_target(self) -> FlasherResetTarget:
"""Convert the reset target enum into one compatible with USF."""
return FlasherResetTarget(self.value)
@singleton(OTBR_ADDON_MANAGER_DATA)
@callback
def get_otbr_addon_manager(hass: HomeAssistant) -> WaitingAddonManager:
@@ -310,23 +295,20 @@ async def guess_firmware_info(hass: HomeAssistant, device_path: str) -> Firmware
async def probe_silabs_firmware_info(
device: str,
*,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
flasher_cls: type[BaseFlasher],
application_probe_methods: Sequence[ApplicationType] | None = None,
) -> FirmwareInfo | None:
"""Probe the running firmware on a SiLabs device."""
flasher = Flasher(
device=device,
probe_methods=tuple(
(m.as_flasher_application_type(), baudrate)
for m, baudrate in application_probe_methods
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)
flasher = flasher_cls(device=device)
try:
await flasher.probe_app_type()
await flasher.probe_app_type(
only=(
[m.as_flasher_application_type() for m in application_probe_methods]
if application_probe_methods is not None
else None
)
)
except Exception: # noqa: BLE001
_LOGGER.debug("Failed to probe application type", exc_info=True)
@@ -349,20 +331,25 @@ async def probe_silabs_firmware_info(
async def probe_silabs_firmware_type(
device: str,
*,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
) -> ApplicationType | None:
"""Probe the running firmware type on a SiLabs device."""
fw_info = await probe_silabs_firmware_info(
device,
bootloader_reset_methods=bootloader_reset_methods,
application_probe_methods=application_probe_methods,
flasher = Flasher(
device=device,
probe_methods=[
(m.as_flasher_application_type(), b) for m, b in application_probe_methods
],
)
if fw_info is None:
try:
await flasher.probe_app_type()
except Exception: # noqa: BLE001
_LOGGER.debug("Failed to probe application type", exc_info=True)
if flasher.app_type is None:
return None
return fw_info.firmware_type
return ApplicationType.from_flasher_application_type(flasher.app_type)
@asynccontextmanager
@@ -385,36 +372,18 @@ async def async_flash_silabs_firmware(
hass: HomeAssistant,
device: str,
fw_data: bytes,
flasher_cls: type[DeviceSpecificFlasher],
expected_installed_firmware_type: ApplicationType,
bootloader_reset_methods: Sequence[ResetTarget],
application_probe_methods: Sequence[tuple[ApplicationType, int]],
progress_callback: Callable[[int, int], None] | None = None,
) -> FirmwareInfo:
"""Flash firmware to the SiLabs device.
This function is meant to be used within a firmware update context.
"""
if not any(
method == expected_installed_firmware_type
for method, _ in application_probe_methods
):
raise ValueError(
f"Expected installed firmware type {expected_installed_firmware_type!r}"
f" not in application probe methods {application_probe_methods!r}"
)
fw_image = await hass.async_add_executor_job(parse_firmware_image, fw_data)
flasher = Flasher(
device=device,
probe_methods=tuple(
(m.as_flasher_application_type(), baudrate)
for m, baudrate in application_probe_methods
),
bootloader_reset=tuple(
m.as_flasher_reset_target() for m in bootloader_reset_methods
),
)
flasher = flasher_cls(device=device)
try:
# Enter the bootloader with indeterminate progress
@@ -431,13 +400,9 @@ async def async_flash_silabs_firmware(
probed_firmware_info = await probe_silabs_firmware_info(
device,
bootloader_reset_methods=bootloader_reset_methods,
flasher_cls=flasher_cls,
# Only probe for the expected installed firmware type
application_probe_methods=[
(method, baudrate)
for method, baudrate in application_probe_methods
if method == expected_installed_firmware_type
],
application_probe_methods=[expected_installed_firmware_type],
)
if probed_firmware_info is None:

View File

@@ -5,6 +5,8 @@ from __future__ import annotations
import logging
from typing import TYPE_CHECKING, Any, Protocol
from universal_silabs_flasher.flasher import Zbt1Flasher
from homeassistant.components import usb
from homeassistant.components.homeassistant_hardware import (
firmware_config_flow,
@@ -16,7 +18,6 @@ from homeassistant.components.homeassistant_hardware.helpers import (
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
)
from homeassistant.components.usb import (
usb_service_info_from_device,
@@ -81,18 +82,7 @@ class SkyConnectFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
context: ConfigFlowContext
ZIGBEE_BAUDRATE = 115200
# There is no hardware bootloader trigger
BOOTLOADER_RESET_METHODS: list[ResetTarget] = []
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
# CPC baudrates can be removed once multiprotocol is removed
(ApplicationType.CPC, 115200),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 460800),
(ApplicationType.ROUTER, 115200),
]
_flasher_cls = Zbt1Flasher
def _get_translation_placeholders(self) -> dict[str, str]:
"""Shared translation placeholders."""

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import logging
from universal_silabs_flasher.flasher import Zbt1Flasher
from homeassistant.components.homeassistant_hardware.coordinator import (
FirmwareUpdateCoordinator,
)
@@ -23,7 +25,6 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantSkyConnectConfigEntry
from .config_flow import SkyConnectFirmwareMixin
from .const import (
DOMAIN,
FIRMWARE,
@@ -152,8 +153,7 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""SkyConnect firmware update entity."""
BOOTLOADER_RESET_METHODS = SkyConnectFirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = SkyConnectFirmwareMixin.APPLICATION_PROBE_METHODS
_flasher_cls = Zbt1Flasher
def __init__(
self,

View File

@@ -7,6 +7,7 @@ import asyncio
import logging
from typing import TYPE_CHECKING, Any, Protocol, final
from universal_silabs_flasher.flasher import YellowFlasher
import voluptuous as vol
from homeassistant.components.hassio import (
@@ -25,7 +26,6 @@ from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon
from homeassistant.components.homeassistant_hardware.util import (
ApplicationType,
FirmwareInfo,
ResetTarget,
probe_silabs_firmware_info,
)
from homeassistant.config_entries import (
@@ -83,17 +83,7 @@ class YellowFirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
"""Mixin for Home Assistant Yellow firmware methods."""
ZIGBEE_BAUDRATE = 115200
BOOTLOADER_RESET_METHODS = [ResetTarget.YELLOW]
APPLICATION_PROBE_METHODS = [
(ApplicationType.GECKO_BOOTLOADER, 115200),
(ApplicationType.EZSP, ZIGBEE_BAUDRATE),
(ApplicationType.SPINEL, 460800),
# CPC baudrates can be removed once multiprotocol is removed
(ApplicationType.CPC, 115200),
(ApplicationType.CPC, 230400),
(ApplicationType.CPC, 460800),
(ApplicationType.ROUTER, 115200),
]
_flasher_cls = YellowFlasher
async def async_step_install_zigbee_firmware(
self, user_input: dict[str, Any] | None = None
@@ -159,8 +149,7 @@ class HomeAssistantYellowConfigFlow(
# We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this
self._probed_firmware_info = await probe_silabs_firmware_info(
self._device,
bootloader_reset_methods=self.BOOTLOADER_RESET_METHODS,
application_probe_methods=self.APPLICATION_PROBE_METHODS,
flasher_cls=self._flasher_cls,
)
# Kick off ZHA hardware discovery automatically if Zigbee firmware is running

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
import logging
from universal_silabs_flasher.flasher import YellowFlasher
from homeassistant.components.homeassistant_hardware.coordinator import (
FirmwareUpdateCoordinator,
)
@@ -23,7 +25,6 @@ from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import HomeAssistantYellowConfigEntry
from .config_flow import YellowFirmwareMixin
from .const import DOMAIN, FIRMWARE, FIRMWARE_VERSION, MANUFACTURER, MODEL, RADIO_DEVICE
_LOGGER = logging.getLogger(__name__)
@@ -150,8 +151,7 @@ async def async_setup_entry(
class FirmwareUpdateEntity(BaseFirmwareUpdateEntity):
"""Yellow firmware update entity."""
BOOTLOADER_RESET_METHODS = YellowFirmwareMixin.BOOTLOADER_RESET_METHODS
APPLICATION_PROBE_METHODS = YellowFirmwareMixin.APPLICATION_PROBE_METHODS
_flasher_cls = YellowFlasher
def __init__(
self,

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
@@ -57,3 +58,48 @@ class HuumConfigFlow(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 reauthentication upon an API authentication error."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication dialog."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
huum = Huum(
reauth_entry.data[CONF_USERNAME],
user_input[CONF_PASSWORD],
session=async_get_clientsession(self.hass),
)
try:
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(
reauth_entry,
data_updates={CONF_PASSWORD: user_input[CONF_PASSWORD]},
)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=vol.Schema(
{
vol.Required(CONF_PASSWORD): str,
}
),
description_placeholders={
"username": reauth_entry.data[CONF_USERNAME],
},
errors=errors,
)

View File

@@ -12,8 +12,9 @@ from huum.schemas import HuumStatusResponse
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN
@@ -54,6 +55,6 @@ class HuumDataUpdateCoordinator(DataUpdateCoordinator[HuumStatusResponse]):
try:
return await self.huum.status()
except (Forbidden, NotAuthenticated) as err:
raise UpdateFailed(
raise ConfigEntryAuthFailed(
"Could not log in to Huum with given credentials"
) from err

View File

@@ -38,7 +38,7 @@ rules:
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage:
status: todo
comment: |

View File

@@ -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%]",
@@ -9,6 +10,16 @@
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"reauth_confirm": {
"data": {
"password": "[%key:common::config_flow::data::password%]"
},
"data_description": {
"password": "[%key:component::huum::config::step::user::data_description::password%]"
},
"description": "The authentication for {username} is no longer valid. Please enter the current password.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",

View File

@@ -2,22 +2,9 @@
from __future__ import annotations
from aioimmich import Immich
from aioimmich.const import CONNECT_ERRORS
from aioimmich.exceptions import ImmichUnauthorizedError
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_VERIFY_SSL,
Platform,
)
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
@@ -38,30 +25,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ImmichConfigEntry) -> bool:
"""Set up Immich from a config entry."""
session = async_get_clientsession(hass, entry.data[CONF_VERIFY_SSL])
immich = Immich(
session,
entry.data[CONF_API_KEY],
entry.data[CONF_HOST],
entry.data[CONF_PORT],
entry.data[CONF_SSL],
"home-assistant",
)
try:
user_info = await immich.users.async_get_my_user()
except ImmichUnauthorizedError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except CONNECT_ERRORS as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
coordinator = ImmichDataUpdateCoordinator(hass, entry, immich, user_info.is_admin)
coordinator = ImmichDataUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

View File

@@ -16,11 +16,19 @@ from aioimmich.server.models import (
ImmichServerVersionCheck,
)
from awesomeversion import AwesomeVersion
from yarl import URL
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PORT, CONF_SSL
from homeassistant.const import (
CONF_API_KEY,
CONF_HOST,
CONF_PORT,
CONF_SSL,
CONF_VERIFY_SSL,
)
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -46,24 +54,49 @@ class ImmichDataUpdateCoordinator(DataUpdateCoordinator[ImmichData]):
config_entry: ImmichConfigEntry
def __init__(
self, hass: HomeAssistant, entry: ConfigEntry, api: Immich, is_admin: bool
) -> None:
def __init__(self, hass: HomeAssistant, config_entry: ImmichConfigEntry) -> None:
"""Initialize the data update coordinator."""
self.api = api
self.is_admin = is_admin
self.configuration_url = (
f"{'https' if entry.data[CONF_SSL] else 'http'}://"
f"{entry.data[CONF_HOST]}:{entry.data[CONF_PORT]}"
self.api = Immich(
async_get_clientsession(hass, config_entry.data[CONF_VERIFY_SSL]),
config_entry.data[CONF_API_KEY],
config_entry.data[CONF_HOST],
config_entry.data[CONF_PORT],
config_entry.data[CONF_SSL],
"home-assistant",
)
self.is_admin = False
self.configuration_url = str(
URL.build(
scheme="https" if config_entry.data[CONF_SSL] else "http",
host=config_entry.data[CONF_HOST],
port=config_entry.data[CONF_PORT],
)
)
super().__init__(
hass,
_LOGGER,
config_entry=entry,
config_entry=config_entry,
name=DOMAIN,
update_interval=timedelta(seconds=60),
)
async def _async_setup(self) -> None:
"""Handle setup of the coordinator."""
try:
user_info = await self.api.users.async_get_my_user()
except ImmichUnauthorizedError as err:
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="auth_error",
) from err
except CONNECT_ERRORS as err:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from err
self.is_admin = user_info.is_admin
async def _async_update_data(self) -> ImmichData:
"""Update data via internal method."""
try:

View File

@@ -20,13 +20,5 @@
"turn_on": {
"service": "mdi:toggle-switch"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:toggle-switch-off"
},
"turned_on": {
"trigger": "mdi:toggle-switch"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted toggles to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::input_boolean::title%]",
@@ -21,15 +17,6 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"reload": {
"description": "Reloads helpers from the YAML-configuration.",
@@ -48,27 +35,5 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Input boolean",
"triggers": {
"turned_off": {
"description": "Triggers after one or more toggles turn off.",
"fields": {
"behavior": {
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
}
},
"name": "Toggle turned off"
},
"turned_on": {
"description": "Triggers after one or more toggles turn on.",
"fields": {
"behavior": {
"description": "[%key:component::input_boolean::common::trigger_behavior_description%]",
"name": "[%key:component::input_boolean::common::trigger_behavior_name%]"
}
},
"name": "Toggle turned on"
}
}
"title": "Input boolean"
}

View File

@@ -1,17 +0,0 @@
"""Provides triggers for input booleans."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from . import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for input booleans."""
return TRIGGERS

View File

@@ -1,18 +0,0 @@
.trigger_common: &trigger_common
target:
entity:
domain: input_boolean
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -114,24 +114,26 @@ class KnxYamlBinarySensor(_KnxBinarySensor, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX binary sensor."""
self._device = XknxBinarySensor(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[CONF_STATE_ADDRESS],
invert=config[CONF_INVERT],
sync_state=config[CONF_SYNC_STATE],
ignore_internal_state=config[CONF_IGNORE_INTERNAL_STATE],
context_timeout=config.get(CONF_CONTEXT_TIMEOUT),
reset_after=config.get(CONF_RESET_AFTER),
always_callback=True,
)
super().__init__(
knx_module=knx_module,
device=XknxBinarySensor(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[CONF_STATE_ADDRESS],
invert=config[CONF_INVERT],
sync_state=config[CONF_SYNC_STATE],
ignore_internal_state=config[CONF_IGNORE_INTERNAL_STATE],
context_timeout=config.get(CONF_CONTEXT_TIMEOUT),
reset_after=config.get(CONF_RESET_AFTER),
always_callback=True,
),
unique_id=str(self._device.remote_value.group_address_state),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_force_update = self._device.ignore_internal_state
self._attr_unique_id = str(self._device.remote_value.group_address_state)
class KnxUiBinarySensor(_KnxBinarySensor, KnxUiEntity):

View File

@@ -35,19 +35,18 @@ class KNXButton(KnxYamlEntity, ButtonEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX button."""
super().__init__(
knx_module=knx_module,
device=XknxRawValue(
xknx=knx_module.xknx,
name=config[CONF_NAME],
payload_length=config[CONF_PAYLOAD_LENGTH],
group_address=config[KNX_ADDRESS],
),
self._device = XknxRawValue(
xknx=knx_module.xknx,
name=config[CONF_NAME],
payload_length=config[CONF_PAYLOAD_LENGTH],
group_address=config[KNX_ADDRESS],
)
self._payload = config[CONF_PAYLOAD]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.remote_value.group_address}_{self._payload}"
super().__init__(
knx_module=knx_module,
unique_id=f"{self._device.remote_value.group_address}_{self._payload}",
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
async def async_press(self) -> None:

View File

@@ -119,7 +119,7 @@ async def async_setup_entry(
async_add_entities(entities)
def _create_climate(xknx: XKNX, config: ConfigType) -> XknxClimate:
def _create_climate_yaml(xknx: XKNX, config: ConfigType) -> XknxClimate:
"""Return a KNX Climate device to be used within XKNX."""
climate_mode = XknxClimateMode(
xknx,
@@ -646,9 +646,17 @@ class KnxYamlClimate(_KnxClimate, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX climate device."""
self._device = _create_climate_yaml(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_climate(knx_module.xknx, config),
unique_id=(
f"{self._device.temperature.group_address_state}_"
f"{self._device.target_temperature.group_address_state}_"
f"{self._device.target_temperature.group_address}_"
f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
default_hvac_mode: HVACMode = config[ClimateConf.DEFAULT_CONTROLLER_MODE]
fan_max_step = config[ClimateConf.FAN_MAX_STEP]
@@ -660,14 +668,6 @@ class KnxYamlClimate(_KnxClimate, KnxYamlEntity):
fan_zero_mode=fan_zero_mode,
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.temperature.group_address_state}_"
f"{self._device.target_temperature.group_address_state}_"
f"{self._device.target_temperature.group_address}_"
f"{self._device._setpoint_shift.group_address}" # noqa: SLF001
)
class KnxUiClimate(_KnxClimate, KnxUiEntity):
"""Representation of a KNX climate device configured from the UI."""

View File

@@ -191,36 +191,34 @@ class KnxYamlCover(_KnxCover, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize the cover."""
self._device = XknxCover(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_long=config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS),
group_address_short=config.get(CoverSchema.CONF_MOVE_SHORT_ADDRESS),
group_address_stop=config.get(CoverSchema.CONF_STOP_ADDRESS),
group_address_position_state=config.get(
CoverSchema.CONF_POSITION_STATE_ADDRESS
),
group_address_angle=config.get(CoverSchema.CONF_ANGLE_ADDRESS),
group_address_angle_state=config.get(CoverSchema.CONF_ANGLE_STATE_ADDRESS),
group_address_position=config.get(CoverSchema.CONF_POSITION_ADDRESS),
travel_time_down=config[CoverConf.TRAVELLING_TIME_DOWN],
travel_time_up=config[CoverConf.TRAVELLING_TIME_UP],
invert_updown=config[CoverConf.INVERT_UPDOWN],
invert_position=config[CoverConf.INVERT_POSITION],
invert_angle=config[CoverConf.INVERT_ANGLE],
)
super().__init__(
knx_module=knx_module,
device=XknxCover(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_long=config.get(CoverSchema.CONF_MOVE_LONG_ADDRESS),
group_address_short=config.get(CoverSchema.CONF_MOVE_SHORT_ADDRESS),
group_address_stop=config.get(CoverSchema.CONF_STOP_ADDRESS),
group_address_position_state=config.get(
CoverSchema.CONF_POSITION_STATE_ADDRESS
),
group_address_angle=config.get(CoverSchema.CONF_ANGLE_ADDRESS),
group_address_angle_state=config.get(
CoverSchema.CONF_ANGLE_STATE_ADDRESS
),
group_address_position=config.get(CoverSchema.CONF_POSITION_ADDRESS),
travel_time_down=config[CoverConf.TRAVELLING_TIME_DOWN],
travel_time_up=config[CoverConf.TRAVELLING_TIME_UP],
invert_updown=config[CoverConf.INVERT_UPDOWN],
invert_position=config[CoverConf.INVERT_POSITION],
invert_angle=config[CoverConf.INVERT_ANGLE],
unique_id=(
f"{self._device.updown.group_address}_"
f"{self._device.position_target.group_address}"
),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self.init_base()
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.updown.group_address}_"
f"{self._device.position_target.group_address}"
)
if custom_device_class := config.get(CONF_DEVICE_CLASS):
self._attr_device_class = custom_device_class

View File

@@ -105,20 +105,21 @@ class KnxYamlDate(_KNXDate, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX date."""
self._device = XknxDateDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
)
super().__init__(
knx_module=knx_module,
device=XknxDateDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiDate(_KNXDate, KnxUiEntity):

View File

@@ -110,20 +110,21 @@ class KnxYamlDateTime(_KNXDateTime, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX datetime."""
self._device = XknxDateTimeDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
)
super().__init__(
knx_module=knx_module,
device=XknxDateTimeDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiDateTime(_KNXDateTime, KnxUiEntity):

View File

@@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any
from xknx.devices import Device as XknxDevice
from homeassistant.const import CONF_ENTITY_CATEGORY, EntityCategory
from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, EntityCategory
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import EntityPlatform
@@ -52,14 +52,11 @@ class _KnxEntityBase(Entity):
"""Representation of a KNX entity."""
_attr_should_poll = False
_attr_unique_id: str
_knx_module: KNXModule
_device: XknxDevice
@property
def name(self) -> str:
"""Return the name of the KNX device."""
return self._device.name
@property
def available(self) -> bool:
"""Return True if entity is available."""
@@ -100,16 +97,23 @@ class _KnxEntityBase(Entity):
class KnxYamlEntity(_KnxEntityBase):
"""Representation of a KNX entity configured from YAML."""
def __init__(self, knx_module: KNXModule, device: XknxDevice) -> None:
def __init__(
self,
knx_module: KNXModule,
unique_id: str,
name: str,
entity_category: EntityCategory | None,
) -> None:
"""Initialize the YAML entity."""
self._knx_module = knx_module
self._device = device
self._attr_name = name or None
self._attr_unique_id = unique_id
self._attr_entity_category = entity_category
class KnxUiEntity(_KnxEntityBase):
"""Representation of a KNX UI entity."""
_attr_unique_id: str
_attr_has_entity_name = True
def __init__(
@@ -117,6 +121,8 @@ class KnxUiEntity(_KnxEntityBase):
) -> None:
"""Initialize the UI entity."""
self._knx_module = knx_module
self._attr_name = entity_config[CONF_NAME]
self._attr_unique_id = unique_id
if entity_category := entity_config.get(CONF_ENTITY_CATEGORY):
self._attr_entity_category = EntityCategory(entity_category)

View File

@@ -208,35 +208,32 @@ class KnxYamlFan(_KnxFan, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX fan."""
max_step = config.get(FanConf.MAX_STEP)
self._device = XknxFan(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_speed=config.get(KNX_ADDRESS),
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
group_address_oscillation=config.get(FanSchema.CONF_OSCILLATION_ADDRESS),
group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
group_address_switch=config.get(FanSchema.CONF_SWITCH_ADDRESS),
group_address_switch_state=config.get(FanSchema.CONF_SWITCH_STATE_ADDRESS),
max_step=max_step,
sync_state=config.get(CONF_SYNC_STATE, True),
)
super().__init__(
knx_module=knx_module,
device=XknxFan(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address_speed=config.get(KNX_ADDRESS),
group_address_speed_state=config.get(FanSchema.CONF_STATE_ADDRESS),
group_address_oscillation=config.get(
FanSchema.CONF_OSCILLATION_ADDRESS
),
group_address_oscillation_state=config.get(
FanSchema.CONF_OSCILLATION_STATE_ADDRESS
),
group_address_switch=config.get(FanSchema.CONF_SWITCH_ADDRESS),
group_address_switch_state=config.get(
FanSchema.CONF_SWITCH_STATE_ADDRESS
),
max_step=max_step,
sync_state=config.get(CONF_SYNC_STATE, True),
unique_id=(
str(self._device.speed.group_address)
if self._device.speed.group_address
else str(self._device.switch.group_address)
),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
# FanSpeedMode.STEP if max_step is set
self._step_range: tuple[int, int] | None = (1, max_step) if max_step else None
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
if self._device.speed.group_address:
self._attr_unique_id = str(self._device.speed.group_address)
else:
self._attr_unique_id = str(self._device.switch.group_address)
class KnxUiFan(_KnxFan, KnxUiEntity):

View File

@@ -558,15 +558,16 @@ class KnxYamlLight(_KnxLight, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX light."""
self._device = _create_yaml_light(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_yaml_light(knx_module.xknx, config),
unique_id=self._device_unique_id(),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_color_mode = next(iter(self.supported_color_modes))
self._attr_max_color_temp_kelvin: int = config[LightSchema.CONF_MAX_KELVIN]
self._attr_min_color_temp_kelvin: int = config[LightSchema.CONF_MIN_KELVIN]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = self._device_unique_id()
def _device_unique_id(self) -> str:
"""Return unique id for this device."""

View File

@@ -46,12 +46,13 @@ class KNXNotify(KnxYamlEntity, NotifyEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX notification."""
self._device = _create_notification_instance(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_notification_instance(knx_module.xknx, config),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a notification to knx bus."""

View File

@@ -109,16 +109,19 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX number."""
self._device = NumericValue(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
)
super().__init__(
knx_module=knx_module,
device=NumericValue(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
),
unique_id=str(self._device.sensor_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
dpt_string = self._device.sensor_value.dpt_class.dpt_number_str()
dpt_info = get_supported_dpts()[dpt_string]
@@ -131,7 +134,6 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
dpt_info["sensor_device_class"],
),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_mode = config[CONF_MODE]
self._attr_native_max_value = config.get(
NumberConf.MAX,
@@ -149,7 +151,6 @@ class KnxYamlNumber(_KnxNumber, KnxYamlEntity):
CONF_UNIT_OF_MEASUREMENT,
dpt_info["unit"],
)
self._attr_unique_id = str(self._device.sensor_value.group_address)
self._device.sensor_value.value = max(0, self._attr_native_min_value)

View File

@@ -83,18 +83,19 @@ class KnxYamlScene(_KnxScene, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize KNX scene."""
self._device = XknxScene(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
scene_number=config[SceneSchema.CONF_SCENE_NUMBER],
)
super().__init__(
knx_module=knx_module,
device=XknxScene(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
scene_number=config[SceneSchema.CONF_SCENE_NUMBER],
unique_id=(
f"{self._device.scene_value.group_address}_{self._device.scene_number}"
),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = (
f"{self._device.scene_value.group_address}_{self._device.scene_number}"
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)

View File

@@ -199,16 +199,22 @@ class KNXPlatformSchema(ABC):
}
COMMON_ENTITY_SCHEMA = vol.Schema(
{
vol.Optional(CONF_NAME, default=""): cv.string,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
class BinarySensorSchema(KNXPlatformSchema):
"""Voluptuous schema for KNX binary sensors."""
PLATFORM = Platform.BINARY_SENSOR
DEFAULT_NAME = "KNX Binary Sensor"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Optional(CONF_IGNORE_INTERNAL_STATE, default=False): cv.boolean,
vol.Optional(CONF_INVERT, default=False): cv.boolean,
@@ -218,7 +224,6 @@ class BinarySensorSchema(KNXPlatformSchema):
),
vol.Optional(CONF_DEVICE_CLASS): BINARY_SENSOR_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_RESET_AFTER): cv.positive_float,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
)
@@ -230,7 +235,6 @@ class ButtonSchema(KNXPlatformSchema):
PLATFORM = Platform.BUTTON
CONF_VALUE = "value"
DEFAULT_NAME = "KNX Button"
payload_or_value_msg = f"Please use only one of `{CONF_PAYLOAD}` or `{CONF_VALUE}`"
length_or_type_msg = (
@@ -238,9 +242,8 @@ class ButtonSchema(KNXPlatformSchema):
)
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(KNX_ADDRESS): ga_validator,
vol.Exclusive(
CONF_PAYLOAD, "payload_or_value", msg=payload_or_value_msg
@@ -254,7 +257,6 @@ class ButtonSchema(KNXPlatformSchema):
vol.Exclusive(
CONF_TYPE, "length_or_type", msg=length_or_type_msg
): object,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
vol.Any(
@@ -322,7 +324,6 @@ class ClimateSchema(KNXPlatformSchema):
CONF_SWING_HORIZONTAL_ADDRESS = "swing_horizontal_address"
CONF_SWING_HORIZONTAL_STATE_ADDRESS = "swing_horizontal_state_address"
DEFAULT_NAME = "KNX Climate"
DEFAULT_SETPOINT_SHIFT_MODE = "DPT6010"
DEFAULT_SETPOINT_SHIFT_MAX = 6
DEFAULT_SETPOINT_SHIFT_MIN = -6
@@ -331,9 +332,8 @@ class ClimateSchema(KNXPlatformSchema):
DEFAULT_FAN_SPEED_MODE = "percent"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(
ClimateConf.SETPOINT_SHIFT_MAX, default=DEFAULT_SETPOINT_SHIFT_MAX
): vol.All(int, vol.Range(min=0, max=32)),
@@ -399,7 +399,6 @@ class ClimateSchema(KNXPlatformSchema):
): vol.Coerce(HVACMode),
vol.Optional(ClimateConf.MIN_TEMP): vol.Coerce(float),
vol.Optional(ClimateConf.MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_FAN_SPEED_ADDRESS): ga_list_validator,
vol.Optional(CONF_FAN_SPEED_STATE_ADDRESS): ga_list_validator,
vol.Optional(ClimateConf.FAN_MAX_STEP, default=3): cv.byte,
@@ -433,12 +432,10 @@ class CoverSchema(KNXPlatformSchema):
CONF_ANGLE_STATE_ADDRESS = "angle_state_address"
DEFAULT_TRAVEL_TIME = 25
DEFAULT_NAME = "KNX Cover"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_MOVE_LONG_ADDRESS): ga_list_validator,
vol.Optional(CONF_MOVE_SHORT_ADDRESS): ga_list_validator,
vol.Optional(CONF_STOP_ADDRESS): ga_list_validator,
@@ -456,7 +453,6 @@ class CoverSchema(KNXPlatformSchema):
vol.Optional(CoverConf.INVERT_POSITION, default=False): cv.boolean,
vol.Optional(CoverConf.INVERT_ANGLE, default=False): cv.boolean,
vol.Optional(CONF_DEVICE_CLASS): COVER_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
vol.Any(
@@ -481,16 +477,12 @@ class DateSchema(KNXPlatformSchema):
PLATFORM = Platform.DATE
DEFAULT_NAME = "KNX Date"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -500,16 +492,12 @@ class DateTimeSchema(KNXPlatformSchema):
PLATFORM = Platform.DATETIME
DEFAULT_NAME = "KNX DateTime"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -571,12 +559,9 @@ class FanSchema(KNXPlatformSchema):
CONF_SWITCH_ADDRESS = "switch_address"
CONF_SWITCH_STATE_ADDRESS = "switch_state_address"
DEFAULT_NAME = "KNX Fan"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_SWITCH_ADDRESS): ga_list_validator,
@@ -584,7 +569,6 @@ class FanSchema(KNXPlatformSchema):
vol.Optional(CONF_OSCILLATION_ADDRESS): ga_list_validator,
vol.Optional(CONF_OSCILLATION_STATE_ADDRESS): ga_list_validator,
vol.Optional(FanConf.MAX_STEP): cv.byte,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
}
),
@@ -629,7 +613,6 @@ class LightSchema(KNXPlatformSchema):
CONF_MIN_KELVIN = "min_kelvin"
CONF_MAX_KELVIN = "max_kelvin"
DEFAULT_NAME = "KNX Light"
DEFAULT_COLOR_TEMP_MODE = "absolute"
DEFAULT_MIN_KELVIN = 2700 # 370 mireds
DEFAULT_MAX_KELVIN = 6000 # 166 mireds
@@ -661,9 +644,8 @@ class LightSchema(KNXPlatformSchema):
)
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_BRIGHTNESS_ADDRESS): ga_list_validator,
@@ -713,7 +695,6 @@ class LightSchema(KNXPlatformSchema):
vol.Optional(CONF_MAX_KELVIN, default=DEFAULT_MAX_KELVIN): vol.All(
vol.Coerce(int), vol.Range(min=1)
),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
vol.Any(
@@ -759,14 +740,10 @@ class NotifySchema(KNXPlatformSchema):
PLATFORM = Platform.NOTIFY
DEFAULT_NAME = "KNX Notify"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_TYPE, default="latin_1"): string_type_validator,
vol.Required(KNX_ADDRESS): ga_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -775,12 +752,10 @@ class NumberSchema(KNXPlatformSchema):
"""Voluptuous schema for KNX numbers."""
PLATFORM = Platform.NUMBER
DEFAULT_NAME = "KNX Number"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_MODE, default=NumberMode.AUTO): vol.Coerce(
NumberMode
@@ -793,7 +768,6 @@ class NumberSchema(KNXPlatformSchema):
vol.Optional(NumberConf.STEP): cv.positive_float,
vol.Optional(CONF_DEVICE_CLASS): NUMBER_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
_number_limit_sub_validator,
@@ -807,15 +781,12 @@ class SceneSchema(KNXPlatformSchema):
CONF_SCENE_NUMBER = "scene_number"
DEFAULT_NAME = "KNX SCENE"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Required(SceneConf.SCENE_NUMBER): vol.All(
vol.Coerce(int), vol.Range(min=1, max=64)
),
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -827,12 +798,10 @@ class SelectSchema(KNXPlatformSchema):
CONF_OPTION = "option"
CONF_OPTIONS = "options"
DEFAULT_NAME = "KNX Select"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Required(CONF_PAYLOAD_LENGTH): vol.All(
@@ -846,7 +815,6 @@ class SelectSchema(KNXPlatformSchema):
],
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
select_options_sub_validator,
@@ -861,12 +829,10 @@ class SensorSchema(KNXPlatformSchema):
CONF_ALWAYS_CALLBACK = "always_callback"
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
CONF_SYNC_STATE = CONF_SYNC_STATE
DEFAULT_NAME = "KNX Sensor"
ENTITY_SCHEMA = vol.All(
vol.Schema(
COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Optional(CONF_ALWAYS_CALLBACK, default=False): cv.boolean,
vol.Optional(CONF_SENSOR_STATE_CLASS): STATE_CLASSES_SCHEMA,
@@ -874,7 +840,6 @@ class SensorSchema(KNXPlatformSchema):
vol.Required(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_DEVICE_CLASS): SENSOR_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
_sensor_attribute_sub_validator,
@@ -889,16 +854,13 @@ class SwitchSchema(KNXPlatformSchema):
CONF_INVERT = CONF_INVERT
CONF_STATE_ADDRESS = CONF_STATE_ADDRESS
DEFAULT_NAME = "KNX Switch"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_INVERT, default=False): cv.boolean,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_DEVICE_CLASS): SWITCH_DEVICE_CLASSES_SCHEMA,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -908,17 +870,13 @@ class TextSchema(KNXPlatformSchema):
PLATFORM = Platform.TEXT
DEFAULT_NAME = "KNX Text"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_TYPE, default="latin_1"): string_type_validator,
vol.Optional(CONF_MODE, default=TextMode.TEXT): vol.Coerce(TextMode),
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -928,16 +886,12 @@ class TimeSchema(KNXPlatformSchema):
PLATFORM = Platform.TIME
DEFAULT_NAME = "KNX Time"
ENTITY_SCHEMA = vol.Schema(
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(KNX_ADDRESS): ga_list_validator,
vol.Optional(CONF_STATE_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
)
@@ -962,27 +916,21 @@ class WeatherSchema(KNXPlatformSchema):
CONF_KNX_AIR_PRESSURE_ADDRESS = "address_air_pressure"
CONF_KNX_HUMIDITY_ADDRESS = "address_humidity"
DEFAULT_NAME = "KNX Weather Station"
ENTITY_SCHEMA = vol.All(
vol.Schema(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(CONF_KNX_TEMPERATURE_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_SOUTH_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_EAST_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_WEST_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_NORTH_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_SPEED_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_BEARING_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_RAIN_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_FROST_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_DAY_NIGHT_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_AIR_PRESSURE_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_HUMIDITY_ADDRESS): ga_list_validator,
vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA,
}
),
ENTITY_SCHEMA = COMMON_ENTITY_SCHEMA.extend(
{
vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator,
vol.Required(CONF_KNX_TEMPERATURE_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_SOUTH_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_EAST_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_WEST_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_BRIGHTNESS_NORTH_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_SPEED_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_BEARING_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_RAIN_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_FROST_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_WIND_ALARM_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_DAY_NIGHT_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_AIR_PRESSURE_ADDRESS): ga_list_validator,
vol.Optional(CONF_KNX_HUMIDITY_ADDRESS): ga_list_validator,
}
)

View File

@@ -65,9 +65,12 @@ class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX select."""
self._device = _create_raw_value(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_raw_value(knx_module.xknx, config),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._option_payloads: dict[str, int] = {
option[SelectSchema.CONF_OPTION]: option[CONF_PAYLOAD]
@@ -75,8 +78,6 @@ class KNXSelect(KnxYamlEntity, SelectEntity, RestoreEntity):
}
self._attr_options = list(self._option_payloads)
self._attr_current_option = None
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
async def async_added_to_hass(self) -> None:
"""Restore last state."""

View File

@@ -202,16 +202,19 @@ class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX sensor."""
self._device = XknxSensor(
knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
sync_state=config[CONF_SYNC_STATE],
always_callback=True,
value_type=config[CONF_TYPE],
)
super().__init__(
knx_module=knx_module,
device=XknxSensor(
knx_module.xknx,
name=config[CONF_NAME],
group_address_state=config[SensorSchema.CONF_STATE_ADDRESS],
sync_state=config[CONF_SYNC_STATE],
always_callback=True,
value_type=config[CONF_TYPE],
),
unique_id=str(self._device.sensor_value.group_address_state),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
dpt_string = self._device.sensor_value.dpt_class.dpt_number_str()
dpt_info = get_supported_dpts()[dpt_string]
@@ -220,7 +223,6 @@ class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
CONF_DEVICE_CLASS,
dpt_info["sensor_device_class"],
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_extra_state_attributes = {}
self._attr_force_update = config[SensorSchema.CONF_ALWAYS_CALLBACK]
self._attr_native_unit_of_measurement = config.get(
@@ -231,7 +233,6 @@ class KnxYamlSensor(_KnxSensor, KnxYamlEntity):
CONF_STATE_CLASS,
dpt_info["sensor_state_class"],
)
self._attr_unique_id = str(self._device.sensor_value.group_address_state)
class KnxUiSensor(_KnxSensor, KnxUiEntity):

View File

@@ -107,20 +107,21 @@ class KnxYamlSwitch(_KnxSwitch, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of KNX switch."""
self._device = XknxSwitch(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(SwitchSchema.CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
invert=config[SwitchSchema.CONF_INVERT],
)
super().__init__(
knx_module=knx_module,
device=XknxSwitch(
xknx=knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(SwitchSchema.CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
invert=config[SwitchSchema.CONF_INVERT],
),
unique_id=str(self._device.switch.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_device_class = config.get(CONF_DEVICE_CLASS)
self._attr_unique_id = str(self._device.switch.group_address)
class KnxUiSwitch(_KnxSwitch, KnxUiEntity):

View File

@@ -112,20 +112,21 @@ class KnxYamlText(_KnxText, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX text."""
self._device = XknxNotification(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
)
super().__init__(
knx_module=knx_module,
device=XknxNotification(
knx_module.xknx,
name=config[CONF_NAME],
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
value_type=config[CONF_TYPE],
),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_mode = config[CONF_MODE]
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiText(_KnxText, KnxUiEntity):

View File

@@ -105,20 +105,21 @@ class KnxYamlTime(_KNXTime, KnxYamlEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize a KNX time."""
self._device = XknxTimeDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
)
super().__init__(
knx_module=knx_module,
device=XknxTimeDevice(
knx_module.xknx,
name=config[CONF_NAME],
localtime=False,
group_address=config[KNX_ADDRESS],
group_address_state=config.get(CONF_STATE_ADDRESS),
respond_to_read=config[CONF_RESPOND_TO_READ],
sync_state=config[CONF_SYNC_STATE],
),
unique_id=str(self._device.remote_value.group_address),
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
self._attr_unique_id = str(self._device.remote_value.group_address)
class KnxUiTime(_KNXTime, KnxUiEntity):

View File

@@ -85,12 +85,13 @@ class KNXWeather(KnxYamlEntity, WeatherEntity):
def __init__(self, knx_module: KNXModule, config: ConfigType) -> None:
"""Initialize of a KNX sensor."""
self._device = _create_weather(knx_module.xknx, config)
super().__init__(
knx_module=knx_module,
device=_create_weather(knx_module.xknx, config),
unique_id=str(self._device._temperature.group_address_state), # noqa: SLF001
name=config[CONF_NAME],
entity_category=config.get(CONF_ENTITY_CATEGORY),
)
self._attr_unique_id = str(self._device._temperature.group_address_state) # noqa: SLF001
self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY)
@property
def native_temperature(self) -> float | None:

View File

@@ -41,7 +41,7 @@ class LGDevice(MediaPlayerEntity):
"""Representation of an LG soundbar device."""
_attr_should_poll = False
_attr_state = MediaPlayerState.ON
_attr_state = MediaPlayerState.OFF
_attr_supported_features = (
MediaPlayerEntityFeature.VOLUME_SET
| MediaPlayerEntityFeature.VOLUME_MUTE
@@ -79,6 +79,8 @@ class LGDevice(MediaPlayerEntity):
self._treble = 0
self._device = None
self._support_play_control = False
self._device_on = False
self._stream_type = 0
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id)}, name=host
)
@@ -113,6 +115,7 @@ class LGDevice(MediaPlayerEntity):
if "i_curr_func" in data:
self._function = data["i_curr_func"]
if "b_powerstatus" in data:
self._device_on = data["b_powerstatus"]
if data["b_powerstatus"]:
self._attr_state = MediaPlayerState.ON
else:
@@ -157,17 +160,34 @@ class LGDevice(MediaPlayerEntity):
def _update_playinfo(self, data: dict[str, Any]) -> None:
"""Update the player info."""
if "i_stream_type" in data:
if self._stream_type != data["i_stream_type"]:
self._stream_type = data["i_stream_type"]
# Ask device for current play info when stream type changed.
self._device.get_play()
if data["i_stream_type"] == 0:
# If the stream type is 0 (aka the soundbar is used as an actual soundbar)
# the last track info should be cleared and the state should only be on or off,
# as all playing/paused are not applicable in this mode
self._attr_media_image_url = None
self._attr_media_artist = None
self._attr_media_title = None
if self._device_on:
self._attr_state = MediaPlayerState.ON
else:
self._attr_state = MediaPlayerState.OFF
if "i_play_ctrl" in data:
if data["i_play_ctrl"] == 0:
self._attr_state = MediaPlayerState.PLAYING
else:
self._attr_state = MediaPlayerState.PAUSED
if self._device_on and self._stream_type != 0:
if data["i_play_ctrl"] == 0:
self._attr_state = MediaPlayerState.PLAYING
else:
self._attr_state = MediaPlayerState.PAUSED
if "s_albumart" in data:
self._attr_media_image_url = data["s_albumart"]
self._attr_media_image_url = data["s_albumart"].strip() or None
if "s_artist" in data:
self._attr_media_artist = data["s_artist"]
self._attr_media_artist = data["s_artist"].strip() or None
if "s_title" in data:
self._attr_media_title = data["s_title"]
self._attr_media_title = data["s_title"].strip() or None
if "b_support_play_ctrl" in data:
self._support_play_control = data["b_support_play_ctrl"]

View File

@@ -0,0 +1,31 @@
"""The Lichess integration."""
from __future__ import annotations
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from .coordinator import LichessConfigEntry, LichessCoordinator
_PLATFORMS: list[Platform] = [
Platform.SENSOR,
]
async def async_setup_entry(hass: HomeAssistant, entry: LichessConfigEntry) -> bool:
"""Set up Lichess from a config entry."""
coordinator = LichessCoordinator(hass, entry)
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: LichessConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS)

View File

@@ -0,0 +1,52 @@
"""Config flow for the Lichess integration."""
from __future__ import annotations
import logging
from typing import Any
from aiolichess import AioLichess
from aiolichess.exceptions import AioLichessError, AuthError
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_API_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
class LichessConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Lichess."""
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:
session = async_get_clientsession(self.hass)
client = AioLichess(session=session)
try:
user = await client.get_all(token=user_input[CONF_API_TOKEN])
except AuthError:
errors["base"] = "invalid_auth"
except AioLichessError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
username = user.username
player_id = user.id
await self.async_set_unique_id(player_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(title=username, data=user_input)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema({vol.Required(CONF_API_TOKEN): str}),
errors=errors,
)

View File

@@ -0,0 +1,3 @@
"""Constants for the Lichess integration."""
DOMAIN = "lichess"

View File

@@ -0,0 +1,44 @@
"""Coordinator for Lichess."""
from datetime import timedelta
import logging
from aiolichess import AioLichess
from aiolichess.exceptions import AioLichessError
from aiolichess.models import LichessStatistics
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_API_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
_LOGGER = logging.getLogger(__name__)
type LichessConfigEntry = ConfigEntry[LichessCoordinator]
class LichessCoordinator(DataUpdateCoordinator[LichessStatistics]):
"""Coordinator for Lichess."""
config_entry: LichessConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: LichessConfigEntry) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
config_entry=config_entry,
name=config_entry.title,
update_interval=timedelta(hours=1),
)
self.client = AioLichess(session=async_get_clientsession(hass))
async def _async_update_data(self) -> LichessStatistics:
"""Update data for Lichess."""
try:
return await self.client.get_statistics(
token=self.config_entry.data[CONF_API_TOKEN]
)
except AioLichessError as err:
raise UpdateFailed("Error in communicating with Lichess") from err

View File

@@ -0,0 +1,26 @@
"""Base entity for Lichess integration."""
from typing import TYPE_CHECKING
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import LichessCoordinator
class LichessEntity(CoordinatorEntity[LichessCoordinator]):
"""Base entity for Lichess integration."""
_attr_has_entity_name = True
def __init__(self, coordinator: LichessCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
if TYPE_CHECKING:
assert coordinator.config_entry.unique_id is not None
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, coordinator.config_entry.unique_id)},
entry_type=DeviceEntryType.SERVICE,
manufacturer="Lichess",
)

View File

@@ -0,0 +1,30 @@
{
"entity": {
"sensor": {
"blitz_games": {
"default": "mdi:chess-pawn"
},
"blitz_rating": {
"default": "mdi:chart-line"
},
"bullet_games": {
"default": "mdi:chess-pawn"
},
"bullet_rating": {
"default": "mdi:chart-line"
},
"classical_games": {
"default": "mdi:chess-pawn"
},
"classical_rating": {
"default": "mdi:chart-line"
},
"rapid_games": {
"default": "mdi:chess-pawn"
},
"rapid_rating": {
"default": "mdi:chart-line"
}
}
}
}

View File

@@ -0,0 +1,11 @@
{
"domain": "lichess",
"name": "Lichess",
"codeowners": ["@aryanhasgithub"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/lichess",
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["aiolichess==1.2.0"]
}

View File

@@ -0,0 +1,72 @@
rules:
# Bronze
action-setup:
status: exempt
comment: There are no custom actions present
appropriate-polling: done
brands: done
common-modules: done
config-flow: done
config-flow-test-coverage: done
dependency-transparency: done
docs-actions:
status: exempt
comment: There are no custom actions present
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: The entities do not explicitly subscribe to events
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: There are no custom actions
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: The integration does not use discovery
discovery:
status: exempt
comment: The integration does not use discovery
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices: todo
entity-category: done
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -0,0 +1,116 @@
"""Sensor platform for Lichess integration."""
from collections.abc import Callable
from dataclasses import dataclass
from aiolichess.models import LichessStatistics
from homeassistant.components.sensor import (
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import LichessConfigEntry
from .coordinator import LichessCoordinator
from .entity import LichessEntity
@dataclass(kw_only=True, frozen=True)
class LichessEntityDescription(SensorEntityDescription):
"""Sensor description for Lichess player."""
value_fn: Callable[[LichessStatistics], int | None]
SENSORS: tuple[LichessEntityDescription, ...] = (
LichessEntityDescription(
key="bullet_rating",
translation_key="bullet_rating",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda state: state.bullet_rating,
),
LichessEntityDescription(
key="bullet_games",
translation_key="bullet_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda state: state.bullet_games,
),
LichessEntityDescription(
key="blitz_rating",
translation_key="blitz_rating",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda state: state.blitz_rating,
),
LichessEntityDescription(
key="blitz_games",
translation_key="blitz_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda state: state.blitz_games,
),
LichessEntityDescription(
key="rapid_rating",
translation_key="rapid_rating",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda state: state.rapid_rating,
),
LichessEntityDescription(
key="rapid_games",
translation_key="rapid_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda state: state.rapid_games,
),
LichessEntityDescription(
key="classical_rating",
translation_key="classical_rating",
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda state: state.classical_rating,
),
LichessEntityDescription(
key="classical_games",
translation_key="classical_games",
state_class=SensorStateClass.TOTAL_INCREASING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda state: state.classical_games,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: LichessConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize the entries."""
coordinator = entry.runtime_data
async_add_entities(
LichessPlayerSensor(coordinator, description) for description in SENSORS
)
class LichessPlayerSensor(LichessEntity, SensorEntity):
"""Lichess sensor."""
entity_description: LichessEntityDescription
def __init__(
self,
coordinator: LichessCoordinator,
description: LichessEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}.{description.key}"
@property
def native_value(self) -> int | None:
"""Return the state of the sensor."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -0,0 +1,54 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"api_token": "[%key:common::config_flow::data::api_token%]"
},
"data_description": {
"api_token": "The Lichess API token of the player."
}
}
}
},
"entity": {
"sensor": {
"blitz_games": {
"name": "Blitz games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"blitz_rating": {
"name": "Blitz rating"
},
"bullet_games": {
"name": "Bullet games",
"unit_of_measurement": "games"
},
"bullet_rating": {
"name": "Bullet rating"
},
"classical_games": {
"name": "Classical games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"classical_rating": {
"name": "Classical rating"
},
"rapid_games": {
"name": "Rapid games",
"unit_of_measurement": "[%key:component::lichess::entity::sensor::bullet_games::unit_of_measurement%]"
},
"rapid_rating": {
"name": "Rapid rating"
}
}
}
}

View File

@@ -15,6 +15,7 @@ from pyliebherrhomeapi.exceptions import (
from homeassistant.const import CONF_API_KEY, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.event import async_track_time_interval
@@ -83,6 +84,28 @@ async def async_setup_entry(hass: HomeAssistant, entry: LiebherrConfigEntry) ->
_LOGGER.exception("Unexpected error scanning for new devices")
return
# Remove stale devices no longer returned by the API
current_device_ids = {device.device_id for device in devices}
device_registry = dr.async_get(hass)
for device_entry in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
device_ids = {
identifier[1]
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
}
if device_ids - current_device_ids:
# Shut down coordinator if one exists
for device_id in device_ids:
if coordinator := data.coordinators.pop(device_id, None):
await coordinator.async_shutdown()
device_registry.async_update_device(
device_id=device_entry.id,
remove_config_entry_id=entry.entry_id,
)
# Add new devices
new_coordinators: list[LiebherrCoordinator] = []
for device in devices:
if device.device_id not in data.coordinators:

View File

@@ -7,8 +7,8 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["pyliebherrhomeapi"],
"quality_scale": "silver",
"requirements": ["pyliebherrhomeapi==0.3.0"],
"quality_scale": "gold",
"requirements": ["pyliebherrhomeapi==0.4.0"],
"zeroconf": [
{
"name": "liebherr*",

View File

@@ -68,7 +68,7 @@ rules:
repair-issues:
status: exempt
comment: No repair issues to implement at this time.
stale-devices: todo
stale-devices: done
# Platinum
async-dependency: done

View File

@@ -199,15 +199,15 @@ class LiebherrSelectEntity(LiebherrEntity, SelectEntity):
def _select_control(self) -> SelectControl | None:
"""Get the select control for this entity."""
for control in self.coordinator.data.controls:
if not isinstance(
control,
IceMakerControl | HydroBreezeControl | BioFreshPlusControl,
):
continue
if (
isinstance(control, self.entity_description.control_type)
and control.zone_id == self._zone_id
):
if TYPE_CHECKING:
assert isinstance(
control,
IceMakerControl | HydroBreezeControl | BioFreshPlusControl,
)
return control
return None

View File

@@ -617,8 +617,10 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
pyrolytic = 323
descale = 326
evaporate_water = 327
rinse = 333
shabbat_program = 335
yom_tov = 336
hydroclean = 341
drying = 357, 2028
heat_crockery = 358
prove_dough = 359, 2023
@@ -723,7 +725,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
belgian_sponge_cake = 624
goose_unstuffed = 625
rack_of_lamb_with_vegetables = 634
yorkshire_pudding = 635
yorkshire_pudding = 635, 2352
meat_loaf = 636
defrost_meat = 647
defrost_vegetables = 654
@@ -1123,7 +1125,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
wholegrain_rice = 3376
parboiled_rice_steam_cooking = 3380
parboiled_rice_rapid_steam_cooking = 3381
basmati_rice_steam_cooking = 3383
basmati_rice_steam_cooking = 3382, 3383
basmati_rice_rapid_steam_cooking = 3384
jasmine_rice_steam_cooking = 3386
jasmine_rice_rapid_steam_cooking = 3387
@@ -1131,7 +1133,7 @@ class OvenProgramId(MieleEnum, missing_to_none=True):
huanghuanian_rapid_steam_cooking = 3390
simiao_steam_cooking = 3392
simiao_rapid_steam_cooking = 3393
long_grain_rice_general_steam_cooking = 3395
long_grain_rice_general_steam_cooking = 3394, 3395
long_grain_rice_general_rapid_steam_cooking = 3396
chongming_steam_cooking = 3398
chongming_rapid_steam_cooking = 3399

View File

@@ -560,6 +560,7 @@
"hot_water": "Hot water",
"huanghuanian_rapid_steam_cooking": "Huanghuanian (rapid steam cooking)",
"huanghuanian_steam_cooking": "Huanghuanian (steam cooking)",
"hydroclean": "HydroClean",
"hygiene": "Hygiene",
"intensive": "Intensive",
"intensive_bake": "Intensive bake",

View File

@@ -72,7 +72,7 @@
},
"services": {
"set_absolute_position": {
"description": "Sets the absolute position of the cover.",
"description": "Sets the absolute position of a cover.",
"fields": {
"absolute_position": {
"description": "Absolute position to move to.",

View File

@@ -13,7 +13,7 @@ from homeassistant.const import (
CONF_PASSWORD,
CONF_USERNAME,
)
from homeassistant.core import HomeAssistant, callback, split_entity_id
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceEntry
@@ -103,10 +103,8 @@ def _async_device_as_dict(hass: HomeAssistant, device: DeviceEntry) -> dict[str,
# The context doesn't provide useful information in this case.
state_dict.pop("context", None)
entity_domain = split_entity_id(state.entity_id)[0]
# Retract some sensitive state attributes
if entity_domain == device_tracker.DOMAIN:
if state.domain == device_tracker.DOMAIN:
state_dict["attributes"] = async_redact_data(
state_dict["attributes"], REDACT_STATE_DEVICE_TRACKER
)

View File

@@ -13,6 +13,7 @@
"documentation": "https://www.home-assistant.io/integrations/opendisplay",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["opendisplay"],
"quality_scale": "silver",
"requirements": ["py-opendisplay==5.5.0"]
}

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