Compare commits

...

65 Commits

Author SHA1 Message Date
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
Simone Chemelli
49586d1519 Fix dnd switch status for Alexa Devices (#164953) 2026-03-11 19:21:51 +01:00
Erwin Douna
c63ded3522 Add Swarm stack to Portainer (#164991) 2026-03-11 18:14:05 +01:00
Josef Zweck
2eb65ab314 Buffer backup upload progress events (#165249)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-11 17:29:35 +01:00
ams2990
402a37b435 Change light.toggle service call to invoke LightEntity.async_toggle (#156196)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
Co-authored-by: Erik Montnemery <erik@montnemery.com>
2026-03-11 17:17:10 +01:00
Erik Montnemery
aa66e8ef0c Improve humidity triggers (#165323) 2026-03-11 17:11:27 +01:00
noambav
f1a1e284b7 Add support for Fish Audio s2-pro model (#165269) 2026-03-11 17:07:56 +01:00
hanwg
08594f4e0c Update migration message for Telegram bot (#165299) 2026-03-11 17:04:16 +01:00
Joakim Plate
8d810588f8 Move secondary zone of arcam to sub-device (#165336) 2026-03-11 16:57:47 +01:00
Sid
70faad15d5 Add binary_sensor to eheimdigital (#165035)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-11 16:21:16 +01:00
TheJulianJES
d447843687 Bump python-otbr-api to 2.9.0 (#165298) 2026-03-11 16:15:35 +01:00
Steve Easley
83b64e29fa Bump pyjvcprojector to 2.0.3 (#165327) 2026-03-11 16:13:26 +01:00
tronikos
4558a10e05 Improve test coverage in Opower to make it silver (#165124) 2026-03-11 15:56:31 +01:00
johanzander
5ad9e81082 Add reauthentication flow to growatt_server (silver quality scale) (#164993)
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-11 15:51:25 +01:00
cdheiser
ba00a14772 Fix flakiness in lutron tests and isolate platforms per test file (#165328) 2026-03-11 15:08:00 +01:00
J. Diego Rodríguez Royo
49f4d07eeb Add fan entity for air conditioner to Home Connect (#155983)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2026-03-11 14:29:01 +01:00
Dan Raper
5d271a0d30 Bump ohme to 1.7.0 (#165318) 2026-03-11 12:49:07 +01:00
Joakim Plate
474b683d3c Update gardena to 2.1.0 (#165322) 2026-03-11 12:48:24 +01:00
Erik Montnemery
d37106a360 Add gate triggers (#165228) 2026-03-11 10:59:53 +01:00
epenet
e115c90719 Reduce internal testing in arcam_fmj tests (#165315) 2026-03-11 10:14:24 +01:00
epenet
6ad3adf0c3 Remove duplicate fixture in arcam_fmj tests (#165312) 2026-03-11 09:51:51 +01:00
dependabot[bot]
2a8d59be4c Bump docker/login-action from 3.7.0 to 4.0.0 (#165302) 2026-03-11 09:16:34 +01:00
dependabot[bot]
6e6e35bc3b Bump actions/dependency-review-action from 4.8.3 to 4.9.0 (#165304) 2026-03-11 09:15:36 +01:00
216 changed files with 9962 additions and 951 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

@@ -196,7 +196,7 @@ jobs:
echo "${GITHUB_SHA};${GITHUB_REF};${GITHUB_EVENT_NAME};${GITHUB_ACTOR}" > rootfs/OFFICIAL_IMAGE
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -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
@@ -328,7 +328,7 @@ jobs:
fi
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -406,13 +406,13 @@ jobs:
- name: Login to DockerHub
if: matrix.registry == 'docker.io/homeassistant'
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
@@ -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'
@@ -585,14 +585,14 @@ jobs:
persist-credentials: false
- name: Login to GitHub Container Registry
uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3.7.0
uses: docker/login-action@b45d80f862d83dbcd57f89517bcf500b2ab88fb2 # v4.0.0
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
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

@@ -609,7 +609,7 @@ jobs:
with:
persist-credentials: false
- name: Dependency review
uses: actions/dependency-review-action@05fe4576374b728f0c523d6a13d64c25081e0803 # v4.8.3
uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0
with:
license-check: false # We use our own license audit checks

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.

8
CODEOWNERS generated
View File

@@ -577,6 +577,8 @@ build.json @home-assistant/supervisor
/tests/components/garages_amsterdam/ @klaasnicolaas
/homeassistant/components/gardena_bluetooth/ @elupus
/tests/components/gardena_bluetooth/ @elupus
/homeassistant/components/gate/ @home-assistant/core
/tests/components/gate/ @home-assistant/core
/homeassistant/components/gdacs/ @exxamalte
/tests/components/gdacs/ @exxamalte
/homeassistant/components/generic/ @davet2001
@@ -1069,6 +1071,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
@@ -1182,6 +1186,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
@@ -1903,6 +1909,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

@@ -243,7 +243,11 @@ DEFAULT_INTEGRATIONS = {
# Integrations providing triggers and conditions for base platforms:
"door",
"garage_door",
"gate",
"humidity",
"motion",
"occupancy",
"window",
}
DEFAULT_INTEGRATIONS_RECOVERY_MODE = {
# These integrations are set up if recovery mode is activated.

View File

@@ -101,7 +101,10 @@ class AmazonSwitchEntity(AmazonEntity, SwitchEntity):
assert method is not None
await method(self.device, state)
await self.coordinator.async_request_refresh()
self.coordinator.data[self.device.serial_number].sensors[
self.entity_description.key
].value = state
self.async_write_ha_state()
async def async_turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""

View File

@@ -11,8 +11,11 @@ from arcam.fmj.state import State
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
@@ -50,6 +53,24 @@ class ArcamFmjCoordinator(DataUpdateCoordinator[None]):
self.state = State(client, zone)
self.last_update_success = False
name = config_entry.title
unique_id = config_entry.unique_id or config_entry.entry_id
unique_id_device = unique_id
if zone != 1:
unique_id_device += f"-{zone}"
name += f" Zone {zone}"
self.device_info = DeviceInfo(
identifiers={(DOMAIN, unique_id_device)},
manufacturer="Arcam",
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)
async def _async_update_data(self) -> None:
"""Fetch data for manual refresh."""
try:

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

@@ -21,12 +21,11 @@ from homeassistant.components.media_player import (
from homeassistant.const import ATTR_ENTITY_ID
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
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, EVENT_TURN_ON
from .const import EVENT_TURN_ON
from .coordinator import ArcamFmjConfigEntry, ArcamFmjCoordinator
from .entity import ArcamFmjEntity
_LOGGER = logging.getLogger(__name__)
@@ -40,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)],
)
@@ -68,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
@@ -95,16 +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 = DeviceInfo(
identifiers={
(DOMAIN, uuid),
},
manufacturer="Arcam",
model="Arcam FMJ AVR",
name=device_name,
)
@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

@@ -144,6 +144,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"door",
"fan",
"garage_door",
"gate",
"humidifier",
"humidity",
"input_boolean",
@@ -151,6 +152,8 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"light",
"lock",
"media_player",
"motion",
"occupancy",
"person",
"remote",
"scene",
@@ -160,6 +163,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"text",
"update",
"vacuum",
"window",
}

View File

@@ -32,6 +32,7 @@ from homeassistant.helpers import (
issue_registry as ir,
start,
)
from homeassistant.helpers.debounce import Debouncer
from homeassistant.helpers.json import json_bytes
from homeassistant.util import dt as dt_util, json as json_util
from homeassistant.util.async_iterator import AsyncIteratorReader
@@ -78,6 +79,8 @@ from .util import (
validate_password_stream,
)
UPLOAD_PROGRESS_DEBOUNCE_SECONDS = 1
@dataclass(frozen=True, kw_only=True, slots=True)
class NewBackup:
@@ -141,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"
@@ -590,23 +594,49 @@ class BackupManager:
)
agent = self.backup_agents[agent_id]
latest_uploaded_bytes = 0
@callback
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
"""Handle upload progress."""
def _emit_upload_progress() -> None:
"""Emit the latest upload progress event."""
self.async_on_backup_event(
UploadBackupEvent(
manager_state=self.state,
agent_id=agent_id,
uploaded_bytes=bytes_uploaded,
uploaded_bytes=latest_uploaded_bytes,
total_bytes=_backup.size,
)
)
upload_progress_debouncer: Debouncer[None] = Debouncer(
self.hass,
LOGGER,
cooldown=UPLOAD_PROGRESS_DEBOUNCE_SECONDS,
immediate=True,
function=_emit_upload_progress,
)
@callback
def on_upload_progress(*, bytes_uploaded: int, **kwargs: Any) -> None:
"""Handle upload progress."""
nonlocal latest_uploaded_bytes
latest_uploaded_bytes = bytes_uploaded
upload_progress_debouncer.async_schedule_call()
await agent.async_upload_backup(
open_stream=open_stream_func,
backup=_backup,
on_progress=on_upload_progress,
)
upload_progress_debouncer.async_cancel()
self.async_on_backup_event(
UploadBackupEvent(
manager_state=self.state,
agent_id=agent_id,
uploaded_bytes=_backup.size,
total_bytes=_backup.size,
)
)
if streamer:
await streamer.wait()
@@ -1261,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

@@ -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

@@ -10,6 +10,7 @@ from .const import DOMAIN
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.CLIMATE,
Platform.LIGHT,
Platform.NUMBER,

View File

@@ -0,0 +1,101 @@
"""EHEIM Digital binary sensors."""
from collections.abc import Callable
from dataclasses import dataclass
from typing import Any
from eheimdigital.device import EheimDigitalDevice
from eheimdigital.reeflex import EheimDigitalReeflexUV
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,
BinarySensorEntity,
BinarySensorEntityDescription,
)
from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import EheimDigitalConfigEntry, EheimDigitalUpdateCoordinator
from .entity import EheimDigitalEntity
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class EheimDigitalBinarySensorDescription[_DeviceT: EheimDigitalDevice](
BinarySensorEntityDescription
):
"""Class describing EHEIM Digital binary sensor entities."""
value_fn: Callable[[_DeviceT], bool | None]
REEFLEX_DESCRIPTIONS: tuple[
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV], ...
] = (
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
key="is_lighting",
translation_key="is_lighting",
value_fn=lambda device: device.is_lighting,
device_class=BinarySensorDeviceClass.LIGHT,
),
EheimDigitalBinarySensorDescription[EheimDigitalReeflexUV](
key="is_uvc_connected",
translation_key="is_uvc_connected",
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda device: device.is_uvc_connected,
device_class=BinarySensorDeviceClass.CONNECTIVITY,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: EheimDigitalConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the callbacks for the coordinator so binary sensors can be added as devices are found."""
coordinator = entry.runtime_data
def async_setup_device_entities(
device_address: dict[str, EheimDigitalDevice],
) -> None:
"""Set up the binary sensor entities for one or multiple devices."""
entities: list[EheimDigitalBinarySensor[Any]] = []
for device in device_address.values():
if isinstance(device, EheimDigitalReeflexUV):
entities += [
EheimDigitalBinarySensor[EheimDigitalReeflexUV](
coordinator, device, description
)
for description in REEFLEX_DESCRIPTIONS
]
async_add_entities(entities)
coordinator.add_platform_callback(async_setup_device_entities)
async_setup_device_entities(coordinator.hub.devices)
class EheimDigitalBinarySensor[_DeviceT: EheimDigitalDevice](
EheimDigitalEntity[_DeviceT], BinarySensorEntity
):
"""Represent an EHEIM Digital binary sensor entity."""
entity_description: EheimDigitalBinarySensorDescription[_DeviceT]
def __init__(
self,
coordinator: EheimDigitalUpdateCoordinator,
device: _DeviceT,
description: EheimDigitalBinarySensorDescription[_DeviceT],
) -> None:
"""Initialize an EHEIM Digital binary sensor entity."""
super().__init__(coordinator, device)
self.entity_description = description
self._attr_unique_id = f"{self._device_address}_{description.key}"
def _async_update_attrs(self) -> None:
self._attr_is_on = self.entity_description.value_fn(self._device)

View File

@@ -1,5 +1,19 @@
{
"entity": {
"binary_sensor": {
"is_lighting": {
"default": "mdi:lightbulb-outline",
"state": {
"on": "mdi:lightbulb-on"
}
},
"is_uvc_connected": {
"default": "mdi:lightbulb-off",
"state": {
"on": "mdi:lightbulb-outline"
}
}
},
"number": {
"day_speed": {
"default": "mdi:weather-sunny"

View File

@@ -33,6 +33,17 @@
}
},
"entity": {
"binary_sensor": {
"is_lighting": {
"state": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
},
"is_uvc_connected": {
"name": "UVC lamp connected"
}
},
"climate": {
"heater": {
"state_attributes": {

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

@@ -111,7 +111,7 @@ def get_model_selection_schema(
),
vol.Required(
CONF_BACKEND,
default=options.get(CONF_BACKEND, "s1"),
default=options.get(CONF_BACKEND, "s2-pro"),
): SelectSelector(
SelectSelectorConfig(
options=[

View File

@@ -31,7 +31,7 @@ TTS_SUPPORTED_LANGUAGES = [
]
BACKEND_MODELS = ["s1", "speech-1.5", "speech-1.6"]
BACKEND_MODELS = ["s2-pro", "s1", "speech-1.5", "speech-1.6"]
SORT_BY_OPTIONS = ["task_count", "score", "created_at"]
LATENCY_OPTIONS = ["normal", "balanced"]

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

@@ -2,12 +2,18 @@
from __future__ import annotations
import asyncio
import logging
from bleak.backends.device import BLEDevice
from gardena_bluetooth.client import CachedConnection, Client
from gardena_bluetooth.const import DeviceConfiguration, DeviceInformation
from gardena_bluetooth.exceptions import CommunicationFailure
from gardena_bluetooth.exceptions import (
CharacteristicNoAccess,
CharacteristicNotFound,
CommunicationFailure,
)
from gardena_bluetooth.parse import CharacteristicTime
from homeassistant.components import bluetooth
from homeassistant.const import CONF_ADDRESS, Platform
@@ -23,6 +29,7 @@ from .coordinator import (
GardenaBluetoothConfigEntry,
GardenaBluetoothCoordinator,
)
from .util import async_get_product_type
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
@@ -51,22 +58,41 @@ def get_connection(hass: HomeAssistant, address: str) -> CachedConnection:
return CachedConnection(DISCONNECT_DELAY, _device_lookup)
async def _update_timestamp(client: Client, characteristics: CharacteristicTime):
try:
await client.update_timestamp(characteristics, dt_util.now())
except CharacteristicNotFound:
pass
except CharacteristicNoAccess:
LOGGER.debug("No access to update internal time")
async def async_setup_entry(
hass: HomeAssistant, entry: GardenaBluetoothConfigEntry
) -> bool:
"""Set up Gardena Bluetooth from a config entry."""
address = entry.data[CONF_ADDRESS]
client = Client(get_connection(hass, address))
try:
async with asyncio.timeout(TIMEOUT):
product_type = await async_get_product_type(hass, address)
except TimeoutError as exception:
raise ConfigEntryNotReady("Unable to find product type") from exception
client = Client(get_connection(hass, address), product_type)
try:
chars = await client.get_all_characteristics()
sw_version = await client.read_char(DeviceInformation.firmware_version, None)
manufacturer = await client.read_char(DeviceInformation.manufacturer_name, None)
model = await client.read_char(DeviceInformation.model_number, None)
name = await client.read_char(
DeviceConfiguration.custom_device_name, entry.title
)
uuids = await client.get_all_characteristics_uuid()
await client.update_timestamp(dt_util.now())
name = entry.title
name = await client.read_char(DeviceConfiguration.custom_device_name, name)
await _update_timestamp(client, DeviceConfiguration.unix_timestamp)
except (TimeoutError, CommunicationFailure, DeviceUnavailable) as exception:
await client.disconnect()
raise ConfigEntryNotReady(
@@ -83,7 +109,7 @@ async def async_setup_entry(
)
coordinator = GardenaBluetoothCoordinator(
hass, entry, LOGGER, client, uuids, device, address
hass, entry, LOGGER, client, set(chars.keys()), device, address
)
entry.runtime_data = coordinator

View File

@@ -34,14 +34,14 @@ class GardenaBluetoothBinarySensorEntityDescription(BinarySensorEntityDescriptio
DESCRIPTIONS = (
GardenaBluetoothBinarySensorEntityDescription(
key=Valve.connected_state.uuid,
key=Valve.connected_state.unique_id,
translation_key="valve_connected_state",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
char=Valve.connected_state,
),
GardenaBluetoothBinarySensorEntityDescription(
key=Sensor.connected_state.uuid,
key=Sensor.connected_state.unique_id,
translation_key="sensor_connected_state",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -60,7 +60,7 @@ async def async_setup_entry(
entities = [
GardenaBluetoothBinarySensor(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.key in coordinator.characteristics
if description.char.unique_id in coordinator.characteristics
]
async_add_entities(entities)

View File

@@ -30,7 +30,7 @@ class GardenaBluetoothButtonEntityDescription(ButtonEntityDescription):
DESCRIPTIONS = (
GardenaBluetoothButtonEntityDescription(
key=Reset.factory_reset.uuid,
key=Reset.factory_reset.unique_id,
translation_key="factory_reset",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
@@ -49,7 +49,7 @@ async def async_setup_entry(
entities = [
GardenaBluetoothButton(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.key in coordinator.characteristics
if description.char.unique_id in coordinator.characteristics
]
async_add_entities(entities)

View File

@@ -15,5 +15,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bleak", "bleak_esphome", "gardena_bluetooth"],
"requirements": ["gardena-bluetooth==1.6.0"]
"requirements": ["gardena-bluetooth==2.1.0"]
}

View File

@@ -46,7 +46,7 @@ class GardenaBluetoothNumberEntityDescription(NumberEntityDescription):
DESCRIPTIONS = (
GardenaBluetoothNumberEntityDescription(
key=Valve.manual_watering_time.uuid,
key=Valve.manual_watering_time.unique_id,
translation_key="manual_watering_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
@@ -58,7 +58,7 @@ DESCRIPTIONS = (
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=Valve.remaining_open_time.uuid,
key=Valve.remaining_open_time.unique_id,
translation_key="remaining_open_time",
native_unit_of_measurement=UnitOfTime.SECONDS,
native_min_value=0.0,
@@ -69,7 +69,7 @@ DESCRIPTIONS = (
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=DeviceConfiguration.rain_pause.uuid,
key=DeviceConfiguration.rain_pause.unique_id,
translation_key="rain_pause",
native_unit_of_measurement=UnitOfTime.MINUTES,
mode=NumberMode.BOX,
@@ -81,7 +81,7 @@ DESCRIPTIONS = (
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=DeviceConfiguration.seasonal_adjust.uuid,
key=DeviceConfiguration.seasonal_adjust.unique_id,
translation_key="seasonal_adjust",
native_unit_of_measurement=UnitOfTime.DAYS,
mode=NumberMode.BOX,
@@ -93,7 +93,7 @@ DESCRIPTIONS = (
device_class=NumberDeviceClass.DURATION,
),
GardenaBluetoothNumberEntityDescription(
key=Sensor.threshold.uuid,
key=Sensor.threshold.unique_id,
translation_key="sensor_threshold",
native_unit_of_measurement=PERCENTAGE,
mode=NumberMode.BOX,
@@ -117,9 +117,9 @@ async def async_setup_entry(
entities: list[NumberEntity] = [
GardenaBluetoothNumber(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.key in coordinator.characteristics
if description.char.unique_id in coordinator.characteristics
]
if Valve.remaining_open_time.uuid in coordinator.characteristics:
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
entities.append(GardenaBluetoothRemainingOpenSetNumber(coordinator))
async_add_entities(entities)

View File

@@ -41,7 +41,7 @@ class GardenaBluetoothSensorEntityDescription(SensorEntityDescription):
DESCRIPTIONS = (
GardenaBluetoothSensorEntityDescription(
key=Valve.activation_reason.uuid,
key=Valve.activation_reason.unique_id,
translation_key="activation_reason",
state_class=SensorStateClass.MEASUREMENT,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -49,7 +49,7 @@ DESCRIPTIONS = (
char=Valve.activation_reason,
),
GardenaBluetoothSensorEntityDescription(
key=Battery.battery_level.uuid,
key=Battery.battery_level.unique_id,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -57,7 +57,7 @@ DESCRIPTIONS = (
char=Battery.battery_level,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.battery_level.uuid,
key=Sensor.battery_level.unique_id,
translation_key="sensor_battery_level",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
@@ -67,7 +67,7 @@ DESCRIPTIONS = (
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.value.uuid,
key=Sensor.value.unique_id,
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.MOISTURE,
native_unit_of_measurement=PERCENTAGE,
@@ -75,14 +75,14 @@ DESCRIPTIONS = (
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.type.uuid,
key=Sensor.type.unique_id,
translation_key="sensor_type",
entity_category=EntityCategory.DIAGNOSTIC,
char=Sensor.type,
connected_state=Sensor.connected_state,
),
GardenaBluetoothSensorEntityDescription(
key=Sensor.measurement_timestamp.uuid,
key=Sensor.measurement_timestamp.unique_id,
translation_key="sensor_measurement_timestamp",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
@@ -102,9 +102,9 @@ async def async_setup_entry(
entities: list[GardenaBluetoothEntity] = [
GardenaBluetoothSensor(coordinator, description, description.context)
for description in DESCRIPTIONS
if description.key in coordinator.characteristics
if description.char.unique_id in coordinator.characteristics
]
if Valve.remaining_open_time.uuid in coordinator.characteristics:
if Valve.remaining_open_time.unique_id in coordinator.characteristics:
entities.append(GardenaBluetoothRemainSensor(coordinator))
async_add_entities(entities)

View File

@@ -35,9 +35,9 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
"""Representation of a valve switch."""
characteristics = {
Valve.state.uuid,
Valve.manual_watering_time.uuid,
Valve.remaining_open_time.uuid,
Valve.state.unique_id,
Valve.manual_watering_time.unique_id,
Valve.remaining_open_time.unique_id,
}
def __init__(
@@ -48,7 +48,7 @@ class GardenaBluetoothValveSwitch(GardenaBluetoothEntity, SwitchEntity):
super().__init__(
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
)
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
self._attr_translation_key = "state"
self._attr_is_on = None
self._attr_entity_registry_enabled_default = False

View File

@@ -0,0 +1,51 @@
"""Utility functions for Gardena Bluetooth integration."""
import asyncio
from collections.abc import AsyncIterator
from gardena_bluetooth.parse import ManufacturerData, ProductType
from homeassistant.components import bluetooth
async def _async_service_info(
hass, address
) -> AsyncIterator[bluetooth.BluetoothServiceInfoBleak]:
queue = asyncio.Queue[bluetooth.BluetoothServiceInfoBleak]()
def _callback(
service_info: bluetooth.BluetoothServiceInfoBleak,
change: bluetooth.BluetoothChange,
) -> None:
if change != bluetooth.BluetoothChange.ADVERTISEMENT:
return
queue.put_nowait(service_info)
service_info = bluetooth.async_last_service_info(hass, address, True)
if service_info:
yield service_info
cancel = bluetooth.async_register_callback(
hass,
_callback,
{bluetooth.match.ADDRESS: address},
bluetooth.BluetoothScanningMode.ACTIVE,
)
try:
while True:
yield await queue.get()
finally:
cancel()
async def async_get_product_type(hass, address: str) -> ProductType:
"""Wait for enough packets of manufacturer data to get the product type."""
data = ManufacturerData()
async for service_info in _async_service_info(hass, address):
data.update(service_info.manufacturer_data.get(ManufacturerData.company, b""))
product_type = ProductType.from_manufacturer_data(data)
if product_type is not ProductType.UNKNOWN:
return product_type
raise AssertionError("Iterator should have been infinite")

View File

@@ -44,9 +44,9 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
_attr_device_class = ValveDeviceClass.WATER
characteristics = {
Valve.state.uuid,
Valve.manual_watering_time.uuid,
Valve.remaining_open_time.uuid,
Valve.state.unique_id,
Valve.manual_watering_time.unique_id,
Valve.remaining_open_time.unique_id,
}
def __init__(
@@ -57,7 +57,7 @@ class GardenaBluetoothValve(GardenaBluetoothEntity, ValveEntity):
super().__init__(
coordinator, {Valve.state.uuid, Valve.manual_watering_time.uuid}
)
self._attr_unique_id = f"{coordinator.address}-{Valve.state.uuid}"
self._attr_unique_id = f"{coordinator.address}-{Valve.state.unique_id}"
def _handle_coordinator_update(self) -> None:
self._attr_is_closed = not self.coordinator.get_cached(Valve.state)

View File

@@ -0,0 +1,17 @@
"""Integration for gate 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 = "gate"
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": {
"closed": {
"trigger": "mdi:gate"
},
"opened": {
"trigger": "mdi:gate-open"
}
}
}

View File

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

View File

@@ -0,0 +1,38 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted gates to trigger on.",
"trigger_behavior_name": "Behavior"
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Gate",
"triggers": {
"closed": {
"description": "Triggers after one or more gates close.",
"fields": {
"behavior": {
"description": "[%key:component::gate::common::trigger_behavior_description%]",
"name": "[%key:component::gate::common::trigger_behavior_name%]"
}
},
"name": "Gate closed"
},
"opened": {
"description": "Triggers after one or more gates open.",
"fields": {
"behavior": {
"description": "[%key:component::gate::common::trigger_behavior_description%]",
"name": "[%key:component::gate::common::trigger_behavior_name%]"
}
},
"name": "Gate opened"
}
}
}

View File

@@ -0,0 +1,25 @@
"""Provides triggers for gates."""
from homeassistant.components.cover import (
DOMAIN as COVER_DOMAIN,
CoverDeviceClass,
make_cover_closed_trigger,
make_cover_opened_trigger,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger
DEVICE_CLASSES_GATE: dict[str, str] = {
COVER_DOMAIN: CoverDeviceClass.GATE,
}
TRIGGERS: dict[str, type[Trigger]] = {
"opened": make_cover_opened_trigger(device_classes=DEVICE_CLASSES_GATE),
"closed": make_cover_closed_trigger(device_classes=DEVICE_CLASSES_GATE),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for gates."""
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
closed:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: gate
opened:
fields: *trigger_common_fields
target:
entity:
- domain: cover
device_class: gate

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

@@ -1,4 +1,28 @@
"""The Growatt server PV inverter sensor integration."""
"""The Growatt server PV inverter sensor integration.
This integration supports two distinct Growatt APIs with different auth models:
Classic API (username/password):
- Authenticates via api.login(), which returns a dict with a "success" key.
- Auth failure is signalled by success=False and msg="502" (LOGIN_INVALID_AUTH_CODE).
- A failed login does NOT raise an exception — the return value must be checked.
- The coordinator calls api.login() on every update cycle to maintain the session.
Open API V1 (API token):
- Stateless — no login call, token is sent as a Bearer header on every request.
- Auth failure is signalled by raising GrowattV1ApiError with error_code=10011
(V1_API_ERROR_NO_PRIVILEGE). The library NEVER returns a failure silently;
any non-zero error_code raises an exception via _process_response().
- Because the library always raises on error, return-value validation after a
successful V1 API call is unnecessary — if it returned, the token was valid.
Error handling pattern for reauth:
- Classic API: check NOT login_response["success"] and msg == LOGIN_INVALID_AUTH_CODE
→ raise ConfigEntryAuthFailed
- V1 API: catch GrowattV1ApiError with error_code == V1_API_ERROR_NO_PRIVILEGE
→ raise ConfigEntryAuthFailed
- All other errors → ConfigEntryError (setup) or UpdateFailed (coordinator)
"""
from collections.abc import Mapping
from json import JSONDecodeError
@@ -25,6 +49,7 @@ from .const import (
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
PLATFORMS,
V1_API_ERROR_NO_PRIVILEGE,
)
from .coordinator import GrowattConfigEntry, GrowattCoordinator
from .models import GrowattRuntimeData
@@ -227,8 +252,12 @@ def get_device_list_v1(
try:
devices_dict = api.device_list(plant_id)
except growattServer.GrowattV1ApiError as e:
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {e.error_msg or str(e)}"
) from e
raise ConfigEntryError(
f"API error during device list: {e} (Code: {getattr(e, 'error_code', None)}, Message: {getattr(e, 'error_msg', None)})"
f"API error during device list: {e.error_msg or str(e)} (Code: {e.error_code})"
) from e
devices = devices_dict.get("devices", [])
# Only MIN device (type = 7) support implemented in current V1 API
@@ -272,6 +301,7 @@ async def async_setup_entry(
# V1 API (token-based, no login needed)
token = config[CONF_TOKEN]
api = growattServer.OpenApiV1(token=token)
api.server_url = url
devices, plant_id = await hass.async_add_executor_job(
get_device_list_v1, api, config
)

View File

@@ -1,5 +1,6 @@
"""Config flow for growatt server integration."""
from collections.abc import Mapping
import logging
from typing import Any
@@ -31,8 +32,11 @@ from .const import (
ERROR_INVALID_AUTH,
LOGIN_INVALID_AUTH_CODE,
SERVER_URLS_NAMES,
V1_API_ERROR_NO_PRIVILEGE,
)
_URL_TO_REGION = {v: k for k, v in SERVER_URLS_NAMES.items()}
_LOGGER = logging.getLogger(__name__)
@@ -60,6 +64,137 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
menu_options=["password_auth", "token_auth"],
)
async def async_step_reauth(self, _: Mapping[str, Any]) -> ConfigFlowResult:
"""Handle reauth."""
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reauth confirmation."""
errors: dict[str, str] = {}
reauth_entry = self._get_reauth_entry()
if user_input is not None:
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
if auth_type == AUTH_PASSWORD:
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
api = growattServer.GrowattApi(
add_random_user_id=True,
agent_identifier=user_input[CONF_USERNAME],
)
api.server_url = server_url
try:
login_response = await self.hass.async_add_executor_job(
api.login, user_input[CONF_USERNAME], user_input[CONF_PASSWORD]
)
except requests.exceptions.RequestException as ex:
_LOGGER.debug("Network error during reauth login: %s", ex)
errors["base"] = ERROR_CANNOT_CONNECT
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.debug("Invalid response format during reauth login: %s", ex)
errors["base"] = ERROR_CANNOT_CONNECT
else:
if not isinstance(login_response, dict):
errors["base"] = ERROR_CANNOT_CONNECT
elif login_response.get("success"):
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_USERNAME: user_input[CONF_USERNAME],
CONF_PASSWORD: user_input[CONF_PASSWORD],
CONF_URL: server_url,
},
)
elif login_response.get("msg") == LOGIN_INVALID_AUTH_CODE:
errors["base"] = ERROR_INVALID_AUTH
else:
errors["base"] = ERROR_CANNOT_CONNECT
elif auth_type == AUTH_API_TOKEN:
server_url = SERVER_URLS_NAMES[user_input[CONF_REGION]]
api = growattServer.OpenApiV1(token=user_input[CONF_TOKEN])
api.server_url = server_url
try:
await self.hass.async_add_executor_job(api.plant_list)
except requests.exceptions.RequestException as ex:
_LOGGER.debug(
"Network error during reauth token validation: %s", ex
)
errors["base"] = ERROR_CANNOT_CONNECT
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
errors["base"] = ERROR_INVALID_AUTH
else:
_LOGGER.debug(
"Growatt V1 API error during reauth: %s (Code: %s)",
err.error_msg or str(err),
err.error_code,
)
errors["base"] = ERROR_CANNOT_CONNECT
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.debug(
"Invalid response format during reauth token validation: %s", ex
)
errors["base"] = ERROR_CANNOT_CONNECT
else:
return self.async_update_reload_and_abort(
reauth_entry,
data_updates={
CONF_TOKEN: user_input[CONF_TOKEN],
CONF_URL: server_url,
},
)
# Determine the current region key from the stored config value.
# Legacy entries may store the region key directly; newer entries store the URL.
stored_url = reauth_entry.data.get(CONF_URL, "")
if stored_url in SERVER_URLS_NAMES:
current_region = stored_url
else:
current_region = _URL_TO_REGION.get(stored_url, DEFAULT_URL)
auth_type = reauth_entry.data.get(CONF_AUTH_TYPE)
if auth_type == AUTH_PASSWORD:
data_schema = vol.Schema(
{
vol.Required(
CONF_USERNAME,
default=reauth_entry.data.get(CONF_USERNAME),
): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(CONF_REGION, default=current_region): SelectSelector(
SelectSelectorConfig(
options=list(SERVER_URLS_NAMES.keys()),
translation_key="region",
)
),
}
)
elif auth_type == AUTH_API_TOKEN:
data_schema = vol.Schema(
{
vol.Required(CONF_TOKEN): str,
vol.Required(CONF_REGION, default=current_region): SelectSelector(
SelectSelectorConfig(
options=list(SERVER_URLS_NAMES.keys()),
translation_key="region",
)
),
}
)
else:
return self.async_abort(reason=ERROR_CANNOT_CONNECT)
return self.async_show_form(
step_id="reauth_confirm",
data_schema=data_schema,
errors=errors,
)
async def async_step_password_auth(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -129,9 +264,11 @@ class GrowattServerConfigFlow(ConfigFlow, domain=DOMAIN):
_LOGGER.error(
"Growatt V1 API error: %s (Code: %s)",
e.error_msg or str(e),
getattr(e, "error_code", None),
e.error_code,
)
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
if e.error_code == V1_API_ERROR_NO_PRIVILEGE:
return self._async_show_token_form({"base": ERROR_INVALID_AUTH})
return self._async_show_token_form({"base": ERROR_CANNOT_CONNECT})
except (ValueError, KeyError, TypeError, AttributeError) as ex:
_LOGGER.error(
"Invalid response format during Growatt V1 API plant list: %s", ex

View File

@@ -40,8 +40,17 @@ DOMAIN = "growatt_server"
PLATFORMS = [Platform.NUMBER, Platform.SENSOR, Platform.SWITCH]
# Growatt Classic API error codes
LOGIN_INVALID_AUTH_CODE = "502"
# Growatt Open API V1 error codes
# Reference: https://www.showdoc.com.cn/262556420217021/1494055648380019
V1_API_ERROR_WRONG_DOMAIN = -1 # Use correct regional domain
V1_API_ERROR_NO_PRIVILEGE = 10011 # No privilege access — invalid or expired token
V1_API_ERROR_RATE_LIMITED = 10012 # Access frequency limit (5 minutes per call)
V1_API_ERROR_PAGE_SIZE = 10013 # Page size cannot exceed 100
V1_API_ERROR_PAGE_COUNT = 10014 # Page count cannot exceed 250
# Config flow error types (also used as abort reasons)
ERROR_CANNOT_CONNECT = "cannot_connect" # Used for both form errors and aborts
ERROR_INVALID_AUTH = "invalid_auth"

View File

@@ -13,7 +13,11 @@ from homeassistant.components.sensor import SensorStateClass
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_PASSWORD, CONF_URL, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
HomeAssistantError,
ServiceValidationError,
)
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
@@ -23,6 +27,8 @@ from .const import (
BATT_MODE_LOAD_FIRST,
DEFAULT_URL,
DOMAIN,
LOGIN_INVALID_AUTH_CODE,
V1_API_ERROR_NO_PRIVILEGE,
)
from .models import GrowattRuntimeData
@@ -63,6 +69,7 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.url = config_entry.data.get(CONF_URL, DEFAULT_URL)
self.token = config_entry.data["token"]
self.api = growattServer.OpenApiV1(token=self.token)
self.api.server_url = self.url
elif self.api_version == "classic":
self.username = config_entry.data.get(CONF_USERNAME)
self.password = config_entry.data[CONF_PASSWORD]
@@ -88,7 +95,14 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# login only required for classic API
if self.api_version == "classic":
self.api.login(self.username, self.password)
login_response = self.api.login(self.username, self.password)
if not login_response.get("success"):
msg = login_response.get("msg", "Unknown error")
if msg == LOGIN_INVALID_AUTH_CODE:
raise ConfigEntryAuthFailed(
"Username, password, or URL may be incorrect"
)
raise UpdateFailed(f"Growatt login failed: {msg}")
if self.device_type == "total":
if self.api_version == "v1":
@@ -100,7 +114,16 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
# todayEnergy -> today_energy
# totalEnergy -> total_energy
# invTodayPpv -> current_power
total_info = self.api.plant_energy_overview(self.plant_id)
try:
total_info = self.api.plant_energy_overview(self.plant_id)
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
) from err
raise UpdateFailed(
f"Error fetching plant energy overview: {err}"
) from err
total_info["todayEnergy"] = total_info["today_energy"]
total_info["totalEnergy"] = total_info["total_energy"]
total_info["invTodayPpv"] = total_info["current_power"]
@@ -122,6 +145,10 @@ class GrowattCoordinator(DataUpdateCoordinator[dict[str, Any]]):
min_settings = self.api.min_settings(self.device_id)
min_energy = self.api.min_energy(self.device_id)
except growattServer.GrowattV1ApiError as err:
if err.error_code == V1_API_ERROR_NO_PRIVILEGE:
raise ConfigEntryAuthFailed(
f"Authentication failed for Growatt API: {err.error_msg or str(err)}"
) from err
raise UpdateFailed(f"Error fetching min device data: {err}") from err
min_info = {**min_details, **min_settings, **min_energy}

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,12 +23,12 @@ 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
parallel-updates: done
reauthentication-flow: todo
reauthentication-flow: done
test-coverage: done
# Gold
@@ -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

@@ -3,7 +3,8 @@
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_plants": "No plants have been found on this account"
"no_plants": "No plants have been found on this account",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
},
"error": {
"cannot_connect": "Cannot connect to Growatt servers. Please check your internet connection and try again.",
@@ -13,30 +14,58 @@
"password_auth": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"url": "Server region",
"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": {
"data": {
"password": "[%key:common::config_flow::data::password%]",
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
"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": {
"token": "API Token",
"url": "Server region"
"region": "[%key:component::growatt_server::config::step::password_auth::data::region%]",
"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

@@ -38,6 +38,7 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
PLATFORMS = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.FAN,
Platform.LIGHT,
Platform.NUMBER,
Platform.SELECT,

View File

@@ -19,7 +19,7 @@ from .coordinator import (
HomeConnectApplianceData,
HomeConnectConfigEntry,
)
from .entity import HomeConnectEntity, HomeConnectOptionEntity
from .entity import HomeConnectEntity
def should_add_option_entity(
@@ -48,7 +48,7 @@ def _create_option_entities(
known_entity_unique_ids: dict[str, str],
get_option_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectOptionEntity],
list[HomeConnectEntity],
],
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
@@ -78,7 +78,7 @@ def _handle_paired_or_connected_appliance(
],
get_option_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectOptionEntity],
list[HomeConnectEntity],
]
| None,
changed_options_listener_remove_callbacks: dict[str, list[Callable[[], None]]],
@@ -161,7 +161,7 @@ def setup_home_connect_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
get_option_entities_for_appliance: Callable[
[HomeConnectApplianceCoordinator, er.EntityRegistry],
list[HomeConnectOptionEntity],
list[HomeConnectEntity],
]
| None = None,
) -> None:

View File

@@ -0,0 +1,235 @@
"""Provides fan entities for Home Connect."""
import contextlib
import logging
from typing import cast
from aiohomeconnect.model import EventKey, OptionKey
from aiohomeconnect.model.error import ActiveProgramNotSetError, HomeConnectError
from homeassistant.components.fan import (
FanEntity,
FanEntityDescription,
FanEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .common import setup_home_connect_entry
from .const import DOMAIN
from .coordinator import HomeConnectApplianceCoordinator, HomeConnectConfigEntry
from .entity import HomeConnectEntity
from .utils import get_dict_from_home_connect_error
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
FAN_SPEED_MODE_OPTIONS = {
"auto": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Automatic",
"manual": "HeatingVentilationAirConditioning.AirConditioner.EnumType.FanSpeedMode.Manual",
}
FAN_SPEED_MODE_OPTIONS_INVERTED = {v: k for k, v in FAN_SPEED_MODE_OPTIONS.items()}
AIR_CONDITIONER_ENTITY_DESCRIPTION = FanEntityDescription(
key="air_conditioner",
translation_key="air_conditioner",
name=None,
)
def _get_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return (
[HomeConnectAirConditioningFanEntity(appliance_coordinator)]
if appliance_coordinator.data.options
and any(
option in appliance_coordinator.data.options
for option in (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
)
)
else []
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HomeConnectConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Connect fan entities."""
setup_home_connect_entry(
hass,
entry,
_get_entities_for_appliance,
async_add_entities,
lambda appliance_coordinator, _: _get_entities_for_appliance(
appliance_coordinator
),
)
class HomeConnectAirConditioningFanEntity(HomeConnectEntity, FanEntity):
"""Representation of a Home Connect fan entity."""
def __init__(
self,
coordinator: HomeConnectApplianceCoordinator,
) -> None:
"""Initialize the entity."""
self._attr_preset_modes = list(FAN_SPEED_MODE_OPTIONS.keys())
self._original_speed_modes_keys = set(FAN_SPEED_MODE_OPTIONS_INVERTED)
super().__init__(
coordinator,
AIR_CONDITIONER_ENTITY_DESCRIPTION,
context_override=(
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
),
)
self.update_preset_mode()
@callback
def _handle_coordinator_update_preset_mode(self) -> None:
"""Handle updated data from the coordinator."""
self.update_preset_mode()
self.async_write_ha_state()
_LOGGER.debug(
"Updated %s (fan mode), new state: %s", self.entity_id, self.preset_mode
)
async def async_added_to_hass(self) -> None:
"""When entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.async_add_listener(
self._handle_coordinator_update_preset_mode,
EventKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
)
)
def update_native_value(self) -> None:
"""Set the speed percentage and speed mode values."""
option_value = None
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
if event := self.appliance.events.get(EventKey(option_key)):
option_value = event.value
self._attr_percentage = (
cast(int, option_value) if option_value is not None else None
)
@property
def supported_features(self) -> FanEntityFeature:
"""Return the supported features for this fan entity."""
features = FanEntityFeature(0)
if (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE
in self.appliance.options
):
features |= FanEntityFeature.SET_SPEED
if (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
in self.appliance.options
):
features |= FanEntityFeature.PRESET_MODE
return features
def update_preset_mode(self) -> None:
"""Set the preset mode value."""
option_value = None
option_key = OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
if event := self.appliance.events.get(EventKey(option_key)):
option_value = event.value
self._attr_preset_mode = (
FAN_SPEED_MODE_OPTIONS_INVERTED.get(cast(str, option_value))
if option_value is not None
else None
)
if (
(
option_definition := self.appliance.options.get(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE
)
)
and (option_constraints := option_definition.constraints)
and option_constraints.allowed_values
and (
allowed_values_without_none := {
value
for value in option_constraints.allowed_values
if value is not None
}
)
and self._original_speed_modes_keys != allowed_values_without_none
):
self._original_speed_modes_keys = allowed_values_without_none
self._attr_preset_modes = [
key
for key, value in FAN_SPEED_MODE_OPTIONS.items()
if value in self._original_speed_modes_keys
]
async def async_set_percentage(self, percentage: int) -> None:
"""Set the speed of the fan, as a percentage."""
await self._async_set_option(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
percentage,
)
_LOGGER.debug(
"Updated %s's speed percentage option, new state: %s",
self.entity_id,
percentage,
)
async def async_set_preset_mode(self, preset_mode: str) -> None:
"""Set new target fan mode."""
await self._async_set_option(
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
FAN_SPEED_MODE_OPTIONS[preset_mode],
)
_LOGGER.debug(
"Updated %s's speed mode option, new state: %s",
self.entity_id,
self.state,
)
async def _async_set_option(self, key: OptionKey, value: str | int) -> None:
"""Set an option for the entity."""
try:
# We try to set the active program option first,
# if it fails we try to set the selected program option
with contextlib.suppress(ActiveProgramNotSetError):
await self.coordinator.client.set_active_program_option(
self.appliance.info.ha_id,
option_key=key,
value=value,
)
return
await self.coordinator.client.set_selected_program_option(
self.appliance.info.ha_id,
option_key=key,
value=value,
)
except HomeConnectError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="set_option",
translation_placeholders=get_dict_from_home_connect_error(err),
) from err
@property
def available(self) -> bool:
"""Return True if entity is available."""
return super().available and any(
option in self.appliance.options
for option in (
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_MODE,
OptionKey.HEATING_VENTILATION_AIR_CONDITIONING_AIR_CONDITIONER_FAN_SPEED_PERCENTAGE,
)
)

View File

@@ -136,7 +136,7 @@ def _get_entities_for_appliance(
def _get_option_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectOptionEntity]:
) -> list[HomeConnectEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectOptionNumberEntity(appliance_coordinator, description)

View File

@@ -355,7 +355,7 @@ def _get_entities_for_appliance(
def _get_option_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectOptionEntity]:
) -> list[HomeConnectEntity]:
"""Get a list of entities."""
return [
HomeConnectSelectOptionEntity(appliance_coordinator, desc)

View File

@@ -119,6 +119,18 @@
"name": "Stop program"
}
},
"fan": {
"air_conditioner": {
"state_attributes": {
"preset_mode": {
"state": {
"auto": "[%key:common::state::auto%]",
"manual": "[%key:common::state::manual%]"
}
}
}
}
},
"light": {
"ambient_light": {
"name": "Ambient light"

View File

@@ -189,7 +189,7 @@ def _get_entities_for_appliance(
def _get_option_entities_for_appliance(
appliance_coordinator: HomeConnectApplianceCoordinator,
entity_registry: er.EntityRegistry,
) -> list[HomeConnectOptionEntity]:
) -> list[HomeConnectEntity]:
"""Get a list of currently available option entities."""
return [
HomeConnectSwitchOptionEntity(appliance_coordinator, description)

View File

@@ -29,21 +29,21 @@
"title": "Humidity",
"triggers": {
"changed": {
"description": "Triggers when the humidity changes.",
"description": "Triggers when the relative humidity changes.",
"fields": {
"above": {
"description": "Only trigger when humidity is above this value.",
"description": "Only trigger when relative humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when humidity is below this value.",
"description": "Only trigger when relative humidity is below this value.",
"name": "Below"
}
},
"name": "Humidity changed"
"name": "Relative humidity changed"
},
"crossed_threshold": {
"description": "Triggers when the humidity crosses a threshold.",
"description": "Triggers when the relative humidity crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::humidity::common::trigger_behavior_description%]",
@@ -62,7 +62,7 @@
"name": "Upper limit"
}
},
"name": "Humidity crossed threshold"
"name": "Relative humidity crossed threshold"
}
}
}

View File

@@ -19,6 +19,7 @@
selector:
number:
mode: box
unit_of_measurement: "%"
entity:
selector:
entity:

View File

@@ -58,7 +58,7 @@ def _is_supported(discovery_info: BluetoothServiceInfo):
# Some mowers only expose the serial number in the manufacturer data
# and not the product type, so we allow None here as well.
if product_type not in (ProductType.MOWER, None):
if product_type not in (ProductType.MOWER, ProductType.UNKNOWN):
LOGGER.debug("Unsupported device: %s (%s)", manufacturer_data, discovery_info)
return False

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/husqvarna_automower_ble",
"integration_type": "device",
"iot_class": "local_polling",
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==1.6.0"]
"requirements": ["automower-ble==0.2.8", "gardena-bluetooth==2.1.0"]
}

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

@@ -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

@@ -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

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["jvcprojector"],
"requirements": ["pyjvcprojector==2.0.2"]
"requirements": ["pyjvcprojector==2.0.3"]
}

View File

@@ -7,7 +7,7 @@ import csv
import dataclasses
import logging
import os
from typing import TYPE_CHECKING, Any, Self, cast, final
from typing import TYPE_CHECKING, Any, Self, cast, final, override
from propcache.api import cached_property
import voluptuous as vol
@@ -272,6 +272,18 @@ def filter_turn_off_params(
return {k: v for k, v in params.items() if k in (ATTR_TRANSITION, ATTR_FLASH)}
def process_turn_off_params(
hass: HomeAssistant, light: LightEntity, params: dict[str, Any]
) -> dict[str, Any]:
"""Process light turn off params."""
params = dict(params)
if ATTR_TRANSITION not in params:
hass.data[DATA_PROFILES].apply_default(light.entity_id, True, params)
return params
def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[str, Any]:
"""Filter out params not supported by the light."""
supported_features = light.supported_features
@@ -306,7 +318,171 @@ def filter_turn_on_params(light: LightEntity, params: dict[str, Any]) -> dict[st
return params
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa: C901
def process_turn_on_params( # noqa: C901
hass: HomeAssistant, light: LightEntity, params: dict[str, Any]
) -> dict[str, Any]:
"""Process light turn on params."""
params = dict(params)
# Only process params once we processed brightness step
if params and (
ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params
):
brightness = light.brightness if light.is_on and light.brightness else 0
if ATTR_BRIGHTNESS_STEP in params:
brightness += params.pop(ATTR_BRIGHTNESS_STEP)
else:
brightness_pct = round(brightness / 255 * 100)
brightness = round(
(brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255
)
params[ATTR_BRIGHTNESS] = max(0, min(255, brightness))
preprocess_turn_on_alternatives(hass, params)
if (not params or not light.is_on) or (params and ATTR_TRANSITION not in params):
hass.data[DATA_PROFILES].apply_default(light.entity_id, light.is_on, params)
supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001
# If a color temperature is specified, emulate it if not supported by the light
if ATTR_COLOR_TEMP_KELVIN in params:
if (
ColorMode.COLOR_TEMP not in supported_color_modes
and ColorMode.RGBWW in supported_color_modes
):
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
color_temp,
brightness,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
elif ColorMode.COLOR_TEMP not in supported_color_modes:
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
if color_supported(supported_color_modes):
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(color_temp)
# If a color is specified, convert to the color space supported by the light
rgb_color: tuple[int, int, int] | None
rgbww_color: tuple[int, int, int, int, int] | None
if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes:
hs_color = params.pop(ATTR_HS_COLOR)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
elif ColorMode.RGBW in supported_color_modes:
rgb_color = color_util.color_hs_to_RGB(*hs_color)
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
rgb_color = color_util.color_hs_to_RGB(*hs_color)
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_hs_to_xy(*hs_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
rgb_color = params.pop(ATTR_RGB_COLOR)
assert rgb_color is not None
if TYPE_CHECKING:
rgb_color = cast(tuple[int, int, int], rgb_color)
if ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
xy_color = params.pop(ATTR_XY_COLOR)
if ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
elif ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color)
elif ColorMode.RGBW in supported_color_modes:
rgb_color = color_util.color_xy_to_RGB(*xy_color)
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
rgb_color = color_util.color_xy_to_RGB(*xy_color)
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.COLOR_TEMP in supported_color_modes:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
rgbw_color = params.pop(ATTR_RGBW_COLOR)
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = rgb_color
elif ColorMode.RGBWW in supported_color_modes:
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes:
rgbww_color = params.pop(ATTR_RGBWW_COLOR)
assert rgbww_color is not None
if TYPE_CHECKING:
rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color)
rgb_color = color_util.color_rgbww_to_rgb(
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = rgb_color
elif ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
# If white is set to True, set it to the light's brightness
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
# integer.
if params.get(ATTR_WHITE) is True:
params[ATTR_WHITE] = light.brightness
# If both white and brightness are specified, override white
if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes:
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
return params
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Expose light control via state machine and services."""
component = hass.data[DATA_COMPONENT] = EntityComponent[LightEntity](
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
@@ -330,177 +506,15 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
base["params"] = data
return base
async def async_handle_light_on_service( # noqa: C901
async def async_handle_light_on_service(
light: LightEntity, call: ServiceCall
) -> None:
"""Handle turning a light on.
If brightness is set to 0, this service will turn the light off.
"""
params: dict[str, Any] = dict(call.data["params"])
params = process_turn_on_params(hass, light, call.data["params"])
# Only process params once we processed brightness step
if params and (
ATTR_BRIGHTNESS_STEP in params or ATTR_BRIGHTNESS_STEP_PCT in params
):
brightness = light.brightness if light.is_on and light.brightness else 0
if ATTR_BRIGHTNESS_STEP in params:
brightness += params.pop(ATTR_BRIGHTNESS_STEP)
else:
brightness_pct = round(brightness / 255 * 100)
brightness = round(
(brightness_pct + params.pop(ATTR_BRIGHTNESS_STEP_PCT)) / 100 * 255
)
params[ATTR_BRIGHTNESS] = max(0, min(255, brightness))
preprocess_turn_on_alternatives(hass, params)
if (not params or not light.is_on) or (
params and ATTR_TRANSITION not in params
):
profiles.apply_default(light.entity_id, light.is_on, params)
supported_color_modes = light._light_internal_supported_color_modes # noqa: SLF001
# If a color temperature is specified, emulate it if not supported by the light
if ATTR_COLOR_TEMP_KELVIN in params:
if (
ColorMode.COLOR_TEMP not in supported_color_modes
and ColorMode.RGBWW in supported_color_modes
):
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
brightness = cast(int, params.get(ATTR_BRIGHTNESS, light.brightness))
params[ATTR_RGBWW_COLOR] = color_util.color_temperature_to_rgbww(
color_temp,
brightness,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
elif ColorMode.COLOR_TEMP not in supported_color_modes:
color_temp = params.pop(ATTR_COLOR_TEMP_KELVIN)
if color_supported(supported_color_modes):
params[ATTR_HS_COLOR] = color_util.color_temperature_to_hs(
color_temp
)
# If a color is specified, convert to the color space supported by the light
rgb_color: tuple[int, int, int] | None
rgbww_color: tuple[int, int, int, int, int] | None
if ATTR_HS_COLOR in params and ColorMode.HS not in supported_color_modes:
hs_color = params.pop(ATTR_HS_COLOR)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color)
elif ColorMode.RGBW in supported_color_modes:
rgb_color = color_util.color_hs_to_RGB(*hs_color)
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
rgb_color = color_util.color_hs_to_RGB(*hs_color)
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_hs_to_xy(*hs_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGB_COLOR in params and ColorMode.RGB not in supported_color_modes:
rgb_color = params.pop(ATTR_RGB_COLOR)
assert rgb_color is not None
if TYPE_CHECKING:
rgb_color = cast(tuple[int, int, int], rgb_color)
if ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color,
light.min_color_temp_kelvin,
light.max_color_temp_kelvin,
)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_XY_COLOR in params and ColorMode.XY not in supported_color_modes:
xy_color = params.pop(ATTR_XY_COLOR)
if ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_xy_to_hs(*xy_color)
elif ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = color_util.color_xy_to_RGB(*xy_color)
elif ColorMode.RGBW in supported_color_modes:
rgb_color = color_util.color_xy_to_RGB(*xy_color)
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.RGBWW in supported_color_modes:
rgb_color = color_util.color_xy_to_RGB(*xy_color)
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.COLOR_TEMP in supported_color_modes:
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif ATTR_RGBW_COLOR in params and ColorMode.RGBW not in supported_color_modes:
rgbw_color = params.pop(ATTR_RGBW_COLOR)
rgb_color = color_util.color_rgbw_to_rgb(*rgbw_color)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = rgb_color
elif ColorMode.RGBWW in supported_color_modes:
params[ATTR_RGBWW_COLOR] = color_util.color_rgb_to_rgbww(
*rgb_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
elif (
ATTR_RGBWW_COLOR in params and ColorMode.RGBWW not in supported_color_modes
):
rgbww_color = params.pop(ATTR_RGBWW_COLOR)
assert rgbww_color is not None
if TYPE_CHECKING:
rgbww_color = cast(tuple[int, int, int, int, int], rgbww_color)
rgb_color = color_util.color_rgbww_to_rgb(
*rgbww_color, light.min_color_temp_kelvin, light.max_color_temp_kelvin
)
if ColorMode.RGB in supported_color_modes:
params[ATTR_RGB_COLOR] = rgb_color
elif ColorMode.RGBW in supported_color_modes:
params[ATTR_RGBW_COLOR] = color_util.color_rgb_to_rgbw(*rgb_color)
elif ColorMode.HS in supported_color_modes:
params[ATTR_HS_COLOR] = color_util.color_RGB_to_hs(*rgb_color)
elif ColorMode.XY in supported_color_modes:
params[ATTR_XY_COLOR] = color_util.color_RGB_to_xy(*rgb_color)
elif ColorMode.COLOR_TEMP in supported_color_modes:
xy_color = color_util.color_RGB_to_xy(*rgb_color)
params[ATTR_COLOR_TEMP_KELVIN] = color_util.color_xy_to_temperature(
*xy_color
)
# If white is set to True, set it to the light's brightness
# Add a warning in Home Assistant Core 2024.3 if the brightness is set to an
# integer.
if params.get(ATTR_WHITE) is True:
params[ATTR_WHITE] = light.brightness
# If both white and brightness are specified, override white
if ATTR_WHITE in params and ColorMode.WHITE in supported_color_modes:
params[ATTR_WHITE] = params.pop(ATTR_BRIGHTNESS, params[ATTR_WHITE])
# Remove deprecated white value if the light supports color mode
if params.get(ATTR_BRIGHTNESS) == 0 or params.get(ATTR_WHITE) == 0:
await async_handle_light_off_service(light, call)
else:
@@ -510,10 +524,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
light: LightEntity, call: ServiceCall
) -> None:
"""Handle turning off a light."""
params = dict(call.data["params"])
if ATTR_TRANSITION not in params:
profiles.apply_default(light.entity_id, True, params)
params = process_turn_off_params(hass, light, call.data["params"])
await light.async_turn_off(**filter_turn_off_params(light, params))
@@ -521,10 +532,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
light: LightEntity, call: ServiceCall
) -> None:
"""Handle toggling a light."""
if light.is_on:
await async_handle_light_off_service(light, call)
else:
await async_handle_light_on_service(light, call)
await light.async_toggle(**call.data["params"])
# Listen for light on and light off service calls.
@@ -1046,3 +1054,15 @@ class LightEntity(ToggleEntity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
def supported_features(self) -> LightEntityFeature:
"""Flag supported features."""
return self._attr_supported_features
@override
async def async_toggle(self, **kwargs: Any) -> None:
"""Toggle the entity."""
if not self.is_on:
params = process_turn_on_params(self.hass, self, kwargs)
if params.get(ATTR_BRIGHTNESS) != 0 and params.get(ATTR_WHITE) != 0:
await self.async_turn_on(**filter_turn_on_params(self, params))
return
params = process_turn_off_params(self.hass, self, kwargs)
await self.async_turn_off(**filter_turn_off_params(self, params))

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,53 @@
"""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.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
class _MotionBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for motion binary sensor state changes."""
_domains = {BINARY_SENSOR_DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities by motion 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)
== 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,57 @@
"""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.trigger import (
EntityTargetStateTriggerBase,
EntityTriggerBase,
Trigger,
get_device_class_or_undefined,
)
class _OccupancyBinaryTriggerBase(EntityTriggerBase):
"""Base trigger for occupancy binary sensor state changes."""
_domains = {BINARY_SENSOR_DOMAIN}
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities by occupancy 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)
== 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

@@ -19,6 +19,5 @@ async def async_get_config_entry_diagnostics(
return {
"device_info": client.device_info,
"vehicles": client.vehicles,
"ct_connected": client.ct_connected,
"cap_available": client.cap_available,
}

View File

@@ -7,5 +7,5 @@
"integration_type": "device",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["ohme==1.6.0"]
"requirements": ["ohme==1.7.0"]
}

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

@@ -431,6 +431,7 @@ class OpowerCoordinator(DataUpdateCoordinator[dict[str, OpowerData]]):
for source_id, source_stats in existing_stats.items():
_LOGGER.debug("Found %d statistics for %s", len(source_stats), source_id)
if not source_stats:
need_migration_source_ids.remove(source_id)
continue
target_id = migration_map[source_id]

View File

@@ -8,6 +8,6 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["opower"],
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["opower==0.17.0"]
}

View File

@@ -39,7 +39,7 @@ rules:
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage: todo
test-coverage: done
# Gold
devices:

View File

@@ -8,5 +8,5 @@
"documentation": "https://www.home-assistant.io/integrations/otbr",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["python-otbr-api==2.8.0"]
"requirements": ["python-otbr-api==2.9.0"]
}

View File

@@ -196,6 +196,7 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
# Check if container belongs to a stack via docker compose label
stack_name: str | None = (
container.labels.get("com.docker.compose.project")
or container.labels.get("com.docker.stack.namespace")
if container.labels
else None
)

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

@@ -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

@@ -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

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

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