Compare commits

...

67 Commits

Author SHA1 Message Date
Mike Degatano
22e7f5928e Remove aiohasupervisor from pyproject.toml 2026-03-13 23:24:14 +00:00
Raphael Hehl
a47faa3ced Add UniFi Access integration (#165404)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-14 00:00:18 +01:00
Josh
7276403ab9 Allow deleting UniFi client devices (#165505) 2026-03-13 23:06:58 +01:00
Raj Laud
018717af4f Fix victron_ble warning sensor using duplicate alarm translation key (#165502)
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-13 22:23:54 +01:00
Norbert Rittel
274c2b8092 Shorten "Power-on behavior" name in matter to be consistent (#165490) 2026-03-13 21:22:49 +01:00
David Bishop
bfe15a55c9 Add entity-unavailable and log-when-unavailable (#165486)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 20:20:55 +00:00
dvdinth
54ad67b810 Bump pyintelliclima dependency for IntelliClima integration (#165478)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2026-03-13 20:16:27 +00:00
Nathan Spencer
4d2732df6f Add diagnostics to Whisker (#165487) 2026-03-13 20:38:57 +01:00
Andres Ruiz
2be3291d8e Update brand name for Subaru integration (#165485) 2026-03-13 20:26:44 +01:00
Joost Lekkerkerker
4326cb96ea Add zigbee address to SmartThings devices (#165474)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-13 20:14:58 +01:00
Norbert Rittel
278894d4b4 Make "power-on behavior" states more consistent in tuya (#165344) 2026-03-13 18:53:32 +00:00
Ariel Ebersberger
eb17367229 Add DomainSpec to trigger and condition helpers (#165392) 2026-03-13 19:50:19 +01:00
Mike Degatano
d96191723f Improve error handling when addon unavailable for install/update (#165352) 2026-03-13 19:28:19 +01:00
mcisk
b6c7b2952e Add autoskope integration (#146772)
Co-authored-by: Franck Nijhof <git@frenck.dev>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 19:19:00 +01:00
David Bishop
356de12bce Add parallel-updates and action-exceptions for Whisker (#165433)
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 18:33:42 +01:00
epenet
57c49d0c48 Fix missing Tuya climate preset_mode (#165460) 2026-03-13 17:49:10 +01:00
Joost Lekkerkerker
af22b5fdbb Bump pySmartThings to 3.7.0 (#165468) 2026-03-13 17:12:15 +01:00
Joost Lekkerkerker
9c710961f0 Add Matter fixtures to SmartThings (#165466) 2026-03-13 17:09:38 +01:00
epenet
2a2da83173 Use external library wrapper in Tuya binary_sensor (#165465) 2026-03-13 17:05:52 +01:00
jvmahon
00a52245e3 Add Matter start-up Power-on level entity (#164775) 2026-03-13 17:04:12 +01:00
TheJulianJES
adb30e1ec1 Hide ZWA-2 adapter in Zigbee serial port selector (#155526) 2026-03-13 16:56:12 +01:00
TheJulianJES
34a7fcf8d3 Bump ZHA to 1.0.2 (#165423) 2026-03-13 16:15:51 +01:00
prana-dev-official
95a57a2984 Add fan platform for Prana Integration (#163379)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-13 16:05:37 +01:00
epenet
7f39cc0aeb Bump tuya-device-handlers to 0.0.12 (#165462) 2026-03-13 15:58:12 +01:00
Robin Lintermann
6962288e85 Add spring status sensor entity (#164332) 2026-03-13 14:29:37 +01:00
Eli Sand
fab4355cc8 Enhance generic_thermostat with min/max run time and cooldown time (#136298) 2026-03-13 14:22:33 +01:00
Robin Lintermann
e39d84e8fc Bump pysmarlaapi to 1.0.2 (#165454) 2026-03-13 12:46:09 +01:00
Christian Lackas
35f597223a Add DHW operating mode select entity to ViCare integration (#163832) 2026-03-13 12:44:24 +01:00
Galorhallen
9d61c8336d Update govee local api to 2.4.0 (#165418) 2026-03-13 12:43:41 +01:00
Robert Resch
6fd3603b7b Bump orjson to 3.11.7 (#165443) 2026-03-13 12:34:13 +01:00
epenet
49ac5c42ee Add base entity to arcam_fmj (#165447) 2026-03-13 12:27:52 +01:00
epenet
df0db5853c Fix device name in arcam_fmj (#165448) 2026-03-13 12:25:52 +01:00
dependabot[bot]
7afc5b777c Bump docker/metadata-action from 5.10.0 to 6.0.0 (#165438)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 12:25:35 +01:00
dependabot[bot]
595aeea8cc Bump github/codeql-action from 4.32.4 to 4.32.6 (#165436)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 12:22:09 +01:00
dependabot[bot]
02abba02d1 Bump docker/setup-buildx-action from 3.12.0 to 4.0.0 (#165437)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 12:21:54 +01:00
dependabot[bot]
4ca1ad96f1 Bump docker/build-push-action from 6.19.2 to 7.0.0 (#165435)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-13 12:21:20 +01:00
Erik Montnemery
9f3beba97a Fix vera test opening sockets (#165439) 2026-03-13 11:00:17 +01:00
johanzander
9f86006328 Update Growatt quality scale: add config flow data descriptions (#165426)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 08:46:14 +01:00
Erik Montnemery
4ac651d0b4 Add occupancy triggers (#165374) 2026-03-13 08:41:48 +01:00
J. Nick Koston
9e54abbcb5 Handle OAuth token request exceptions in Yale setup (#165430) 2026-03-13 08:19:24 +01:00
Erik Montnemery
d5915c8811 Add motion triggers (#165373) 2026-03-13 07:54:51 +01:00
Erik Montnemery
0c2887df9e Fix numerical entity trigger schema (#165411) 2026-03-13 07:32:43 +01:00
Zach Feldman
3767bac850 August oauth2 exception migration (#165397)
Co-authored-by: J. Nick Koston <nick@koston.org>
2026-03-12 17:28:08 -10:00
J. Nick Koston
9d962d3815 Add missing ON_OFF support and target_temperature_step to ESPHome water heater (#165427)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-12 16:10:29 -10:00
Bram Kragten
786fd40ae8 Update frontend to 20260312.0 (#165420) 2026-03-12 23:07:04 +01:00
Joakim Plate
5ec65dbd58 Remove use of media player internals in arcam (#165359) 2026-03-12 21:55:39 +00:00
Josef Zweck
35878bb203 Bump onedrive-personal-sdk to 0.1.7 (#165401) 2026-03-12 21:59:40 +01:00
Arie Catsman
e14d88ff55 Bump pyenphase to 2.4.6 (#165402) 2026-03-12 20:06:49 +00:00
Erwin Douna
d04efbfe48 Add platinum badge to Portainer (#165048)
Co-authored-by: Ariel Ebersberger <31776703+justanotherariel@users.noreply.github.com>
2026-03-12 19:30:31 +01:00
AlCalzone
3f35cd5cd2 Remove Z-Wave Installer panel (#165388)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: AlCalzone <17641229+AlCalzone@users.noreply.github.com>
2026-03-12 17:30:28 +01:00
AlCalzone
86ffd58665 Instruct AI to add type annotations to tests (#165386)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-12 17:10:30 +01:00
prana-dev-official
6206392b28 Bump prana-local-api to 0.12.0 (#165394) 2026-03-12 17:05:26 +01:00
dvdinth
b7c36c707f Add IntelliClima Sensor platform (#163901)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Norbert Rittel <norbert@rittel.de>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-12 16:33:34 +01:00
Joakim Sørensen
973c32b99d Add latency results if available to the support package (#165377) 2026-03-12 10:44:08 +01:00
Erik Montnemery
951775bea6 Add window triggers (#165230) 2026-03-12 10:18:42 +01:00
Artur Pragacz
0f2dbdf4f4 Fix logging of unavailable entities in entity call (#165370) 2026-03-12 09:53:30 +01:00
Jan-Philipp Benecke
443ff7efe1 Bump aiowebdav2 to 0.6.2 (#165353) 2026-03-12 08:17:41 +01:00
Jeef
0ee6b954df Bump intellifire4py to 4.4.0 (#165356) 2026-03-12 08:15:48 +01:00
Norbert Rittel
5681acf0e1 Sentence-case "API token" and "username/password" in growatt (#165368) 2026-03-12 07:49:35 +01:00
Andres Ruiz
a94458b8bc Bump waterfurnace version v1.6.2 (#165348) 2026-03-12 07:49:12 +01:00
Josef Zweck
f3c38ba2d3 Add "cleaning_up" stage to backup (#165349) 2026-03-12 07:28:17 +01:00
Jan Bouwhuis
c1acd1d860 Allow an MQTT entity to show as a group (#152270)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-03-11 22:25:28 +01:00
chli1
f4748aa63d fix #163316: FRITZ!SmartHome integration not showing boost status on … (#164574) 2026-03-11 22:19:43 +01:00
Brett Adams
31f4f618cc Fix duplicate energy remaining sensors in Tessie (#165102) 2026-03-11 21:39:35 +01:00
Oluwatobi Mustapha
30aec4d2ab Migrate OAuth helper token request exception handling in Google Sheets (#165000)
Signed-off-by: Oluwatobi Mustapha <oluwatobimustapha539@gmail.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 20:33:26 +01:00
AlCalzone
335abd7002 Support new Z-Wave JS "Opening state" notification variable (#165236) 2026-03-11 20:13:54 +01:00
Joakim Sørensen
3b3f0e9240 Bump hass-nabucasa from 1.15.0 to 2.0.0 (#165335) 2026-03-11 20:02:28 +01:00
265 changed files with 12482 additions and 1147 deletions

View File

@@ -18,6 +18,11 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
## Testing
When writing or modifying tests, ensure all test function parameters have type annotations.
Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
## Good practices
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.

View File

@@ -208,7 +208,7 @@ jobs:
cosign-release: "v2.5.3"
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.12.0
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Build variables
id: vars
@@ -242,7 +242,7 @@ jobs:
- name: Build base image
id: build
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: .
file: ./Dockerfile
@@ -442,7 +442,7 @@ jobs:
# 2025.12.0.dev202511250240 -> tags: 2025.12.0.dev202511250240, dev
- name: Generate Docker metadata
id: meta
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5.10.0
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with:
images: ${{ matrix.registry }}/home-assistant
sep-tags: ","
@@ -456,7 +456,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}},value=${{ needs.init.outputs.version }},enable=${{ !contains(needs.init.outputs.version, 'd') && !contains(needs.init.outputs.version, 'b') }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3.7.1
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v3.7.1
- name: Copy architecture images to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
@@ -592,7 +592,7 @@ jobs:
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build Docker image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile
@@ -605,7 +605,7 @@ jobs:
- name: Push Docker image
if: needs.init.outputs.channel != 'dev' && needs.init.outputs.publish == 'true'
id: push
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6.19.2
uses: docker/build-push-action@d08e5c354a6adb9ed34480a06d141179aa583294 # v7.0.0
with:
context: . # So action will not pull the repository again
file: ./script/hassfest/docker/Dockerfile

View File

@@ -28,11 +28,11 @@ jobs:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/init@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@89a39a4e59826350b863aa6b6252a07ad50cf83e # v4.32.4
uses: github/codeql-action/analyze@0d579ffd059c29b07949a3cce3983f0780820c98 # v4.32.6
with:
category: "/language:python"

View File

@@ -15,6 +15,11 @@ This repository contains the core of Home Assistant, a Python 3 based home autom
- Python 3.14 explicitly allows `except TypeA, TypeB:` without parentheses.
## Testing
When writing or modifying tests, ensure all test function parameters have type annotations.
Prefer concrete types (for example, `HomeAssistant`, `MockConfigEntry`, etc.) over `Any`.
## Good practices
Integrations with Platinum or Gold level in the Integration Quality Scale reflect a high standard of code quality and maintainability. When looking for examples of something, these are good places to start. The level is indicated in the manifest.json of the integration.

10
CODEOWNERS generated
View File

@@ -186,6 +186,8 @@ build.json @home-assistant/supervisor
/tests/components/auth/ @home-assistant/core
/homeassistant/components/automation/ @home-assistant/core
/tests/components/automation/ @home-assistant/core
/homeassistant/components/autoskope/ @mcisk
/tests/components/autoskope/ @mcisk
/homeassistant/components/avea/ @pattyland
/homeassistant/components/awair/ @ahayworth @ricohageman
/tests/components/awair/ @ahayworth @ricohageman
@@ -1071,6 +1073,8 @@ build.json @home-assistant/supervisor
/tests/components/moon/ @fabaff @frenck
/homeassistant/components/mopeka/ @bdraco
/tests/components/mopeka/ @bdraco
/homeassistant/components/motion/ @home-assistant/core
/tests/components/motion/ @home-assistant/core
/homeassistant/components/motion_blinds/ @starkillerOG
/tests/components/motion_blinds/ @starkillerOG
/homeassistant/components/motionblinds_ble/ @LennP @jerrybboy
@@ -1184,6 +1188,8 @@ build.json @home-assistant/supervisor
/tests/components/nzbget/ @chriscla
/homeassistant/components/obihai/ @dshokouhi @ejpenney
/tests/components/obihai/ @dshokouhi @ejpenney
/homeassistant/components/occupancy/ @home-assistant/core
/tests/components/occupancy/ @home-assistant/core
/homeassistant/components/octoprint/ @rfleming71
/tests/components/octoprint/ @rfleming71
/homeassistant/components/ohmconnect/ @robbiet480
@@ -1780,6 +1786,8 @@ build.json @home-assistant/supervisor
/tests/components/ukraine_alarm/ @PaulAnnekov
/homeassistant/components/unifi/ @Kane610
/tests/components/unifi/ @Kane610
/homeassistant/components/unifi_access/ @imhotep @RaHehl
/tests/components/unifi_access/ @imhotep @RaHehl
/homeassistant/components/unifi_direct/ @tofuSCHNITZEL
/homeassistant/components/unifiled/ @florisvdk
/homeassistant/components/unifiprotect/ @RaHehl
@@ -1905,6 +1913,8 @@ build.json @home-assistant/supervisor
/tests/components/wiffi/ @mampfes
/homeassistant/components/wilight/ @leofig-rj
/tests/components/wilight/ @leofig-rj
/homeassistant/components/window/ @home-assistant/core
/tests/components/window/ @home-assistant/core
/homeassistant/components/wirelesstag/ @sergeymaysak
/homeassistant/components/withings/ @joostlek
/tests/components/withings/ @joostlek

View File

@@ -245,6 +245,9 @@ DEFAULT_INTEGRATIONS = {
"garage_door",
"gate",
"humidity",
"motion",
"occupancy",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
# These integrations are set up if recovery mode is activated.

View File

@@ -2,6 +2,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
EntityStateConditionBase,
@@ -43,7 +44,7 @@ def make_entity_state_required_features_condition(
class CustomCondition(EntityStateRequiredFeaturesCondition):
"""Condition for entity state changes."""
_domain = domain
_domain_specs = {domain: DomainSpec()}
_states = {to_state}
_required_features = required_features

View File

@@ -2,6 +2,7 @@
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
@@ -44,7 +45,7 @@ def make_entity_state_trigger_required_features(
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""
_domains = {domain}
_domain_specs = {domain: DomainSpec()}
_to_states = {to_state}
_required_features = required_features

View File

@@ -66,6 +66,7 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
model="Arcam FMJ AVR",
name=name,
)
self.zone_unique_id = f"{unique_id}-{zone}"
if zone != 1:
self.device_info["via_device"] = (DOMAIN, unique_id)

View File

@@ -0,0 +1,20 @@
"""Base entity for Arcam FMJ integration."""
from __future__ import annotations
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import ArcamFmjCoordinator
class ArcamFmjEntity(CoordinatorEntity[ArcamFmjCoordinator]):
"""Base entity for Arcam FMJ."""
_attr_has_entity_name = True
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self._attr_device_info = coordinator.device_info
self._attr_entity_registry_enabled_default = coordinator.state.zn == 1
self._attr_unique_id = coordinator.zone_unique_id

View File

@@ -22,10 +22,10 @@ from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import EVENT_TURN_ON
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
from .entity import ArcamFmjEntity
_LOGGER = logging.getLogger(__name__)
@@ -39,14 +39,7 @@ async def async_setup_entry(
coordinators = config_entry.runtime_data.coordinators
async_add_entities(
[
ArcamFmj(
config_entry.title,
coordinators[zone],
config_entry.unique_id or config_entry.entry_id,
)
for zone in (1, 2)
],
[ArcamFmj(coordinators[zone]) for zone in (1, 2)],
)
@@ -67,21 +60,13 @@ def convert_exception[**_P, _R](
return _convert_exception
class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
class ArcamFmj(ArcamFmjEntity, MediaPlayerEntity):
"""Representation of a media device."""
_attr_has_entity_name = True
def __init__(
self,
device_name: str,
coordinator: ArcamFmjCoordinator,
uuid: str,
) -> None:
def __init__(self, coordinator: ArcamFmjCoordinator) -> None:
"""Initialize device."""
super().__init__(coordinator)
self._state = coordinator.state
self._attr_name = f"Zone {self._state.zn}"
self._attr_supported_features = (
MediaPlayerEntityFeature.SELECT_SOURCE
| MediaPlayerEntityFeature.PLAY_MEDIA
@@ -94,9 +79,6 @@ class ArcamFmj(CoordinatorEntity[ArcamFmjCoordinator], MediaPlayerEntity):
)
if self._state.zn == 1:
self._attr_supported_features |= MediaPlayerEntityFeature.SELECT_SOUND_MODE
self._attr_unique_id = f"{uuid}-{self._state.zn}"
self._attr_entity_registry_enabled_default = self._state.zn == 1
self._attr_device_info = coordinator.device_info
@property
def state(self) -> MediaPlayerState:

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from pathlib import Path
from typing import cast
from aiohttp import ClientResponseError
from aiohttp import ClientError
from yalexs.exceptions import AugustApiAIOHTTPError
from yalexs.manager.exceptions import CannotConnect, InvalidAuth, RequireValidation
from yalexs.manager.gateway import Config as YaleXSConfig
@@ -13,7 +13,12 @@ from yalexs.manager.gateway import Config as YaleXSConfig
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EVENT_HOMEASSISTANT_STOP
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
@@ -45,11 +50,18 @@ async def async_setup_entry(hass: HomeAssistant, entry: AugustConfigEntry) -> bo
august_gateway = AugustGateway(Path(hass.config.config_dir), session, oauth_session)
try:
await async_setup_august(hass, entry, august_gateway)
except OAuth2TokenRequestReauthError as err:
raise ConfigEntryAuthFailed from err
except (RequireValidation, InvalidAuth) as err:
raise ConfigEntryAuthFailed from err
except TimeoutError as err:
raise ConfigEntryNotReady("Timed out connecting to august api") from err
except (AugustApiAIOHTTPError, ClientResponseError, CannotConnect) as err:
except (
AugustApiAIOHTTPError,
OAuth2TokenRequestError,
ClientError,
CannotConnect,
) as err:
raise ConfigEntryNotReady from err
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@@ -152,6 +152,8 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"light",
"lock",
"media_player",
"motion",
"occupancy",
"person",
"remote",
"scene",
@@ -161,6 +163,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"text",
"update",
"vacuum",
"window",
}

View File

@@ -0,0 +1,53 @@
"""The Autoskope integration."""
from __future__ import annotations
import aiohttp
from autoskope_client.api import AutoskopeApi
from autoskope_client.models import CannotConnect, InvalidAuth
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers.aiohttp_client import async_create_clientsession
from .const import DEFAULT_HOST
from .coordinator import AutoskopeConfigEntry, AutoskopeDataUpdateCoordinator
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
async def async_setup_entry(hass: HomeAssistant, entry: AutoskopeConfigEntry) -> bool:
"""Set up Autoskope from a config entry."""
session = async_create_clientsession(hass, cookie_jar=aiohttp.CookieJar())
api = AutoskopeApi(
host=entry.data.get(CONF_HOST, DEFAULT_HOST),
username=entry.data[CONF_USERNAME],
password=entry.data[CONF_PASSWORD],
session=session,
)
try:
await api.connect()
except InvalidAuth as err:
# Raise ConfigEntryError until reauth flow is implemented (then ConfigEntryAuthFailed)
raise ConfigEntryError(
"Authentication failed, please check credentials"
) from err
except CannotConnect as err:
raise ConfigEntryNotReady("Could not connect to Autoskope API") from err
coordinator = AutoskopeDataUpdateCoordinator(hass, api, 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: AutoskopeConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -0,0 +1,89 @@
"""Config flow for the Autoskope integration."""
from __future__ import annotations
from typing import Any
from autoskope_client.api import AutoskopeApi
from autoskope_client.models import CannotConnect, InvalidAuth
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.data_entry_flow import section
import homeassistant.helpers.config_validation as cv
from homeassistant.helpers.selector import (
TextSelector,
TextSelectorConfig,
TextSelectorType,
)
from .const import DEFAULT_HOST, DOMAIN, SECTION_ADVANCED_SETTINGS
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME): str,
vol.Required(CONF_PASSWORD): TextSelector(
TextSelectorConfig(type=TextSelectorType.PASSWORD)
),
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Required(CONF_HOST, default=DEFAULT_HOST): TextSelector(
TextSelectorConfig(type=TextSelectorType.URL)
),
}
),
{"collapsed": True},
),
}
)
class AutoskopeConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Autoskope."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
username = user_input[CONF_USERNAME].lower()
host = user_input[SECTION_ADVANCED_SETTINGS][CONF_HOST].lower()
try:
cv.url(host)
except vol.Invalid:
errors["base"] = "invalid_url"
if not errors:
await self.async_set_unique_id(f"{username}@{host}")
self._abort_if_unique_id_configured()
try:
async with AutoskopeApi(
host=host,
username=username,
password=user_input[CONF_PASSWORD],
):
pass
except CannotConnect:
errors["base"] = "cannot_connect"
except InvalidAuth:
errors["base"] = "invalid_auth"
else:
return self.async_create_entry(
title=f"Autoskope ({username})",
data={
CONF_USERNAME: username,
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_HOST: host,
},
)
return self.async_show_form(
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
)

View File

@@ -0,0 +1,9 @@
"""Constants for the Autoskope integration."""
from datetime import timedelta
DOMAIN = "autoskope"
DEFAULT_HOST = "https://portal.autoskope.de"
SECTION_ADVANCED_SETTINGS = "advanced_settings"
UPDATE_INTERVAL = timedelta(seconds=60)

View File

@@ -0,0 +1,60 @@
"""Data update coordinator for the Autoskope integration."""
from __future__ import annotations
import logging
from autoskope_client.api import AutoskopeApi
from autoskope_client.models import CannotConnect, InvalidAuth, Vehicle
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN, UPDATE_INTERVAL
_LOGGER = logging.getLogger(__name__)
type AutoskopeConfigEntry = ConfigEntry[AutoskopeDataUpdateCoordinator]
class AutoskopeDataUpdateCoordinator(DataUpdateCoordinator[dict[str, Vehicle]]):
"""Class to manage fetching Autoskope data."""
config_entry: AutoskopeConfigEntry
def __init__(
self, hass: HomeAssistant, api: AutoskopeApi, entry: AutoskopeConfigEntry
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
config_entry=entry,
)
self.api = api
async def _async_update_data(self) -> dict[str, Vehicle]:
"""Fetch data from API endpoint."""
try:
vehicles = await self.api.get_vehicles()
return {vehicle.id: vehicle for vehicle in vehicles}
except InvalidAuth:
# Attempt to re-authenticate using stored credentials
try:
await self.api.authenticate()
# Retry the request after successful re-authentication
vehicles = await self.api.get_vehicles()
return {vehicle.id: vehicle for vehicle in vehicles}
except InvalidAuth as reauth_err:
raise ConfigEntryAuthFailed(
f"Authentication failed: {reauth_err}"
) from reauth_err
except CannotConnect as err:
raise UpdateFailed(f"Error communicating with API: {err}") from err

View File

@@ -0,0 +1,145 @@
"""Support for Autoskope device tracking."""
from __future__ import annotations
from autoskope_client.constants import MANUFACTURER
from autoskope_client.models import Vehicle
from homeassistant.components.device_tracker import SourceType, TrackerEntity
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN
from .coordinator import AutoskopeConfigEntry, AutoskopeDataUpdateCoordinator
PARALLEL_UPDATES = 0
async def async_setup_entry(
hass: HomeAssistant,
entry: AutoskopeConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Autoskope device tracker entities."""
coordinator: AutoskopeDataUpdateCoordinator = entry.runtime_data
tracked_vehicles: set[str] = set()
@callback
def update_entities() -> None:
"""Update entities based on coordinator data."""
current_vehicles = set(coordinator.data.keys())
vehicles_to_add = current_vehicles - tracked_vehicles
if vehicles_to_add:
new_entities = [
AutoskopeDeviceTracker(coordinator, vehicle_id)
for vehicle_id in vehicles_to_add
]
tracked_vehicles.update(vehicles_to_add)
async_add_entities(new_entities)
entry.async_on_unload(coordinator.async_add_listener(update_entities))
update_entities()
class AutoskopeDeviceTracker(
CoordinatorEntity[AutoskopeDataUpdateCoordinator], TrackerEntity
):
"""Representation of an Autoskope tracked device."""
_attr_has_entity_name = True
_attr_name: str | None = None
def __init__(
self, coordinator: AutoskopeDataUpdateCoordinator, vehicle_id: str
) -> None:
"""Initialize the TrackerEntity."""
super().__init__(coordinator)
self._vehicle_id = vehicle_id
self._attr_unique_id = vehicle_id
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if (
self._vehicle_id in self.coordinator.data
and (device_entry := self.device_entry) is not None
and device_entry.name != self._vehicle_data.name
):
device_registry = dr.async_get(self.hass)
device_registry.async_update_device(
device_entry.id, name=self._vehicle_data.name
)
super()._handle_coordinator_update()
@property
def device_info(self) -> DeviceInfo:
"""Return device info for the vehicle."""
vehicle = self.coordinator.data[self._vehicle_id]
return DeviceInfo(
identifiers={(DOMAIN, str(vehicle.id))},
name=vehicle.name,
manufacturer=MANUFACTURER,
model=vehicle.model,
serial_number=vehicle.imei,
)
@property
def available(self) -> bool:
"""Return if entity is available."""
return (
super().available
and self.coordinator.data is not None
and self._vehicle_id in self.coordinator.data
)
@property
def _vehicle_data(self) -> Vehicle:
"""Return the vehicle data for the current entity."""
return self.coordinator.data[self._vehicle_id]
@property
def latitude(self) -> float | None:
"""Return latitude value of the device."""
if (vehicle := self._vehicle_data) and vehicle.position:
return float(vehicle.position.latitude)
return None
@property
def longitude(self) -> float | None:
"""Return longitude value of the device."""
if (vehicle := self._vehicle_data) and vehicle.position:
return float(vehicle.position.longitude)
return None
@property
def source_type(self) -> SourceType:
"""Return the source type of the device."""
return SourceType.GPS
@property
def location_accuracy(self) -> float:
"""Return the location accuracy of the device in meters."""
if (vehicle := self._vehicle_data) and vehicle.gps_quality:
if vehicle.gps_quality > 0:
# HDOP to estimated accuracy in meters
# HDOP of 1-2 = good (5-10m), 2-5 = moderate (10-25m), >5 = poor (>25m)
return float(max(5, int(vehicle.gps_quality * 5.0)))
return 0.0
@property
def icon(self) -> str:
"""Return the icon based on the vehicle's activity."""
if self._vehicle_id not in self.coordinator.data:
return "mdi:car-clock"
vehicle = self._vehicle_data
if vehicle.position:
if vehicle.position.park_mode:
return "mdi:car-brake-parking"
if vehicle.position.speed > 5: # Moving threshold: 5 km/h
return "mdi:car-arrow-right"
return "mdi:car"
return "mdi:car-clock"

View File

@@ -0,0 +1,11 @@
{
"domain": "autoskope",
"name": "Autoskope",
"codeowners": ["@mcisk"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/autoskope",
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["autoskope_client==1.4.1"]
}

View File

@@ -0,0 +1,88 @@
# + in comment indicates requirement for quality scale
# - in comment indicates issue to be fixed, not impacting quality scale
rules:
# Bronze
action-setup:
status: exempt
comment: |
Integration does not provide 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: |
Integration does not provide custom 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:
status: exempt
comment: |
Integration does not provide custom services.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: done
reauthentication-flow:
status: todo
comment: |
Reauthentication flow removed for initial PR, will be added in follow-up.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
Integration does not use discovery. Autoskope devices use NB-IoT/LTE-M (via IoT SIMs) and LoRaWAN.
discovery:
status: exempt
comment: |
Integration does not use discovery. Autoskope devices use NB-IoT/LTE-M (via IoT SIMs) and LoRaWAN.
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
Only one entity type (device_tracker) is created, making this not applicable.
entity-translations: done
exception-translations: done
icon-translations: done
reconfiguration-flow:
status: todo
comment: |
Reconfiguration flow removed for initial PR, will be added in follow-up.
repair-issues: todo
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing:
status: todo
comment: |
Integration needs to be added to .strict-typing file for full compliance.

View File

@@ -0,0 +1,52 @@
{
"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%]",
"invalid_url": "Invalid URL",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password for your Autoskope account.",
"username": "The username for your Autoskope account."
},
"description": "Enter your Autoskope credentials.",
"sections": {
"advanced_settings": {
"data": {
"host": "API endpoint"
},
"data_description": {
"host": "The URL of your Autoskope API endpoint. Only change this if you use a white-label portal."
},
"name": "Advanced settings"
}
},
"title": "Connect to Autoskope"
}
}
},
"issues": {
"cannot_connect": {
"description": "Home Assistant could not connect to the Autoskope API at {host}. Please check the connection details and ensure the API endpoint is reachable.\n\nError: {error}",
"title": "Failed to connect to Autoskope"
},
"invalid_auth": {
"description": "Authentication with Autoskope failed for user {username}. Please re-authenticate the integration with the correct password.",
"title": "Invalid Autoskope authentication"
},
"low_battery": {
"description": "The battery voltage for vehicle {vehicle_name} ({vehicle_id}) is low ({value}V). Consider checking or replacing the battery.",
"title": "Low vehicle battery ({vehicle_name})"
}
}
}

View File

@@ -144,6 +144,7 @@ class CreateBackupStage(StrEnum):
ADDONS = "addons"
AWAIT_ADDON_RESTARTS = "await_addon_restarts"
DOCKER_CONFIG = "docker_config"
CLEANING_UP = "cleaning_up"
FINISHING_FILE = "finishing_file"
FOLDERS = "folders"
HOME_ASSISTANT = "home_assistant"
@@ -1290,6 +1291,13 @@ class BackupManager:
)
# delete old backups more numerous than copies
# try this regardless of agent errors above
self.async_on_backup_event(
CreateBackupEvent(
reason=None,
stage=CreateBackupStage.CLEANING_UP,
state=CreateBackupState.IN_PROGRESS,
)
)
await delete_backups_exceeding_configured_count(self)
finally:

View File

@@ -2,6 +2,7 @@
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
@@ -14,7 +15,7 @@ from . import DOMAIN
class ButtonPressedTrigger(EntityTriggerBase):
"""Trigger for button entity presses."""
_domains = {DOMAIN}
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:

View File

@@ -5,13 +5,14 @@ import voluptuous as vol
from homeassistant.const import ATTR_TEMPERATURE, CONF_OPTIONS
from homeassistant.core import HomeAssistant
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,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_numerical_state_attribute_changed_trigger,
make_entity_numerical_state_attribute_crossed_threshold_trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_attribute_trigger,
make_entity_target_state_trigger,
make_entity_transition_trigger,
@@ -35,7 +36,7 @@ HVAC_MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend
class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
"""Trigger for entity state changes."""
_domains = {DOMAIN}
_domain_specs = {DOMAIN: DomainSpec()}
_schema = HVAC_MODE_CHANGED_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
@@ -52,17 +53,17 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
"target_humidity_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
),
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
{DOMAIN}, {DOMAIN: ATTR_HUMIDITY}
"target_humidity_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)}
),
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
"target_temperature_changed": make_entity_numerical_state_changed_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
),
"target_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
{DOMAIN}, {DOMAIN: ATTR_TEMPERATURE}
"target_temperature_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
),
"turned_off": make_entity_target_state_trigger(DOMAIN, HVACMode.OFF),
"turned_on": make_entity_transition_trigger(

View File

@@ -516,6 +516,8 @@ class DownloadSupportPackageView(HomeAssistantView):
hass_info: dict[str, Any],
domains_info: dict[str, dict[str, str]],
) -> str:
cloud = hass.data[DATA_CLOUD]
def get_domain_table_markdown(domain_info: dict[str, Any]) -> str:
if len(domain_info) == 0:
return "No information available\n"
@@ -572,6 +574,15 @@ class DownloadSupportPackageView(HomeAssistantView):
"</details>\n\n"
)
# Add stored latency response if available
if locations := cloud.remote.latency_by_location:
markdown += "## Latency by location\n\n"
markdown += "Location | Latency (ms)\n"
markdown += "--- | ---\n"
for location in sorted(locations):
markdown += f"{location} | {locations[location]['avg'] or 'N/A'}\n"
markdown += "\n"
# Add installed packages section
try:
installed_packages = await async_get_installed_packages()

View File

@@ -13,6 +13,6 @@
"integration_type": "system",
"iot_class": "cloud_push",
"loggers": ["acme", "hass_nabucasa", "snitun"],
"requirements": ["hass-nabucasa==1.15.0", "openai==2.21.0"],
"requirements": ["hass-nabucasa==2.0.0", "openai==2.21.0"],
"single_config_entry": true
}

View File

@@ -1,81 +1,82 @@
"""Provides triggers for covers."""
from dataclasses import dataclass
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.trigger import (
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
class CoverTriggerBase(EntityTriggerBase):
@dataclass(frozen=True, slots=True)
class CoverDomainSpec(DomainSpec):
"""DomainSpec with a target value for comparison."""
target_value: str | bool | None = None
class CoverTriggerBase(EntityTriggerBase[CoverDomainSpec]):
"""Base trigger for cover state changes."""
_binary_sensor_target_state: str
_cover_is_closed_target_value: bool
_device_classes: dict[str, str]
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities by cover device class."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if get_device_class_or_undefined(self._hass, entity_id)
== self._device_classes[split_entity_id(entity_id)[0]]
}
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]]
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."""
if split_entity_id(state.entity_id)[0] == DOMAIN:
return (
state.attributes.get(ATTR_IS_CLOSED)
== self._cover_is_closed_target_value
)
return state.state == self._binary_sensor_target_state
domain_spec = self._domain_specs[split_entity_id(state.entity_id)[0]]
return self._get_value(state) == domain_spec.target_value
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the transition is valid for a cover state change."""
if from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return False
if split_entity_id(from_state.entity_id)[0] == DOMAIN:
if (from_is_closed := from_state.attributes.get(ATTR_IS_CLOSED)) is None:
return False
return from_is_closed != to_state.attributes.get(ATTR_IS_CLOSED) # type: ignore[no-any-return]
return from_state.state != to_state.state
if (from_value := self._get_value(from_state)) is None:
return False
return from_value != self._get_value(to_state)
def make_cover_opened_trigger(
*, device_classes: dict[str, str], domains: set[str] | None = None
*, device_classes: dict[str, str]
) -> type[CoverTriggerBase]:
"""Create a trigger cover_opened."""
class CoverOpenedTrigger(CoverTriggerBase):
"""Trigger for cover opened state changes."""
_binary_sensor_target_state = STATE_ON
_cover_is_closed_target_value = False
_domains = domains or {DOMAIN}
_device_classes = device_classes
_domain_specs = {
domain: CoverDomainSpec(
device_class=dc,
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
target_value=False if domain == DOMAIN else STATE_ON,
)
for domain, dc in device_classes.items()
}
return CoverOpenedTrigger
def make_cover_closed_trigger(
*, device_classes: dict[str, str], domains: set[str] | None = None
*, device_classes: dict[str, str]
) -> type[CoverTriggerBase]:
"""Create a trigger cover_closed."""
class CoverClosedTrigger(CoverTriggerBase):
"""Trigger for cover closed state changes."""
_binary_sensor_target_state = STATE_OFF
_cover_is_closed_target_value = True
_domains = domains or {DOMAIN}
_device_classes = device_classes
_domain_specs = {
domain: CoverDomainSpec(
device_class=dc,
value_source=ATTR_IS_CLOSED if domain == DOMAIN else None,
target_value=True if domain == DOMAIN else STATE_OFF,
)
for domain, dc in device_classes.items()
}
return CoverClosedTrigger

View File

@@ -20,14 +20,8 @@ DEVICE_CLASSES_DOOR: dict[str, str] = {
TRIGGERS: dict[str, type[Trigger]] = {
"opened": make_cover_opened_trigger(
device_classes=DEVICE_CLASSES_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"closed": make_cover_closed_trigger(
device_classes=DEVICE_CLASSES_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_DOOR),
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_DOOR),
}

View File

@@ -8,7 +8,7 @@
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"quality_scale": "platinum",
"requirements": ["pyenphase==2.4.5"],
"requirements": ["pyenphase==2.4.6"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@@ -5,7 +5,13 @@ from __future__ import annotations
from functools import partial
from typing import Any
from aioesphomeapi import EntityInfo, WaterHeaterInfo, WaterHeaterMode, WaterHeaterState
from aioesphomeapi import (
EntityInfo,
WaterHeaterFeature,
WaterHeaterInfo,
WaterHeaterMode,
WaterHeaterState,
)
from homeassistant.components.water_heater import (
WaterHeaterEntity,
@@ -54,6 +60,7 @@ class EsphomeWaterHeater(
static_info = self._static_info
self._attr_min_temp = static_info.min_temperature
self._attr_max_temp = static_info.max_temperature
self._attr_target_temperature_step = static_info.target_temperature_step
features = WaterHeaterEntityFeature.TARGET_TEMPERATURE
if static_info.supported_modes:
features |= WaterHeaterEntityFeature.OPERATION_MODE
@@ -63,6 +70,8 @@ class EsphomeWaterHeater(
]
else:
self._attr_operation_list = None
if static_info.supported_features & WaterHeaterFeature.SUPPORTS_ON_OFF:
features |= WaterHeaterEntityFeature.ON_OFF
self._attr_supported_features = features
@property
@@ -101,6 +110,24 @@ class EsphomeWaterHeater(
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the water heater on."""
self._client.water_heater_command(
key=self._key,
on=True,
device_id=self._static_info.device_id,
)
@convert_api_error_ha_error
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the water heater off."""
self._client.water_heater_command(
key=self._key,
on=False,
device_id=self._static_info.device_id,
)
async_setup_entry = partial(
platform_async_setup_entry,

View File

@@ -179,7 +179,9 @@ class FritzboxThermostat(FritzBoxDeviceEntity, ClimateEntity):
return PRESET_HOLIDAY
if self.data.summer_active:
return PRESET_SUMMER
if self.data.target_temperature == ON_API_TEMPERATURE:
if self.data.target_temperature == ON_API_TEMPERATURE or getattr(
self.data, "boost_active", False
):
return PRESET_BOOST
if self.data.target_temperature == self.data.comfort_temperature:
return PRESET_COMFORT

View File

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

View File

@@ -20,14 +20,8 @@ DEVICE_CLASSES_GARAGE_DOOR: dict[str, str] = {
TRIGGERS: dict[str, type[Trigger]] = {
"opened": make_cover_opened_trigger(
device_classes=DEVICE_CLASSES_GARAGE_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"closed": make_cover_closed_trigger(
device_classes=DEVICE_CLASSES_GARAGE_DOOR,
domains={BINARY_SENSOR_DOMAIN, COVER_DOMAIN},
),
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GARAGE_DOOR),
}

View File

@@ -12,7 +12,7 @@ from homeassistant.helpers.helper_integration import (
async_remove_helper_config_entry_from_source_device,
)
from .const import CONF_HEATER, CONF_SENSOR, PLATFORMS
from .const import CONF_DUR_COOLDOWN, CONF_HEATER, CONF_MIN_DUR, CONF_SENSOR, PLATFORMS
_LOGGER = logging.getLogger(__name__)
@@ -91,8 +91,13 @@ async def async_migrate_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
helper_config_entry_id=config_entry.entry_id,
source_device_id=source_device_id,
)
if config_entry.minor_version < 3:
# Set `cycle_cooldown` to `min_cycle_duration` to mimic the old behavior
if CONF_MIN_DUR in options:
options[CONF_DUR_COOLDOWN] = options[CONF_MIN_DUR]
hass.config_entries.async_update_entry(
config_entry, options=options, minor_version=2
config_entry, options=options, minor_version=3
)
_LOGGER.debug(

View File

@@ -5,6 +5,7 @@ from __future__ import annotations
import asyncio
from collections.abc import Mapping
from datetime import datetime, timedelta
from functools import partial
import logging
import math
from typing import Any
@@ -38,7 +39,9 @@ from homeassistant.const import (
UnitOfTemperature,
)
from homeassistant.core import (
CALLBACK_TYPE,
DOMAIN as HOMEASSISTANT_DOMAIN,
Context,
CoreState,
Event,
EventStateChangedData,
@@ -46,27 +49,30 @@ from homeassistant.core import (
State,
callback,
)
from homeassistant.exceptions import ConditionError
from homeassistant.helpers import condition, config_validation as cv
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.device import async_entity_id_to_device
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.event import (
async_call_later,
async_track_state_change_event,
async_track_time_interval,
)
from homeassistant.helpers.reload import async_setup_reload_service
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolDictType
from homeassistant.util import dt as dt_util
from .const import (
CONF_AC_MODE,
CONF_COLD_TOLERANCE,
CONF_DUR_COOLDOWN,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_KEEP_ALIVE,
CONF_MAX_DUR,
CONF_MAX_TEMP,
CONF_MIN_DUR,
CONF_MIN_TEMP,
@@ -98,6 +104,8 @@ PLATFORM_SCHEMA_COMMON = vol.Schema(
vol.Optional(CONF_AC_MODE): cv.boolean,
vol.Optional(CONF_MAX_TEMP): vol.Coerce(float),
vol.Optional(CONF_MIN_DUR): cv.positive_time_period,
vol.Optional(CONF_MAX_DUR): cv.positive_time_period,
vol.Optional(CONF_DUR_COOLDOWN): cv.positive_time_period,
vol.Optional(CONF_MIN_TEMP): vol.Coerce(float),
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_COLD_TOLERANCE, default=DEFAULT_TOLERANCE): vol.Coerce(float),
@@ -167,6 +175,8 @@ async def _async_setup_config(
target_temp: float | None = config.get(CONF_TARGET_TEMP)
ac_mode: bool | None = config.get(CONF_AC_MODE)
min_cycle_duration: timedelta | None = config.get(CONF_MIN_DUR)
max_cycle_duration: timedelta | None = config.get(CONF_MAX_DUR)
cycle_cooldown: timedelta | None = config.get(CONF_DUR_COOLDOWN)
cold_tolerance: float = config[CONF_COLD_TOLERANCE]
hot_tolerance: float = config[CONF_HOT_TOLERANCE]
keep_alive: timedelta | None = config.get(CONF_KEEP_ALIVE)
@@ -190,6 +200,8 @@ async def _async_setup_config(
target_temp=target_temp,
ac_mode=ac_mode,
min_cycle_duration=min_cycle_duration,
max_cycle_duration=max_cycle_duration,
cycle_cooldown=cycle_cooldown,
cold_tolerance=cold_tolerance,
hot_tolerance=hot_tolerance,
keep_alive=keep_alive,
@@ -221,6 +233,8 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
target_temp: float | None,
ac_mode: bool | None,
min_cycle_duration: timedelta | None,
max_cycle_duration: timedelta | None,
cycle_cooldown: timedelta | None,
cold_tolerance: float,
hot_tolerance: float,
keep_alive: timedelta | None,
@@ -240,8 +254,16 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
heater_entity_id,
)
self.ac_mode = ac_mode
self.min_cycle_duration = min_cycle_duration
self.min_cycle_duration = min_cycle_duration or timedelta()
self.max_cycle_duration = max_cycle_duration
self.cycle_cooldown = cycle_cooldown or timedelta()
self._cold_tolerance = cold_tolerance
# Subtract the cooldown so it doesn't impact startup
self._last_toggled_time = dt_util.utcnow() - self.cycle_cooldown
self._cycle_callback: CALLBACK_TYPE | None = None
self._check_callback: CALLBACK_TYPE | None = None
# Context ID used to detect our own toggles
self._last_context_id: str | None = None
self._hot_tolerance = hot_tolerance
self._keep_alive = keep_alive
self._hvac_mode = initial_hvac_mode
@@ -289,6 +311,7 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
self.hass, [self.heater_entity_id], self._async_switch_changed
)
)
self.async_on_remove(self._cancel_timers)
if self._keep_alive:
self.async_on_remove(
@@ -482,6 +505,18 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
self.hass.async_create_task(
self._check_switch_initial_state(), eager_start=True
)
# Update timestamp on toggle
self._last_toggled_time = new_state.last_changed
# If the user toggles the switch, assume they want control and clear the timers.
# Note: If a manual interaction occurs within the 2s context window of a switch
# toggle initiated by us, we may not detect manual control. Users are advised to
# use the climate entity for reliable control, not the switch entity.
if new_state.context.id != self._last_context_id:
_LOGGER.debug("External switch change detected, clearing timers")
self._last_context_id = None
self._cancel_timers()
self.async_write_ha_state()
@callback
@@ -517,57 +552,69 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
if not self._active or self._hvac_mode == HVACMode.OFF:
return
# If the `force` argument is True, we
# ignore `min_cycle_duration`.
# If the `time` argument is not none, we were invoked for
# keep-alive purposes, and `min_cycle_duration` is irrelevant.
if not force and time is None and self.min_cycle_duration:
if self._is_device_active:
current_state = STATE_ON
else:
current_state = HVACMode.OFF
try:
long_enough = condition.state(
self.hass,
self.heater_entity_id,
current_state,
self.min_cycle_duration,
)
except ConditionError:
long_enough = False
if not long_enough:
return
if force and time is not None and self.max_cycle_duration:
# We were invoked due to `max_cycle_duration`, so turn off
_LOGGER.debug(
"Turning off heater %s due to max cycle time of %s",
self.heater_entity_id,
self.max_cycle_duration,
)
self._cancel_cycle_timer()
await self._async_heater_turn_off()
return
assert self._cur_temp is not None and self._target_temp is not None
min_temp = self._target_temp - self._cold_tolerance
max_temp = self._target_temp + self._hot_tolerance
too_cold = self._target_temp > self._cur_temp + self._cold_tolerance
too_hot = self._target_temp < self._cur_temp - self._hot_tolerance
now = dt_util.utcnow()
if self._is_device_active:
if (self.ac_mode and self._cur_temp <= min_temp) or (
not self.ac_mode and self._cur_temp >= max_temp
):
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
await self._async_heater_turn_off()
if (self.ac_mode and too_cold) or (not self.ac_mode and too_hot):
# Make sure it's past the `min_cycle_duration` before turning off
if (
self._last_toggled_time + self.min_cycle_duration <= now
or force
):
_LOGGER.debug("Turning off heater %s", self.heater_entity_id)
await self._async_heater_turn_off()
elif self._check_callback is None:
_LOGGER.debug(
"Minimum cycle time not reached, check again at %s",
self._last_toggled_time + self.min_cycle_duration,
)
self._check_callback = async_call_later(
self.hass,
now - self._last_toggled_time + self.min_cycle_duration,
self._async_timer_control_heating,
)
elif time is not None:
# The time argument is passed only in keep-alive case
# This is a keep-alive call, so ensure it's on
_LOGGER.debug(
"Keep-alive - Turning on heater heater %s",
"Keep-alive - Turning on heater %s",
self.heater_entity_id,
)
await self._async_heater_turn_on(keepalive=True)
elif (self.ac_mode and too_hot) or (not self.ac_mode and too_cold):
# Make sure it's past the `cycle_cooldown` before turning on
if self._last_toggled_time + self.cycle_cooldown <= now or force:
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
await self._async_heater_turn_on()
elif (self.ac_mode and self._cur_temp > max_temp) or (
not self.ac_mode and self._cur_temp < min_temp
):
_LOGGER.debug("Turning on heater %s", self.heater_entity_id)
await self._async_heater_turn_on()
elif self._check_callback is None:
_LOGGER.debug(
"Cooldown time not reached, check again at %s",
self._last_toggled_time + self.cycle_cooldown,
)
self._check_callback = async_call_later(
self.hass,
now - self._last_toggled_time + self.cycle_cooldown,
self._async_timer_control_heating,
)
elif time is not None:
# The time argument is passed only in keep-alive case
# This is a keep-alive call, so ensure it's off
_LOGGER.debug(
"Keep-alive - Turning off heater %s", self.heater_entity_id
)
await self._async_heater_turn_off()
await self._async_heater_turn_off(keepalive=True)
@property
def _is_device_active(self) -> bool | None:
@@ -577,19 +624,48 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
return self.hass.states.is_state(self.heater_entity_id, STATE_ON)
async def _async_heater_turn_on(self) -> None:
async def _async_heater_turn_on(self, keepalive: bool = False) -> None:
"""Turn heater toggleable device on."""
data = {ATTR_ENTITY_ID: self.heater_entity_id}
# Create a new context for this service call so we can identify
# the resulting state change event as originating from us
new_context = Context(parent_id=self._context.id if self._context else None)
self.async_set_context(new_context)
self._last_context_id = new_context.id
await self.hass.services.async_call(
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=self._context
HOMEASSISTANT_DOMAIN, SERVICE_TURN_ON, data, context=new_context
)
if not keepalive:
# Update timestamp on turn on
self._last_toggled_time = dt_util.utcnow()
self._cancel_check_timer()
if self.max_cycle_duration:
_LOGGER.debug(
"Scheduling maximum run-time shut-off for %s",
self._last_toggled_time + self.max_cycle_duration,
)
self._cancel_cycle_timer()
self._cycle_callback = async_call_later(
self.hass,
self.max_cycle_duration,
partial(self._async_control_heating, force=True),
)
async def _async_heater_turn_off(self) -> None:
async def _async_heater_turn_off(self, keepalive: bool = False) -> None:
"""Turn heater toggleable device off."""
data = {ATTR_ENTITY_ID: self.heater_entity_id}
# Create a new context for this service call so we can identify
# the resulting state change event as originating from us
new_context = Context(parent_id=self._context.id if self._context else None)
self.async_set_context(new_context)
self._last_context_id = new_context.id
await self.hass.services.async_call(
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=self._context
HOMEASSISTANT_DOMAIN, SERVICE_TURN_OFF, data, context=new_context
)
if not keepalive:
# Update timestamp on turn off
self._last_toggled_time = dt_util.utcnow()
self._cancel_timers()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new preset mode."""
@@ -613,3 +689,30 @@ class GenericThermostat(ClimateEntity, RestoreEntity):
await self._async_control_heating(force=True)
self.async_write_ha_state()
async def _async_timer_control_heating(self, _: datetime | None = None) -> None:
"""Reset check timer and control heating."""
self._check_callback = None
await self._async_control_heating()
@callback
def _cancel_check_timer(self) -> None:
"""Reset check timer."""
if self._check_callback:
_LOGGER.debug("Cancelling scheduled state check")
self._check_callback()
self._check_callback = None
@callback
def _cancel_cycle_timer(self) -> None:
"""Reset cycle timer."""
if self._cycle_callback:
_LOGGER.debug("Cancelling scheduled shut-off")
self._cycle_callback()
self._cycle_callback = None
@callback
def _cancel_timers(self) -> None:
"""Reset timers."""
self._cancel_check_timer()
self._cancel_cycle_timer()

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
from collections.abc import Mapping
from datetime import timedelta
from typing import Any, cast
import voluptuous as vol
@@ -12,16 +13,20 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDevic
from homeassistant.const import CONF_NAME, DEGREE
from homeassistant.helpers import selector
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
SchemaFlowError,
SchemaFlowFormStep,
)
from .const import (
CONF_AC_MODE,
CONF_COLD_TOLERANCE,
CONF_DUR_COOLDOWN,
CONF_HEATER,
CONF_HOT_TOLERANCE,
CONF_KEEP_ALIVE,
CONF_MAX_DUR,
CONF_MAX_TEMP,
CONF_MIN_DUR,
CONF_MIN_TEMP,
@@ -63,6 +68,12 @@ OPTIONS_SCHEMA = {
vol.Optional(CONF_KEEP_ALIVE): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
vol.Optional(CONF_MAX_DUR): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
vol.Optional(CONF_DUR_COOLDOWN): selector.DurationSelector(
selector.DurationSelectorConfig(allow_negative=False)
),
vol.Optional(CONF_MIN_TEMP): selector.NumberSelector(
selector.NumberSelectorConfig(
mode=selector.NumberSelectorMode.BOX, unit_of_measurement=DEGREE, step=0.1
@@ -90,13 +101,31 @@ CONFIG_SCHEMA = {
}
async def _validate_config(
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
) -> dict[str, Any]:
"""Validate config."""
if all(x in user_input for x in (CONF_MIN_DUR, CONF_MAX_DUR)):
min_cycle = timedelta(**user_input[CONF_MIN_DUR])
max_cycle = timedelta(**user_input[CONF_MAX_DUR])
if min_cycle >= max_cycle:
raise SchemaFlowError("min_max_runtime")
return user_input
CONFIG_FLOW = {
"user": SchemaFlowFormStep(vol.Schema(CONFIG_SCHEMA), next_step="presets"),
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
}
OPTIONS_FLOW = {
"init": SchemaFlowFormStep(vol.Schema(OPTIONS_SCHEMA), next_step="presets"),
"init": SchemaFlowFormStep(
vol.Schema(OPTIONS_SCHEMA),
validate_user_input=_validate_config,
next_step="presets",
),
"presets": SchemaFlowFormStep(vol.Schema(PRESETS_SCHEMA)),
}
@@ -104,7 +133,7 @@ OPTIONS_FLOW = {
class ConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN):
"""Handle a config or options flow."""
MINOR_VERSION = 2
MINOR_VERSION = 3
config_flow = CONFIG_FLOW
options_flow = OPTIONS_FLOW

View File

@@ -20,6 +20,8 @@ CONF_HEATER = "heater"
CONF_HOT_TOLERANCE = "hot_tolerance"
CONF_MAX_TEMP = "max_temp"
CONF_MIN_DUR = "min_cycle_duration"
CONF_MAX_DUR = "max_cycle_duration"
CONF_DUR_COOLDOWN = "cycle_cooldown"
CONF_MIN_TEMP = "min_temp"
CONF_PRESETS = {
p: f"{p}_temp"

View File

@@ -16,11 +16,13 @@
"data": {
"ac_mode": "Cooling mode",
"cold_tolerance": "Cold tolerance",
"cycle_cooldown": "Cooldown period after running",
"heater": "Actuator switch",
"hot_tolerance": "Hot tolerance",
"keep_alive": "Keep-alive interval",
"max_cycle_duration": "Maximum run time",
"max_temp": "Maximum target temperature",
"min_cycle_duration": "Minimum cycle duration",
"min_cycle_duration": "Minimum run time",
"min_temp": "Minimum target temperature",
"name": "[%key:common::config_flow::data::name%]",
"target_sensor": "Temperature sensor"
@@ -28,10 +30,12 @@
"data_description": {
"ac_mode": "Set the actuator specified to be treated as a cooling device instead of a heating device.",
"cold_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched on. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will start when the sensor goes below 24.5.",
"cycle_cooldown": "After switching off, the minimum amount of time that must elapse before it can be switched back on.",
"heater": "Switch entity used to cool or heat depending on A/C mode.",
"hot_tolerance": "Minimum amount of difference between the temperature read by the temperature sensor the target temperature that must change prior to being switched off. For example, if the target temperature is 25 and the tolerance is 0.5 the heater will stop when the sensor equals or goes above 25.5.",
"keep_alive": "Trigger the heater periodically to keep devices from losing state. When set, min cycle duration is ignored.",
"min_cycle_duration": "Set a minimum amount of time that the switch specified must be in its current state prior to being switched either off or on.",
"keep_alive": "Trigger the heater periodically to keep devices from losing state.",
"max_cycle_duration": "Once switched on, the maximum amount of time that can elapse before it will be switched off.",
"min_cycle_duration": "Once switched on, the minimum amount of time that must elapse before it may be switched off.",
"target_sensor": "Temperature sensor that reflects the current temperature."
},
"description": "Create a climate entity that controls the temperature via a switch and sensor.",
@@ -40,14 +44,19 @@
}
},
"options": {
"error": {
"min_max_runtime": "Minimum run time must be less than the maximum run time."
},
"step": {
"init": {
"data": {
"ac_mode": "[%key:component::generic_thermostat::config::step::user::data::ac_mode%]",
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data::cold_tolerance%]",
"cycle_cooldown": "[%key:component::generic_thermostat::config::step::user::data::cycle_cooldown%]",
"heater": "[%key:component::generic_thermostat::config::step::user::data::heater%]",
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data::hot_tolerance%]",
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data::keep_alive%]",
"max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::max_cycle_duration%]",
"max_temp": "[%key:component::generic_thermostat::config::step::user::data::max_temp%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data::min_cycle_duration%]",
"min_temp": "[%key:component::generic_thermostat::config::step::user::data::min_temp%]",
@@ -56,9 +65,11 @@
"data_description": {
"ac_mode": "[%key:component::generic_thermostat::config::step::user::data_description::ac_mode%]",
"cold_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::cold_tolerance%]",
"cycle_cooldown": "[%key:component::generic_thermostat::config::step::user::data_description::cycle_cooldown%]",
"heater": "[%key:component::generic_thermostat::config::step::user::data_description::heater%]",
"hot_tolerance": "[%key:component::generic_thermostat::config::step::user::data_description::hot_tolerance%]",
"keep_alive": "[%key:component::generic_thermostat::config::step::user::data_description::keep_alive%]",
"max_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::max_cycle_duration%]",
"min_cycle_duration": "[%key:component::generic_thermostat::config::step::user::data_description::min_cycle_duration%]",
"target_sensor": "[%key:component::generic_thermostat::config::step::user::data_description::target_sensor%]"
}

View File

@@ -7,7 +7,12 @@ import aiohttp
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.config_entry_oauth2_flow import (
OAuth2Session,
@@ -39,11 +44,11 @@ async def async_setup_entry(
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except aiohttp.ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
) from err
except OAuth2TokenRequestReauthError as err:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauth required"
) from err
except OAuth2TokenRequestError as err:
raise ConfigEntryNotReady from err
except aiohttp.ClientError as err:
raise ConfigEntryNotReady from err

View File

@@ -6,5 +6,5 @@
"dependencies": ["network"],
"documentation": "https://www.home-assistant.io/integrations/govee_light_local",
"iot_class": "local_push",
"requirements": ["govee-local-api==2.3.0"]
"requirements": ["govee-local-api==2.4.0"]
}

View File

@@ -5,9 +5,7 @@ rules:
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow:
status: todo
comment: data-descriptions missing
config-flow: done
dependency-transparency: done
docs-actions: done
docs-high-level-description: done
@@ -25,7 +23,7 @@ rules:
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: todo
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
@@ -55,7 +53,7 @@ rules:
status: todo
comment: Replace custom precision field with suggested_display_precision to preserve full data granularity.
entity-disabled-by-default: todo
entity-translations: todo
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo

View File

@@ -17,12 +17,20 @@
"region": "Server region",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "The password for your Growatt account.",
"region": "The server region that matches your Growatt account location.",
"username": "The email address or username for your Growatt account."
},
"title": "Enter your Growatt login credentials"
},
"plant": {
"data": {
"plant_id": "Plant"
},
"data_description": {
"plant_id": "The Growatt plant (solar installation) to integrate."
},
"title": "Select your plant"
},
"reauth_confirm": {
@@ -32,22 +40,32 @@
"token": "[%key:component::growatt_server::config::step::token_auth::data::token%]",
"username": "[%key:common::config_flow::data::username%]"
},
"data_description": {
"password": "[%key:component::growatt_server::config::step::password_auth::data_description::password%]",
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
"token": "[%key:component::growatt_server::config::step::token_auth::data_description::token%]",
"username": "[%key:component::growatt_server::config::step::password_auth::data_description::username%]"
},
"description": "Re-enter your credentials to continue using this integration.",
"title": "Re-authenticate with Growatt"
},
"token_auth": {
"data": {
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
"token": "API Token"
"token": "API token"
},
"data_description": {
"region": "[%key:component::growatt_server::config::step::password_auth::data_description::region%]",
"token": "The API token for your Growatt account. You can generate one via the Growatt web portal or ShinePhone app."
},
"description": "Token authentication is only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"title": "Enter your API token"
},
"user": {
"description": "Note: API Token authentication is currently only supported for MIN/TLX devices. For other device types, please use Username & Password authentication.",
"description": "Note: Token authentication is currently only supported for MIN/TLX devices. For other device types, please use username/password authentication.",
"menu_options": {
"password_auth": "Username & Password",
"token_auth": "API Token (MIN/TLX only)"
"password_auth": "Username/password",
"token_auth": "API token (MIN/TLX only)"
},
"title": "Choose authentication method"
}

View File

@@ -10,7 +10,11 @@ from functools import partial, wraps
import logging
from typing import Any, Concatenate
from aiohasupervisor import SupervisorError
from aiohasupervisor import (
AddonNotSupportedError,
SupervisorError,
SupervisorNotFoundError,
)
from aiohasupervisor.models import (
AddonsOptions,
AddonState as SupervisorAddonState,
@@ -165,15 +169,7 @@ class AddonManager:
)
addon_info = await self._supervisor_client.addons.addon_info(self.addon_slug)
addon_state = self.async_get_addon_state(addon_info)
return AddonInfo(
available=addon_info.available,
hostname=addon_info.hostname,
options=addon_info.options,
state=addon_state,
update_available=addon_info.update_available,
version=addon_info.version,
)
return self._async_convert_installed_addon_info(addon_info)
@callback
def async_get_addon_state(self, addon_info: InstalledAddonComplete) -> AddonState:
@@ -189,6 +185,20 @@ class AddonManager:
return addon_state
@callback
def _async_convert_installed_addon_info(
self, addon_info: InstalledAddonComplete
) -> AddonInfo:
"""Convert InstalledAddonComplete model to AddonInfo model."""
return AddonInfo(
available=addon_info.available,
hostname=addon_info.hostname,
options=addon_info.options,
state=self.async_get_addon_state(addon_info),
update_available=addon_info.update_available,
version=addon_info.version,
)
@api_error(
"Failed to set the {addon_name} app options",
expected_error_type=SupervisorError,
@@ -199,21 +209,17 @@ class AddonManager:
self.addon_slug, AddonsOptions(config=config)
)
def _check_addon_available(self, addon_info: AddonInfo) -> None:
"""Check if the managed add-on is available."""
if not addon_info.available:
raise AddonError(f"{self.addon_name} app is not available")
@api_error(
"Failed to install the {addon_name} app", expected_error_type=SupervisorError
)
async def async_install_addon(self) -> None:
"""Install the managed add-on."""
addon_info = await self.async_get_addon_info()
self._check_addon_available(addon_info)
await self._supervisor_client.store.install_addon(self.addon_slug)
try:
await self._supervisor_client.store.install_addon(self.addon_slug)
except AddonNotSupportedError as err:
raise AddonError(
f"{self.addon_name} app is not available: {err!s}"
) from None
@api_error(
"Failed to uninstall the {addon_name} app",
@@ -226,17 +232,29 @@ class AddonManager:
@api_error("Failed to update the {addon_name} app")
async def async_update_addon(self) -> None:
"""Update the managed add-on if needed."""
addon_info = await self.async_get_addon_info()
self._check_addon_available(addon_info)
if addon_info.state is AddonState.NOT_INSTALLED:
raise AddonError(f"{self.addon_name} app is not installed")
try:
# Not using async_get_addon_info here because it would make an unnecessary
# call to /store/addon/{slug}/info. This will raise if the addon is not
# installed so one call to /addon/{slug}/info is all that is needed
addon_info = await self._supervisor_client.addons.addon_info(
self.addon_slug
)
except SupervisorNotFoundError:
raise AddonError(f"{self.addon_name} app is not installed") from None
if not addon_info.update_available:
return
await self.async_create_backup()
try:
await self._supervisor_client.store.addon_availability(self.addon_slug)
except AddonNotSupportedError as err:
raise AddonError(
f"{self.addon_name} app is not available: {err!s}"
) from None
await self.async_create_backup(
addon_info=self._async_convert_installed_addon_info(addon_info)
)
await self._supervisor_client.store.update_addon(
self.addon_slug, StoreAddonUpdate(backup=False)
)
@@ -266,10 +284,14 @@ class AddonManager:
"Failed to create a backup of the {addon_name} app",
expected_error_type=SupervisorError,
)
async def async_create_backup(self) -> None:
async def async_create_backup(self, *, addon_info: AddonInfo | None = None) -> None:
"""Create a partial backup of the managed add-on."""
addon_info = await self.async_get_addon_info()
name = f"addon_{self.addon_slug}_{addon_info.version}"
if addon_info:
addon_version = addon_info.version
else:
addon_version = (await self.async_get_addon_info()).version
name = f"addon_{self.addon_slug}_{addon_version}"
self._logger.debug("Creating backup: %s", name)
await self._supervisor_client.backups.partial_backup(

View File

@@ -15,50 +15,43 @@ from homeassistant.components.weather import (
ATTR_WEATHER_HUMIDITY,
DOMAIN as WEATHER_DOMAIN,
)
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateAttributeChangedTriggerBase,
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
class _HumidityTriggerMixin(EntityTriggerBase):
"""Mixin for humidity triggers providing entity filtering and value extraction."""
_attributes = {
CLIMATE_DOMAIN: CLIMATE_ATTR_CURRENT_HUMIDITY,
HUMIDIFIER_DOMAIN: HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
SENSOR_DOMAIN: None, # Use state.state
WEATHER_DOMAIN: ATTR_WEATHER_HUMIDITY,
}
_domains = {SENSOR_DOMAIN, CLIMATE_DOMAIN, HUMIDIFIER_DOMAIN, WEATHER_DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities: all climate/humidifier/weather, sensor only with device_class humidity."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] != SENSOR_DOMAIN
or get_device_class_or_undefined(self._hass, entity_id)
== SensorDeviceClass.HUMIDITY
}
HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
CLIMATE_DOMAIN: NumericalDomainSpec(
value_source=CLIMATE_ATTR_CURRENT_HUMIDITY,
),
HUMIDIFIER_DOMAIN: NumericalDomainSpec(
value_source=HUMIDIFIER_ATTR_CURRENT_HUMIDITY,
),
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.HUMIDITY,
),
WEATHER_DOMAIN: NumericalDomainSpec(
value_source=ATTR_WEATHER_HUMIDITY,
),
}
class HumidityChangedTrigger(
_HumidityTriggerMixin, EntityNumericalStateAttributeChangedTriggerBase
):
class HumidityChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for humidity value changes across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
class HumidityCrossedThresholdTrigger(
_HumidityTriggerMixin, EntityNumericalStateAttributeCrossedThresholdTriggerBase
EntityNumericalStateAttributeCrossedThresholdTriggerBase
):
"""Trigger for humidity value crossing a threshold across multiple domains."""
_domain_specs = HUMIDITY_DOMAIN_SPECS
TRIGGERS: dict[str, type[Trigger]] = {
"changed": HumidityChangedTrigger,

View File

@@ -9,7 +9,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import LOGGER
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
PLATFORMS = [Platform.FAN, Platform.SELECT]
PLATFORMS = [Platform.FAN, Platform.SELECT, Platform.SENSOR]
async def async_setup_entry(

View File

@@ -74,7 +74,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
"""Return the current speed percentage."""
device_data = self._device_data
if device_data.speed_set == FanSpeed.auto:
if device_data.speed_set == FanSpeed.auto_get:
return None
return ranged_value_to_percentage(self._speed_range, int(device_data.speed_set))
@@ -92,7 +92,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
if device_data.mode_set == FanMode.off:
return None
if (
device_data.speed_set == FanSpeed.auto
device_data.speed_set == FanSpeed.auto_get
and device_data.mode_set == FanMode.sensor
):
return "auto"
@@ -111,7 +111,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
infinitely.
"""
percentage = 25 if percentage == 0 else percentage
await self.async_set_mode_speed(fan_mode=preset_mode, percentage=percentage)
await self.async_set_mode_speed(preset_mode=preset_mode, percentage=percentage)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn off the fan."""
@@ -124,10 +124,10 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set preset mode."""
await self.async_set_mode_speed(fan_mode=preset_mode)
await self.async_set_mode_speed(preset_mode=preset_mode)
async def async_set_mode_speed(
self, fan_mode: str | None = None, percentage: int | None = None
self, preset_mode: str | None = None, percentage: int | None = None
) -> None:
"""Set mode and speed.
@@ -137,7 +137,7 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
percentage = self.percentage if percentage is None else percentage
percentage = 25 if percentage is None else percentage
if fan_mode == "auto":
if preset_mode == "auto":
# auto is a special case with special mode and speed setting
await self.coordinator.api.ecocomfort.set_mode_speed_auto(self._device_sn)
await self.coordinator.async_request_refresh()
@@ -148,21 +148,20 @@ class IntelliClimaVMCFan(IntelliClimaECOEntity, FanEntity):
return
# Determine the fan mode
if fan_mode is not None:
# Set to requested fan_mode
mode = fan_mode
elif not self.is_on:
if not self.is_on:
# Default to alternate fan mode if not turned on
mode = FanMode.alternate
else:
# Maintain current mode
mode = self._device_data.mode_set
speed = str(
math.ceil(
percentage_to_ranged_value(
self._speed_range,
percentage,
speed = FanSpeed(
str(
math.ceil(
percentage_to_ranged_value(
self._speed_range,
percentage,
)
)
)
)

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["pyintelliclima==0.2.2"]
"requirements": ["pyintelliclima==0.3.1"]
}

View File

@@ -68,12 +68,12 @@ class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity):
# If in auto mode (sensor mode with auto speed), return None (handled by fan entity preset mode)
if (
device_data.speed_set == FanSpeed.auto
device_data.speed_set == FanSpeed.auto_get
and device_data.mode_set == FanMode.sensor
):
return None
return INTELLICLIMA_MODE_TO_FAN_MODE.get(FanMode(device_data.mode_set))
return INTELLICLIMA_MODE_TO_FAN_MODE.get(device_data.mode_set)
async def async_select_option(self, option: str) -> None:
"""Set the fan mode."""
@@ -83,7 +83,7 @@ class IntelliClimaVMCFanModeSelect(IntelliClimaECOEntity, SelectEntity):
# Determine speed: keep current speed if available, otherwise default to sleep
if (
device_data.speed_set == FanSpeed.auto
device_data.speed_set == FanSpeed.auto_get
or device_data.mode_set == FanMode.off
):
speed = FanSpeed.sleep

View File

@@ -0,0 +1,101 @@
"""Sensor platform for IntelliClima VMC."""
from collections.abc import Callable
from dataclasses import dataclass
from pyintelliclima.intelliclima_types import IntelliClimaECO
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import IntelliClimaConfigEntry, IntelliClimaCoordinator
from .entity import IntelliClimaECOEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class IntelliClimaSensorEntityDescription(SensorEntityDescription):
"""Describes a sensor entity."""
value_fn: Callable[[IntelliClimaECO], int | float | str | None]
INTELLICLIMA_SENSORS: tuple[IntelliClimaSensorEntityDescription, ...] = (
IntelliClimaSensorEntityDescription(
key="temperature",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
value_fn=lambda device_data: float(device_data.tamb),
),
IntelliClimaSensorEntityDescription(
key="humidity",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.HUMIDITY,
native_unit_of_measurement=PERCENTAGE,
value_fn=lambda device_data: float(device_data.rh),
),
IntelliClimaSensorEntityDescription(
key="voc",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
value_fn=lambda device_data: float(device_data.voc_state),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: IntelliClimaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up a IntelliClima Sensors."""
coordinator = entry.runtime_data
entities: list[IntelliClimaSensor] = [
IntelliClimaSensor(
coordinator=coordinator, device=ecocomfort2, description=description
)
for ecocomfort2 in coordinator.data.ecocomfort2_devices.values()
for description in INTELLICLIMA_SENSORS
]
async_add_entities(entities)
class IntelliClimaSensor(IntelliClimaECOEntity, SensorEntity):
"""Extends IntelliClimaEntity with Sensor specific logic."""
entity_description: IntelliClimaSensorEntityDescription
def __init__(
self,
coordinator: IntelliClimaCoordinator,
device: IntelliClimaECO,
description: IntelliClimaSensorEntityDescription,
) -> None:
"""Class initializer."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{device.id}_{description.key}"
@property
def native_value(self) -> int | float | str | None:
"""Use this to get the correct value."""
return self.entity_description.value_fn(self._device_data)

View File

@@ -6,7 +6,7 @@
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
"no_devices": "No IntelliClima devices found in your account",
"no_devices": "No supported IntelliClima devices were found in your account",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {

View File

@@ -12,5 +12,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["intellifire4py"],
"requirements": ["intellifire4py==4.3.1"]
"requirements": ["intellifire4py==4.4.0"]
}

View File

@@ -4,6 +4,7 @@ from typing import Any
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.trigger import (
EntityNumericalStateAttributeChangedTriggerBase,
EntityNumericalStateAttributeCrossedThresholdTriggerBase,
@@ -20,13 +21,18 @@ def _convert_uint8_to_percentage(value: Any) -> float:
return (float(value) / 255.0) * 100.0
BRIGHTNESS_DOMAIN_SPECS = {
DOMAIN: NumericalDomainSpec(
value_source=ATTR_BRIGHTNESS,
value_converter=_convert_uint8_to_percentage,
),
}
class BrightnessChangedTrigger(EntityNumericalStateAttributeChangedTriggerBase):
"""Trigger for brightness changed."""
_domains = {DOMAIN}
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
_converter = staticmethod(_convert_uint8_to_percentage)
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
class BrightnessCrossedThresholdTrigger(
@@ -34,9 +40,7 @@ class BrightnessCrossedThresholdTrigger(
):
"""Trigger for brightness crossed threshold."""
_domains = {DOMAIN}
_attributes = {DOMAIN: ATTR_BRIGHTNESS}
_converter = staticmethod(_convert_uint8_to_percentage)
_domain_specs = BRIGHTNESS_DOMAIN_SPECS
TRIGGERS: dict[str, type[Trigger]] = {

View File

@@ -20,6 +20,8 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class RobotBinarySensorEntityDescription(

View File

@@ -14,7 +14,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
@@ -71,6 +73,7 @@ class LitterRobotButtonEntity(LitterRobotEntity[_WhiskerEntityT], ButtonEntity):
entity_description: RobotButtonEntityDescription[_WhiskerEntityT]
@whisker_command
async def async_press(self) -> None:
"""Press the button."""
await self.entity_description.press_fn(self.robot)

View File

@@ -46,11 +46,18 @@ class LitterRobotDataUpdateCoordinator(DataUpdateCoordinator[None]):
async def _async_update_data(self) -> None:
"""Update all device states from the Litter-Robot API."""
await self.account.refresh_robots()
await self.account.load_pets()
for pet in self.account.pets:
# Need to fetch weight history for `get_visits_since`
await pet.fetch_weight_history()
try:
await self.account.refresh_robots()
await self.account.load_pets()
for pet in self.account.pets:
# Need to fetch weight history for `get_visits_since`
await pet.fetch_weight_history()
except LitterRobotLoginException as ex:
raise ConfigEntryAuthFailed("Invalid credentials") from ex
except LitterRobotException as ex:
raise UpdateFailed(
f"Unable to fetch data from the Whisker API: {ex}"
) from ex
async def _async_setup(self) -> None:
"""Set up the coordinator."""

View File

@@ -0,0 +1,24 @@
"""Diagnostics support for Litter-Robot."""
from __future__ import annotations
from typing import Any
from pylitterbot.utils import REDACT_FIELDS
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import LitterRobotConfigEntry
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: LitterRobotConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
account = entry.runtime_data.account
data = {
"robots": [robot.to_dict() for robot in account.robots],
"pets": [pet.to_dict() for pet in account.pets],
}
return async_redact_data(data, REDACT_FIELDS)

View File

@@ -2,11 +2,14 @@
from __future__ import annotations
from typing import Generic, TypeVar
from collections.abc import Awaitable, Callable, Coroutine
from typing import Any, Concatenate, Generic, TypeVar
from pylitterbot import Pet, Robot
from pylitterbot.exceptions import LitterRobotException
from pylitterbot.robot import EVENT_UPDATE
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
@@ -17,6 +20,26 @@ from .coordinator import LitterRobotDataUpdateCoordinator
_WhiskerEntityT = TypeVar("_WhiskerEntityT", bound=Robot | Pet)
def whisker_command[_WhiskerEntityT2: LitterRobotEntity, **_P](
func: Callable[Concatenate[_WhiskerEntityT2, _P], Awaitable[None]],
) -> Callable[Concatenate[_WhiskerEntityT2, _P], Coroutine[Any, Any, None]]:
"""Wrap a Whisker command to handle exceptions."""
async def handler(
self: _WhiskerEntityT2, *args: _P.args, **kwargs: _P.kwargs
) -> None:
try:
await func(self, *args, **kwargs)
except LitterRobotException as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="command_failed",
translation_placeholders={"error": str(ex)},
) from ex
return handler
def get_device_info(whisker_entity: Robot | Pet) -> DeviceInfo:
"""Get device info for a robot or pet."""
if isinstance(whisker_entity, Robot):

View File

@@ -23,16 +23,16 @@ rules:
unique-config-entry: done
# Silver
action-exceptions: todo
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters:
status: done
comment: No options to configure
docs-installation-parameters: done
entity-unavailable: todo
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates: todo
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage:
status: todo
@@ -42,7 +42,7 @@ rules:
# Gold
devices: done
diagnostics: todo
diagnostics: done
discovery-update-info:
status: done
comment: The integration is cloud-based

View File

@@ -15,7 +15,9 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import LitterRobotConfigEntry, LitterRobotDataUpdateCoordinator
from .entity import LitterRobotEntity, _WhiskerEntityT
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
PARALLEL_UPDATES = 1
_CastTypeT = TypeVar("_CastTypeT", int, float, str)
@@ -154,6 +156,7 @@ class LitterRobotSelectEntity(
"""Return the selected entity option to represent the entity state."""
return str(self.entity_description.current_fn(self.robot))
@whisker_command
async def async_select_option(self, option: str) -> None:
"""Change the selected option."""
await self.entity_description.select_fn(self.robot, option)

View File

@@ -23,6 +23,8 @@ from homeassistant.util import dt as dt_util
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
PARALLEL_UPDATES = 0
def icon_for_gauge_level(gauge_level: int | None = None, offset: int = 0) -> str:
"""Return a gauge icon valid identifier."""

View File

@@ -195,6 +195,14 @@
}
}
},
"exceptions": {
"command_failed": {
"message": "An error occurred while communicating with the device: {error}"
},
"firmware_update_failed": {
"message": "Unable to start firmware update on {name}"
}
},
"issues": {
"deprecated_entity": {
"description": "The Litter-Robot entity `{entity}` is deprecated and will be removed in a future release.\nPlease update your dashboards, automations and scripts, disable `{entity}` and reload the integration/restart Home Assistant to fix this issue.",

View File

@@ -25,7 +25,9 @@ from homeassistant.helpers.issue_registry import (
from .const import DOMAIN
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
@@ -135,10 +137,12 @@ class RobotSwitchEntity(LitterRobotEntity[_WhiskerEntityT], SwitchEntity):
"""Return true if switch is on."""
return self.entity_description.value_fn(self.robot)
@whisker_command
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
await self.entity_description.set_fn(self.robot, True)
@whisker_command
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
await self.entity_description.set_fn(self.robot, False)

View File

@@ -16,7 +16,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity, _WhiskerEntityT
from .entity import LitterRobotEntity, _WhiskerEntityT, whisker_command
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
@@ -74,6 +76,7 @@ class LitterRobotTimeEntity(LitterRobotEntity[_WhiskerEntityT], TimeEntity):
"""Return the value reported by the time."""
return self.entity_description.value_fn(self.robot)
@whisker_command
async def async_set_value(self, value: time) -> None:
"""Update the current value."""
await self.entity_description.set_fn(self.robot, value)

View File

@@ -17,8 +17,11 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity
from .entity import LitterRobotEntity, whisker_command
PARALLEL_UPDATES = 1
SCAN_INTERVAL = timedelta(days=1)
@@ -80,11 +83,15 @@ class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity):
latest_version = self.robot.firmware
self._attr_latest_version = latest_version
@whisker_command
async def async_install(
self, version: str | None, backup: bool, **kwargs: Any
) -> None:
"""Install an update."""
if await self.robot.has_firmware_update(True):
if not await self.robot.update_firmware():
message = f"Unable to start firmware update on {self.robot.name}"
raise HomeAssistantError(message)
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="firmware_update_failed",
translation_placeholders={"name": self.robot.name},
)

View File

@@ -19,7 +19,9 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util import dt as dt_util
from .coordinator import LitterRobotConfigEntry
from .entity import LitterRobotEntity
from .entity import LitterRobotEntity, whisker_command
PARALLEL_UPDATES = 1
LITTER_BOX_STATUS_STATE_MAP = {
LitterBoxStatus.CLEAN_CYCLE: VacuumActivity.CLEANING,
@@ -66,15 +68,18 @@ class LitterRobotCleaner(LitterRobotEntity[LitterRobot], StateVacuumEntity):
"""Return the state of the cleaner."""
return LITTER_BOX_STATUS_STATE_MAP.get(self.robot.status, VacuumActivity.ERROR)
@whisker_command
async def async_start(self) -> None:
"""Start a clean cycle."""
await self.robot.set_power_status(True)
await self.robot.start_cleaning()
@whisker_command
async def async_stop(self, **kwargs: Any) -> None:
"""Stop the vacuum cleaner."""
await self.robot.set_power_status(False)
@whisker_command
async def async_set_sleep_mode(
self, enabled: bool, start_time: str | None = None
) -> None:

View File

@@ -187,6 +187,27 @@ DISCOVERY_SCHEMAS = [
# allow None value to account for 'default' value
allow_none_value=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(
key="power_on_level",
entity_category=EntityCategory.CONFIG,
translation_key="power_on_level",
native_max_value=255,
native_min_value=0,
mode=NumberMode.BOX,
# use 255 to indicate that the value should revert to the default
device_to_ha=lambda x: 255 if x is None else x,
ha_to_device=lambda x: None if x == 255 else int(x),
native_step=1,
native_unit_of_measurement=None,
),
entity_class=MatterNumber,
required_attributes=(clusters.LevelControl.Attributes.StartUpCurrentLevel,),
not_device_type=(device_types.Speaker,),
# allow None value to account for 'default' value
allow_none_value=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(

View File

@@ -238,6 +238,9 @@
"on_transition_time": {
"name": "On transition time"
},
"power_on_level": {
"name": "Power-on level"
},
"pump_setpoint": {
"name": "Setpoint"
},
@@ -322,11 +325,11 @@
}
},
"startup_on_off": {
"name": "Power-on behavior on startup",
"name": "Power-on behavior",
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]",
"previous": "Previous",
"previous": "Previous state",
"toggle": "[%key:common::action::toggle%]"
}
},

View File

@@ -0,0 +1,17 @@
"""Integration for motion triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "motion"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -0,0 +1,10 @@
{
"triggers": {
"cleared": {
"trigger": "mdi:motion-sensor-off"
},
"detected": {
"trigger": "mdi:motion-sensor"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"domain": "motion",
"name": "Motion",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/motion",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,38 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted motion sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Motion",
"triggers": {
"cleared": {
"description": "Triggers after one or more motion sensors stop detecting motion.",
"fields": {
"behavior": {
"description": "[%key:component::motion::common::trigger_behavior_description%]",
"name": "[%key:component::motion::common::trigger_behavior_name%]"
}
},
"name": "Motion cleared"
},
"detected": {
"description": "Triggers after one or more motion sensors start detecting motion.",
"fields": {
"behavior": {
"description": "[%key:component::motion::common::trigger_behavior_description%]",
"name": "[%key:component::motion::common::trigger_behavior_name%]"
}
},
"name": "Motion detected"
}
}
}

View File

@@ -0,0 +1,45 @@
"""Provides triggers for motion."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
)
class _MotionBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for motion binary sensor state changes."""
_domain_specs = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.MOTION)
}
class MotionDetectedTrigger(_MotionBinaryTriggerBase, EntityTargetStateTriggerBase):
"""Trigger for motion detected (binary sensor ON)."""
_to_states = {STATE_ON}
class MotionClearedTrigger(_MotionBinaryTriggerBase, EntityTargetStateTriggerBase):
"""Trigger for motion cleared (binary sensor OFF)."""
_to_states = {STATE_OFF}
TRIGGERS: dict[str, type[Trigger]] = {
"detected": MotionDetectedTrigger,
"cleared": MotionClearedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for motion."""
return TRIGGERS

View File

@@ -0,0 +1,25 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
detected:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: motion
cleared:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: motion

View File

@@ -72,6 +72,7 @@ ABBREVIATIONS = {
"fan_mode_stat_t": "fan_mode_state_topic",
"frc_upd": "force_update",
"g_tpl": "green_template",
"grp": "group",
"hs_cmd_t": "hs_command_topic",
"hs_cmd_tpl": "hs_command_template",
"hs_stat_t": "hs_state_topic",

View File

@@ -10,6 +10,7 @@ from homeassistant.helpers import config_validation as cv
from .const import (
CONF_COMMAND_TOPIC,
CONF_ENCODING,
CONF_GROUP,
CONF_QOS,
CONF_RETAIN,
CONF_STATE_TOPIC,
@@ -23,6 +24,7 @@ from .util import valid_publish_topic, valid_qos_schema, valid_subscribe_topic
SCHEMA_BASE = {
vol.Optional(CONF_QOS, default=DEFAULT_QOS): valid_qos_schema,
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string,
vol.Optional(CONF_GROUP): vol.All(cv.ensure_list, [cv.string]),
}
MQTT_BASE_SCHEMA = vol.Schema(SCHEMA_BASE)

View File

@@ -109,6 +109,7 @@ CONF_FLASH_TIME_SHORT = "flash_time_short"
CONF_GET_POSITION_TEMPLATE = "position_template"
CONF_GET_POSITION_TOPIC = "position_topic"
CONF_GREEN_TEMPLATE = "green_template"
CONF_GROUP = "group"
CONF_HS_COMMAND_TEMPLATE = "hs_command_template"
CONF_HS_COMMAND_TOPIC = "hs_command_topic"
CONF_HS_STATE_TOPIC = "hs_state_topic"

View File

@@ -48,6 +48,7 @@ from homeassistant.helpers.event import (
async_track_device_registry_updated_event,
async_track_entity_registry_updated_event,
)
from homeassistant.helpers.group import IntegrationSpecificGroup
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
from homeassistant.helpers.service_info.mqtt import ReceivePayloadType
from homeassistant.helpers.typing import (
@@ -78,6 +79,7 @@ from .const import (
CONF_ENABLED_BY_DEFAULT,
CONF_ENCODING,
CONF_ENTITY_PICTURE,
CONF_GROUP,
CONF_HW_VERSION,
CONF_IDENTIFIERS,
CONF_JSON_ATTRS_TEMPLATE,
@@ -133,6 +135,7 @@ MQTT_ATTRIBUTES_BLOCKED = {
"device_class",
"device_info",
"entity_category",
"entity_id",
"entity_picture",
"entity_registry_enabled_default",
"extra_state_attributes",
@@ -460,7 +463,7 @@ def async_setup_entity_entry_helper(
class MqttAttributesMixin(Entity):
"""Mixin used for platforms that support JSON attributes."""
"""Mixin used for platforms that support JSON attributes and group entities."""
_attributes_extra_blocked: frozenset[str] = frozenset()
_attr_tpl: Callable[[ReceivePayloadType], ReceivePayloadType] | None = None
@@ -468,10 +471,13 @@ class MqttAttributesMixin(Entity):
[MessageCallbackType, set[str] | None, ReceiveMessage], None
]
_process_update_extra_state_attributes: Callable[[dict[str, Any]], None]
group: IntegrationSpecificGroup | None
def __init__(self, config: ConfigType) -> None:
"""Initialize the JSON attributes mixin."""
"""Initialize the JSON attributes and handle group entities."""
self._attributes_sub_state: dict[str, EntitySubscription] = {}
if CONF_GROUP in config:
self.group = IntegrationSpecificGroup(self, config[CONF_GROUP])
self._attributes_config = config
async def async_added_to_hass(self) -> None:
@@ -482,6 +488,16 @@ class MqttAttributesMixin(Entity):
def attributes_prepare_discovery_update(self, config: DiscoveryInfoType) -> None:
"""Handle updated discovery message."""
if CONF_GROUP in config:
if self.group is not None:
self.group.member_unique_ids = config[CONF_GROUP]
else:
_LOGGER.info(
"Group member update received for entity %s, "
"but this entity was not initialized with the `group` option. "
"Reload the MQTT integration or restart Home Assistant to activate"
)
self._attributes_config = config
self._attributes_prepare_subscribe_topics()
@@ -543,7 +559,7 @@ class MqttAttributesMixin(Entity):
_LOGGER.warning("Erroneous JSON: %s", payload)
else:
if isinstance(json_dict, dict):
filtered_dict = {
filtered_dict: dict[str, Any] = {
k: v
for k, v in json_dict.items()
if k not in MQTT_ATTRIBUTES_BLOCKED

View File

@@ -0,0 +1,17 @@
"""Integration for occupancy triggers."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
DOMAIN = "occupancy"
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
__all__ = []
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the component."""
return True

View File

@@ -0,0 +1,10 @@
{
"triggers": {
"cleared": {
"trigger": "mdi:home-outline"
},
"detected": {
"trigger": "mdi:home-account"
}
}
}

View File

@@ -0,0 +1,8 @@
{
"domain": "occupancy",
"name": "Occupancy",
"codeowners": ["@home-assistant/core"],
"documentation": "https://www.home-assistant.io/integrations/occupancy",
"integration_type": "system",
"quality_scale": "internal"
}

View File

@@ -0,0 +1,38 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted occupancy sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Occupancy",
"triggers": {
"cleared": {
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::occupancy::common::trigger_behavior_description%]",
"name": "[%key:component::occupancy::common::trigger_behavior_name%]"
}
},
"name": "Occupancy cleared"
},
"detected": {
"description": "Triggers after one or more occupancy sensors start detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::occupancy::common::trigger_behavior_description%]",
"name": "[%key:component::occupancy::common::trigger_behavior_name%]"
}
},
"name": "Occupancy detected"
}
}
}

View File

@@ -0,0 +1,49 @@
"""Provides triggers for occupancy."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
)
class _OccupancyBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for occupancy binary sensor state changes."""
_domain_specs = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.OCCUPANCY)
}
class OccupancyDetectedTrigger(
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
):
"""Trigger for occupancy detected (binary sensor ON)."""
_to_states = {STATE_ON}
class OccupancyClearedTrigger(
_OccupancyBinaryTriggerBase, EntityTargetStateTriggerBase
):
"""Trigger for occupancy cleared (binary sensor OFF)."""
_to_states = {STATE_OFF}
TRIGGERS: dict[str, type[Trigger]] = {
"detected": OccupancyDetectedTrigger,
"cleared": OccupancyClearedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for occupancy."""
return TRIGGERS

View File

@@ -0,0 +1,25 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
detected:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: occupancy
cleared:
fields: *trigger_common_fields
target:
entity:
- domain: binary_sensor
device_class: occupancy

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.1.6"]
"requirements": ["onedrive-personal-sdk==0.1.7"]
}

View File

@@ -10,5 +10,5 @@
"iot_class": "cloud_polling",
"loggers": ["onedrive_personal_sdk"],
"quality_scale": "platinum",
"requirements": ["onedrive-personal-sdk==0.1.6"]
"requirements": ["onedrive-personal-sdk==0.1.7"]
}

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/portainer",
"integration_type": "service",
"iot_class": "local_polling",
"quality_scale": "bronze",
"quality_scale": "platinum",
"requirements": ["pyportainer==1.0.33"]
}

View File

@@ -14,13 +14,11 @@ from .coordinator import PranaConfigEntry, PranaCoordinator
_LOGGER = logging.getLogger(__name__)
# Keep platforms sorted alphabetically to satisfy lint rule
PLATFORMS = [Platform.SWITCH]
PLATFORMS = [Platform.FAN, Platform.SWITCH]
async def async_setup_entry(hass: HomeAssistant, entry: PranaConfigEntry) -> bool:
"""Set up Prana from a config entry."""
coordinator = PranaCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator

View File

@@ -5,7 +5,7 @@ from typing import Any
from prana_local_api_client.exceptions import PranaApiCommunicationError
from prana_local_api_client.models.prana_device_info import PranaDeviceInfo
from prana_local_api_client.prana_api_client import PranaLocalApiClient
from prana_local_api_client.prana_local_api_client import PranaLocalApiClient
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult

View File

@@ -12,7 +12,7 @@ from prana_local_api_client.exceptions import (
)
from prana_local_api_client.models.prana_device_info import PranaDeviceInfo
from prana_local_api_client.models.prana_state import PranaState
from prana_local_api_client.prana_api_client import PranaLocalApiClient
from prana_local_api_client.prana_local_api_client import PranaLocalApiClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST

View File

@@ -0,0 +1,186 @@
"""Fan platform for Prana integration."""
from collections.abc import Callable
from dataclasses import dataclass
import math
from typing import Any
from prana_local_api_client.models.prana_state import FanState
from homeassistant.components.fan import (
FanEntity,
FanEntityDescription,
FanEntityFeature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.percentage import (
percentage_to_ranged_value,
ranged_value_to_percentage,
)
from homeassistant.util.scaling import int_states_in_range
from . import PranaConfigEntry
from .entity import PranaBaseEntity, PranaCoordinator, PranaEntityDescription, StrEnum
PARALLEL_UPDATES = 1
# The Prana device API expects fan speed values in scaled units (tenths of a speed step)
# rather than the raw step value used internally by this integration. This factor is
# applied when sending speeds to the API to match its expected units.
PRANA_SPEED_MULTIPLIER = 10
class PranaFanType(StrEnum):
"""Enumerates Prana fan types exposed by the device API."""
SUPPLY = "supply"
EXTRACT = "extract"
BOUNDED = "bounded"
@dataclass(frozen=True, kw_only=True)
class PranaFanEntityDescription(FanEntityDescription, PranaEntityDescription):
"""Description of a Prana fan entity."""
value_fn: Callable[[PranaCoordinator], FanState]
speed_range: Callable[[PranaCoordinator], tuple[int, int]]
ENTITIES: tuple[PranaEntityDescription, ...] = (
PranaFanEntityDescription(
key=PranaFanType.SUPPLY,
translation_key="supply",
value_fn=lambda coord: (
coord.data.supply if not coord.data.bound else coord.data.bounded
),
speed_range=lambda coord: (
1,
coord.data.supply.max_speed
if not coord.data.bound
else coord.data.bounded.max_speed,
),
),
PranaFanEntityDescription(
key=PranaFanType.EXTRACT,
translation_key="extract",
value_fn=lambda coord: (
coord.data.extract if not coord.data.bound else coord.data.bounded
),
speed_range=lambda coord: (
1,
coord.data.extract.max_speed
if not coord.data.bound
else coord.data.bounded.max_speed,
),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: PranaConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Prana fan entities from a config entry."""
async_add_entities(
PranaFan(entry.runtime_data, entity_description)
for entity_description in ENTITIES
)
class PranaFan(PranaBaseEntity, FanEntity):
"""Representation of a Prana fan entity."""
entity_description: PranaFanEntityDescription
_attr_preset_modes = ["night", "boost"]
_attr_supported_features = (
FanEntityFeature.SET_SPEED
| FanEntityFeature.TURN_ON
| FanEntityFeature.TURN_OFF
| FanEntityFeature.PRESET_MODE
)
@property
def _api_target_key(self) -> str:
"""Return the correct target key for API commands based on bounded state."""
# If the device is in bound mode, both supply and extract fans control the same bounded fan speeds.
if self.coordinator.data.bound:
return PranaFanType.BOUNDED
# Otherwise, return the specific fan type (supply or extract) for API commands.
return self.entity_description.key
@property
def speed_count(self) -> int:
"""Return the number of speeds the fan supports."""
return int_states_in_range(
self.entity_description.speed_range(self.coordinator)
)
@property
def percentage(self) -> int | None:
"""Return the current fan speed percentage."""
current_speed = self.entity_description.value_fn(self.coordinator).speed
return ranged_value_to_percentage(
self.entity_description.speed_range(self.coordinator), current_speed
)
async def async_set_percentage(self, percentage: int) -> None:
"""Set fan speed (0-100%) by converting to device-specific speed steps."""
if percentage == 0:
await self.async_turn_off()
return
await self.coordinator.api_client.set_speed(
math.ceil(
percentage_to_ranged_value(
self.entity_description.speed_range(self.coordinator),
percentage,
)
)
* PRANA_SPEED_MULTIPLIER,
self._api_target_key,
)
await self.coordinator.async_refresh()
@property
def is_on(self) -> bool:
"""Return true if the fan is on."""
return self.entity_description.value_fn(self.coordinator).is_on
async def async_turn_on(
self,
percentage: int | None = None,
preset_mode: str | None = None,
**kwargs: Any,
) -> None:
"""Turn the fan on and optionally set speed or preset mode."""
if percentage == 0:
await self.async_turn_off()
return
await self.coordinator.api_client.set_speed_is_on(True, self._api_target_key)
if percentage is not None:
await self.async_set_percentage(percentage)
if preset_mode is not None:
await self.async_set_preset_mode(preset_mode)
if percentage is None and preset_mode is None:
await self.coordinator.async_refresh()
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the fan off."""
await self.coordinator.api_client.set_speed_is_on(False, self._api_target_key)
await self.coordinator.async_refresh()
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set the preset mode (e.g., night or boost)."""
await self.coordinator.api_client.set_switch(preset_mode, True)
await self.coordinator.async_refresh()
@property
def preset_mode(self) -> str | None:
"""Return the current preset mode."""
if self.coordinator.data.night:
return "night"
if self.coordinator.data.boost:
return "boost"
return None

View File

@@ -1,5 +1,13 @@
{
"entity": {
"fan": {
"extract": {
"default": "mdi:arrow-expand-right"
},
"supply": {
"default": "mdi:arrow-expand-left"
}
},
"switch": {
"auto": {
"default": "mdi:fan-auto"

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["prana-api-client==0.10.0"],
"requirements": ["prana-api-client==0.12.0"],
"zeroconf": [
{
"type": "_prana._tcp.local."

View File

@@ -25,6 +25,30 @@
}
},
"entity": {
"fan": {
"extract": {
"name": "Extract fan",
"state_attributes": {
"preset_mode": {
"state": {
"boost": "Boost",
"night": "Night"
}
}
}
},
"supply": {
"name": "Supply fan",
"state_attributes": {
"preset_mode": {
"state": {
"boost": "[%key:component::prana::entity::fan::extract::state_attributes::preset_mode::state::boost%]",
"night": "[%key:component::prana::entity::fan::extract::state_attributes::preset_mode::state::night%]"
}
}
}
}
},
"switch": {
"auto": {
"name": "Auto"

View File

@@ -2,6 +2,7 @@
from homeassistant.const import STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
@@ -14,7 +15,7 @@ from . import DOMAIN
class SceneActivatedTrigger(EntityTriggerBase):
"""Trigger for scene entity activations."""
_domains = {DOMAIN}
_domain_specs = {DOMAIN: DomainSpec()}
_schema = ENTITY_STATE_TRIGGER_SCHEMA
def is_valid_transition(self, from_state: State, to_state: State) -> bool:

View File

@@ -2,6 +2,7 @@
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
EntityTransitionTriggerBase,
Trigger,
@@ -14,7 +15,7 @@ from .const import ATTR_NEXT_EVENT, DOMAIN
class ScheduleBackToBackTrigger(EntityTransitionTriggerBase):
"""Trigger for back-to-back schedule blocks."""
_domains = {DOMAIN}
_domain_specs = {DOMAIN: DomainSpec()}
_from_states = {STATE_OFF, STATE_ON}
_to_states = {STATE_ON}

View File

@@ -15,6 +15,9 @@
"period": {
"default": "mdi:sine-wave"
},
"spring_status": {
"default": "mdi:feather"
},
"swing_count": {
"default": "mdi:counter"
},

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_push",
"loggers": ["pysmarlaapi", "pysignalr"],
"quality_scale": "silver",
"requirements": ["pysmarlaapi==1.0.1"]
"requirements": ["pysmarlaapi==1.0.2"]
}

View File

@@ -1,14 +1,18 @@
"""Support for the Swing2Sleep Smarla sensor entities."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any, Generic, TypeVar
from pysmarlaapi.federwiege.services.classes import Property
from pysmarlaapi.federwiege.services.types import SpringStatus
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
StateType,
)
from homeassistant.const import UnitOfLength, UnitOfTime
from homeassistant.core import HomeAssistant
@@ -19,53 +23,56 @@ from .entity import SmarlaBaseEntity, SmarlaEntityDescription
PARALLEL_UPDATES = 0
_VT = TypeVar("_VT")
@dataclass(frozen=True, kw_only=True)
class SmarlaSensorEntityDescription(SmarlaEntityDescription, SensorEntityDescription):
class SmarlaSensorEntityDescription(
SmarlaEntityDescription, SensorEntityDescription, Generic[_VT]
):
"""Class describing Swing2Sleep Smarla sensor entities."""
multiple: bool = False
value_pos: int = 0
value_fn: Callable[[_VT | None], StateType] = lambda value: (
value if isinstance(value, (str, int, float)) else None
)
SENSORS: list[SmarlaSensorEntityDescription] = [
SmarlaSensorEntityDescription(
SENSORS: list[SmarlaSensorEntityDescription[Any]] = [
SmarlaSensorEntityDescription[list[int]](
key="amplitude",
translation_key="amplitude",
service="analyser",
property="oscillation",
multiple=True,
value_pos=0,
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.MILLIMETERS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: value[0] if value else None,
),
SmarlaSensorEntityDescription(
SmarlaSensorEntityDescription[list[int]](
key="period",
translation_key="period",
service="analyser",
property="oscillation",
multiple=True,
value_pos=1,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MILLISECONDS,
state_class=SensorStateClass.MEASUREMENT,
value_fn=lambda value: value[1] if value else None,
),
SmarlaSensorEntityDescription(
SmarlaSensorEntityDescription[int](
key="activity",
translation_key="activity",
service="analyser",
property="activity",
state_class=SensorStateClass.MEASUREMENT,
),
SmarlaSensorEntityDescription(
SmarlaSensorEntityDescription[int](
key="swing_count",
translation_key="swing_count",
service="analyser",
property="swing_count",
state_class=SensorStateClass.TOTAL_INCREASING,
),
SmarlaSensorEntityDescription(
SmarlaSensorEntityDescription[int](
key="total_swing_time",
translation_key="total_swing_time",
service="info",
@@ -75,6 +82,21 @@ SENSORS: list[SmarlaSensorEntityDescription] = [
suggested_unit_of_measurement=UnitOfTime.HOURS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SmarlaSensorEntityDescription[SpringStatus](
key="spring_status",
translation_key="spring_status",
service="analyser",
property="spring_status",
device_class=SensorDeviceClass.ENUM,
options=[
status.name.lower()
for status in SpringStatus
if status != SpringStatus.UNKNOWN
],
value_fn=lambda value: (
value.name.lower() if value and value != SpringStatus.UNKNOWN else None
),
),
]
@@ -85,38 +107,18 @@ async def async_setup_entry(
) -> None:
"""Set up the Smarla sensors from config entry."""
federwiege = config_entry.runtime_data
async_add_entities(
(
SmarlaSensor(federwiege, desc)
if not desc.multiple
else SmarlaSensorMultiple(federwiege, desc)
)
for desc in SENSORS
)
async_add_entities(SmarlaSensor(federwiege, desc) for desc in SENSORS)
class SmarlaSensor(SmarlaBaseEntity, SensorEntity):
class SmarlaSensor(SmarlaBaseEntity, SensorEntity, Generic[_VT]):
"""Representation of Smarla sensor."""
entity_description: SmarlaSensorEntityDescription
entity_description: SmarlaSensorEntityDescription[_VT]
_property: Property[int]
_property: Property[_VT]
@property
def native_value(self) -> int | None:
def native_value(self) -> StateType:
"""Return the entity value to represent the entity state."""
return self._property.get()
class SmarlaSensorMultiple(SmarlaBaseEntity, SensorEntity):
"""Representation of Smarla sensor with multiple values inside property."""
entity_description: SmarlaSensorEntityDescription
_property: Property[list[int]]
@property
def native_value(self) -> int | None:
"""Return the entity value to represent the entity state."""
v = self._property.get()
return v[self.entity_description.value_pos] if v is not None else None
value = self._property.get()
return self.entity_description.value_fn(value)

View File

@@ -50,6 +50,16 @@
"period": {
"name": "Period"
},
"spring_status": {
"name": "Spring status",
"state": {
"constellation_critical_too_high": "Critically too strong",
"constellation_critical_too_low": "Critically too weak",
"constellation_too_high": "Too strong",
"constellation_too_low": "Too weak",
"normal": "Normal"
}
},
"swing_count": {
"name": "Swing count",
"unit_of_measurement": "swings"

View File

@@ -74,6 +74,11 @@ from .const import (
_LOGGER = logging.getLogger(__name__)
def format_zigbee_address(address: str) -> str:
"""Format a zigbee address to be more readable."""
return ":".join(address.lower()[i : i + 2] for i in range(0, 16, 2))
@dataclass
class SmartThingsData:
"""Define an object to hold SmartThings data."""
@@ -490,6 +495,14 @@ def create_devices(
kwargs[ATTR_CONNECTIONS] = {
(dr.CONNECTION_NETWORK_MAC, device.device.hub.mac_address)
}
if device.device.hub.hub_eui:
connections = kwargs.setdefault(ATTR_CONNECTIONS, set())
connections.add(
(
dr.CONNECTION_ZIGBEE,
format_zigbee_address(device.device.hub.hub_eui),
)
)
if device.device.parent_device_id and device.device.parent_device_id in devices:
kwargs[ATTR_VIA_DEVICE] = (DOMAIN, device.device.parent_device_id)
if (ocf := device.device.ocf) is not None:
@@ -513,6 +526,10 @@ def create_devices(
ATTR_SW_VERSION: viper.software_version,
}
)
if (zigbee := device.device.zigbee) is not None:
kwargs[ATTR_CONNECTIONS] = {
(dr.CONNECTION_ZIGBEE, format_zigbee_address(zigbee.eui))
}
if (matter := device.device.matter) is not None:
kwargs.update(
{

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