Compare commits

..

97 Commits

Author SHA1 Message Date
Daniel Hjelseth Høyer
5a2ecf5e39 Merge branch 'dev' into tibber_data 2025-12-02 21:02:45 +01:00
Joost Lekkerkerker
d75e5498c6 Add health concern entities to SmartThings (#157773) 2025-12-02 21:00:50 +01:00
puddly
2dd58dbe39 Fix ZHA network formation (#157769) 2025-12-02 14:59:55 -05:00
Joost Lekkerkerker
4ef17799db Add snapshot test to Vivotek (#157767) 2025-12-02 20:47:02 +01:00
Joost Lekkerkerker
9373378350 Add fixture for hood to SmartThings (#157770) 2025-12-02 20:46:06 +01:00
Daniel Hjelseth Høyer
7d6ceb0975 test
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-02 20:45:00 +01:00
Marcel van der Veldt
18833a194b Let AuthenticationRequired also trigger the reauth flow in MusicAssistant (#157580) 2025-12-02 14:22:40 -05:00
Kevin Stillhammer
2631c77bee add platform binary_sensor to fressnapf_tracker (#157753) 2025-12-02 20:05:34 +01:00
Kevin McCormack
c67247bf32 Add config flow for Vivotek integration (#154801)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-02 19:47:22 +01:00
Joost Lekkerkerker
18b5ffd365 Add SmartThings walloven fixtures (#157748) 2025-12-02 19:32:28 +01:00
Bram Kragten
c4e3a4d65e Update frontend to 20251202.0 (#157755)
Co-authored-by: Franck Nijhof <git@frenck.dev>
2025-12-02 17:50:13 +01:00
victorigualada
84d2686517 Don't register Home Assistant Cloud LLM platforms if not logged in (#157630) 2025-12-02 17:47:08 +01:00
Michael Hansen
ae8980ce5b Bump intents to 2025.12.2 (#157758) 2025-12-02 17:43:21 +01:00
epenet
b2d4c9ecb4 Add exception translation to SFR box (#157756) 2025-12-02 17:42:16 +01:00
Matthias Alphart
f5b046ee7d Add integration_type for Fronius (#157760) 2025-12-02 17:31:05 +01:00
epenet
55c5fb7374 Migrate Tuya climate (swing) to use wrapper class (#157646) 2025-12-02 17:24:36 +01:00
Erik Montnemery
5d78cd328a Remove explicit templating of velbus service data (#157749) 2025-12-02 17:00:10 +01:00
epenet
bc36578ada Add mac address to SFR Box device registry entries (#157752) 2025-12-02 16:52:09 +01:00
Erik Montnemery
e63242e465 Add occupancy binary sensor triggers (#157631) 2025-12-02 16:37:02 +01:00
epenet
e84c09745d Bump SFR box IQS to silver (#157754) 2025-12-02 16:32:38 +01:00
Julian Meier
f07991d0ba Add boot and energy sensor to MyStrom Switch (#155132) 2025-12-02 15:42:04 +01:00
epenet
872fef1f6f Add reconfigure flow to SFR Box (#157711) 2025-12-02 15:35:25 +01:00
Kevin Stillhammer
c866dc973c Add sensor platform to fressnapf_tracker (#157658)
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2025-12-02 15:33:57 +01:00
Zoltán Farkasdi
e2acf30637 Add Netatmo outdoor camera test (#156740) 2025-12-02 15:01:47 +01:00
epenet
29631a2c5a Cleanup SFR Box sensors (#157708) 2025-12-02 14:52:52 +01:00
Heindrich Paul
1d31e6d0ea Create more sensors for Nederlandse Spoorwegen (#154466)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: G Johansson <goran.johansson@shiftit.se>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-02 14:39:05 +01:00
Artur Pragacz
8109d9a39c Add integration type to music_assistant (#157725)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 13:38:08 +01:00
Artur Pragacz
e1abd451b8 Add integration type to google (#157729)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 13:37:37 +01:00
Robert Resch
2c72cd94f2 Create the go2rtc unix socket inside a temporary folder (#157742) 2025-12-02 13:35:39 +01:00
Franck Nijhof
3bccb4b89c Rename preview feature to purpose-specific triggers and conditions (#157717) 2025-12-02 13:34:52 +01:00
Artur Pragacz
6d4fb30630 Add integration type to tplink (#157735)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 13:24:21 +01:00
Artur Pragacz
c04411f1bc Add integration type to dlna_dmr (#157733)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 12:48:59 +01:00
Artur Pragacz
753ea023de Add integration type to ibeacon (#157734)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 12:48:33 +01:00
Artur Pragacz
1ca1cf59eb Add integration type to ring (#157738)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 12:44:09 +01:00
Artur Pragacz
5b01bb1a29 Add integration type to broadlink (#157739)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 12:43:19 +01:00
Artur Pragacz
15c89d24eb Add integration type to xiaomi_ble (#157740)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 12:42:21 +01:00
Artur Pragacz
b26b2347e6 Add integration type to roborock (#157737)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 12:42:10 +01:00
Artur Pragacz
7d54103c09 Add integration type to speedtestdotnet (#157727)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 12:27:25 +01:00
Artur Pragacz
c705a1dc4b Add integration type to rest (#157728)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 12:26:02 +01:00
Artur Pragacz
998bd23446 Add integration type to webostv (#157736)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 12:25:37 +01:00
Artur Pragacz
3a1a58d6ad Add integration type to ping (#157730)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 12:23:19 +01:00
Artur Pragacz
f9219dd841 Add integration type to dlna_dms (#157723)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 12:04:17 +01:00
Artur Pragacz
402ed7e0f3 Add integration type to met (#157720)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 11:51:51 +01:00
epenet
7a1a5df89e Use _async_send_commands in Tuya base entity (#157716) 2025-12-02 11:50:07 +01:00
Artur Pragacz
df558fc1e7 Add integration type to google_translate (#157718)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 11:47:30 +01:00
Erik Montnemery
ec66407ef1 Improve helpers.condition.async_subscribe_platform_events (#157710) 2025-12-02 11:32:14 +01:00
Paulus Schoutsen
6b99234a43 Add integration_type to SwitchBot Bluetooth manifest (#157675)
Co-authored-by: Claude <noreply@anthropic.com>
2025-12-02 11:31:04 +01:00
Erik Montnemery
393be71009 Improve trigger descriptions (#157643)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-12-02 11:08:39 +01:00
epenet
12bc1687ec Use _async_send_commands in Tuya vacuum (#157704) 2025-12-02 11:01:51 +01:00
epenet
c59b322c0a Use _async_send_commands in Tuya light (#157703) 2025-12-02 11:01:38 +01:00
Arjan
e00266463d Meteo France: add new mapping "Brouillard dense givrant" (#157627) 2025-12-02 10:55:51 +01:00
dependabot[bot]
cbc8a33553 Bump github/codeql-action from 4.31.5 to 4.31.6 (#157700)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-12-02 10:52:13 +01:00
Paulus Schoutsen
28582f75d4 Add integration_type to Ecowitt manifest (#157666) 2025-12-02 10:49:58 +01:00
J. Diego Rodríguez Royo
39cccd212d Bump aiohomeconnect to version 0.24.0 (#157670) 2025-12-02 10:46:37 +01:00
Brett Adams
329ea33337 Add integration_type to Teslemetry manifest (#157677) 2025-12-02 10:45:41 +01:00
Brett Adams
521733c420 Revert integration type in Tessie (#157713) 2025-12-02 10:45:21 +01:00
Brett Adams
33e9f9a0ff Add integration_type to Tesla Fleet manifest (#157679) 2025-12-02 10:44:49 +01:00
Erik Montnemery
5fda2bccbe Improve helpers.trigger.async_subscribe_platform_events (#157709) 2025-12-02 10:37:19 +01:00
Daniel Hjelseth Høyer
5d03ce2b28 Update homeassistant/components/tibber/sensor.py
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-02 10:06:56 +01:00
Åke Strandberg
ae75332656 Add program id:s and phases to new Miele WQ1000 (#157660) 2025-12-02 09:25:47 +01:00
Paulus Schoutsen
b171785f96 Add integration_type to SmartThings manifest (#157673) 2025-12-02 09:17:49 +01:00
Paulus Schoutsen
ff3d6783c6 Add integration_type to Konnected.io manifest (#157681) 2025-12-02 09:15:18 +01:00
cdnninja
b1e579bea0 Bump pyvesync to 3.3.3 (#157697) 2025-12-02 09:14:41 +01:00
Jan Bouwhuis
87241ea051 Add read support for MQTT config entry version to 2.1 (#157623) 2025-12-02 08:02:06 +01:00
dependabot[bot]
a871ec0bdf Bump home-assistant/wheels from 2025.11.0 to 2025.12.0 (#157699) 2025-12-02 07:41:44 +01:00
Copilot
b8829b645a Add labs_updated event to subscription allowlist (#157552)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: balloob <1444314+balloob@users.noreply.github.com>
2025-12-02 07:35:29 +01:00
Daniel Hjelseth Høyer
1a9b72a5d5 tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-12-02 06:19:47 +01:00
Daniel Hjelseth Høyer
0f460730c5 adjust available state
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-27 20:33:01 +01:00
Daniel Hjelseth Høyer
2b898d2eab config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-27 18:54:32 +01:00
Daniel Hjelseth Høyer
45ddbb6ab8 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-27 16:48:46 +01:00
Daniel Hjelseth Høyer
7d8a89258f config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-27 16:36:51 +01:00
Daniel Hjelseth Høyer
420e01ef26 fix strings
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-26 07:13:34 +01:00
Daniel Hjelseth Høyer
b1b3eb80b1 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-25 20:25:58 +01:00
Daniel Hjelseth Høyer
1d717a1957 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-25 19:02:38 +01:00
Daniel Hjelseth Høyer
a8e3f79941 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-25 18:57:12 +01:00
Daniel Hjelseth Høyer
946afd7d91 config
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-25 07:17:34 +01:00
Daniel Hjelseth Høyer
f6277d0ec2 Merge branch 'dev' into tibber_data 2025-11-24 12:21:03 +01:00
Daniel Hjelseth Høyer
d7aa939f83 Merge branch 'tibber_data' of github.com:home-assistant/core into tibber_data 2025-11-19 06:53:01 +01:00
Daniel Hjelseth Høyer
77b349d00f test
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-19 06:52:06 +01:00
Daniel Hjelseth Høyer
1c036128fa Merge branch 'dev' into tibber_data 2025-11-18 08:40:09 +01:00
Daniel Hjelseth Høyer
16d898cc8e test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-18 07:12:48 +01:00
Daniel Hjelseth Høyer
a7225c7cd4 Merge branch 'dev' into tibber_data 2025-11-18 06:51:29 +01:00
Daniel Hjelseth Høyer
433a429c5a test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-18 06:37:33 +01:00
Daniel Hjelseth Høyer
c4770ed423 test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-17 20:57:03 +01:00
Daniel Hjelseth Høyer
df329fd273 test coverage
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-17 20:36:43 +01:00
Daniel Hjelseth Høyer
6eb40574bc tests
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 19:39:19 +01:00
Daniel Hjelseth Høyer
4fd1ef5483 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 17:49:18 +01:00
Daniel Hjelseth Høyer
7ec5d5305d Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 16:38:01 +01:00
Daniel Hjelseth Høyer
7f31d2538e Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 16:08:45 +01:00
Daniel Hjelseth Høyer
e1943307cf Merge branch 'dev' into tibber_data 2025-11-16 16:08:21 +01:00
Daniel Hjelseth Høyer
a06529d187 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 15:59:18 +01:00
Daniel Hjelseth Høyer
21554af6a1 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-16 12:14:03 +01:00
Daniel Hjelseth Høyer
b4aae93c45 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-14 19:18:22 +01:00
Daniel Hjelseth Høyer
1f9c244c5c Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-14 06:01:05 +01:00
Daniel Hjelseth Høyer
9fa1b1b8df Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-13 22:11:18 +01:00
Daniel Hjelseth Høyer
f3ac3ecf05 Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-13 21:07:27 +01:00
Daniel Hjelseth Høyer
9477b2206b Tibber data api
Signed-off-by: Daniel Hjelseth Høyer <github@dahoiv.net>
2025-11-13 20:07:57 +01:00
192 changed files with 11765 additions and 1055 deletions

View File

@@ -24,11 +24,11 @@ jobs:
uses: actions/checkout@1af3b93b6815bc44a9784bd300feb67ff0d1eeb3 # v6.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
uses: github/codeql-action/init@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
with:
languages: python
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@fdbfb4d2750291e159f0156def62b853c2798ca2 # v4.31.5
uses: github/codeql-action/analyze@fe4161a26a8629af62121b670040955b330f9af2 # v4.31.6
with:
category: "/language:python"

View File

@@ -136,7 +136,7 @@ jobs:
sed -i "/uv/d" requirements_diff.txt
- name: Build wheels
uses: &home-assistant-wheels home-assistant/wheels@6066c17a2a4aafcf7bdfeae01717f63adfcdba98 # 2025.11.0
uses: &home-assistant-wheels home-assistant/wheels@e5742a69d69f0e274e2689c998900c7d19652c21 # 2025.12.0
with:
abi: ${{ matrix.abi }}
tag: musllinux_1_2

1
CODEOWNERS generated
View File

@@ -1763,6 +1763,7 @@ build.json @home-assistant/supervisor
/homeassistant/components/vilfo/ @ManneW
/tests/components/vilfo/ @ManneW
/homeassistant/components/vivotek/ @HarlemSquirrel
/tests/components/vivotek/ @HarlemSquirrel
/homeassistant/components/vizio/ @raman325
/tests/components/vizio/ @raman325
/homeassistant/components/vlc_telnet/ @rodripf @MartinHjelmare

View File

@@ -7,6 +7,7 @@ from typing import Any, Final
from homeassistant.const import (
EVENT_COMPONENT_LOADED,
EVENT_CORE_CONFIG_UPDATE,
EVENT_LABS_UPDATED,
EVENT_LOVELACE_UPDATED,
EVENT_PANELS_UPDATED,
EVENT_RECORDER_5MIN_STATISTICS_GENERATED,
@@ -45,6 +46,7 @@ SUBSCRIBE_ALLOWLIST: Final[set[EventType[Any] | str]] = {
EVENT_STATE_CHANGED,
EVENT_THEMES_UPDATED,
EVENT_LABEL_REGISTRY_UPDATED,
EVENT_LABS_UPDATED,
EVENT_CATEGORY_REGISTRY_UPDATED,
EVENT_FLOOR_REGISTRY_UPDATED,
}

View File

@@ -159,74 +159,74 @@
"title": "Alarm control panel",
"triggers": {
"armed": {
"description": "Triggers when an alarm is armed.",
"description": "Triggers after one or more alarms become armed, regardless of the mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed"
"name": "Alarm armed"
},
"armed_away": {
"description": "Triggers when an alarm is armed away.",
"description": "Triggers after one or more alarms become armed in away mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed away"
"name": "Alarm armed away"
},
"armed_home": {
"description": "Triggers when an alarm is armed home.",
"description": "Triggers after one or more alarms become armed in home mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed home"
"name": "Alarm armed home"
},
"armed_night": {
"description": "Triggers when an alarm is armed night.",
"description": "Triggers after one or more alarms become armed in night mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed night"
"name": "Alarm armed night"
},
"armed_vacation": {
"description": "Triggers when an alarm is armed vacation.",
"description": "Triggers after one or more alarms become armed in vacation mode.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed vacation"
"name": "Alarm armed vacation"
},
"disarmed": {
"description": "Triggers when an alarm is disarmed.",
"description": "Triggers after one or more alarms become disarmed.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is disarmed"
"name": "Alarm disarmed"
},
"triggered": {
"description": "Triggers when an alarm is triggered.",
"description": "Triggers after one or more alarms become triggered.",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is triggered"
"name": "Alarm triggered"
}
}
}

View File

@@ -112,44 +112,44 @@
"title": "Assist satellite",
"triggers": {
"idle": {
"description": "Triggers when an Assist satellite becomes idle.",
"description": "Triggers after one or more voice assistant satellites become idle after having processed a command.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
"name": "When an Assist satellite becomes idle"
"name": "Satellite became idle"
},
"listening": {
"description": "Triggers when an Assist satellite starts listening.",
"description": "Triggers after one or more voice assistant satellites start listening for a command from someone.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
"name": "When an Assist satellite starts listening"
"name": "Satellite started listening"
},
"processing": {
"description": "Triggers when an Assist satellite is processing.",
"description": "Triggers after one or more voice assistant satellites start processing a command after having heard it.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
"name": "When an Assist satellite is processing"
"name": "Satellite started processing"
},
"responding": {
"description": "Triggers when an Assist satellite is responding.",
"description": "Triggers after one or more voice assistant satellites start responding to a command after having processed it, or start announcing something.",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
"name": "When an Assist satellite is responding"
"name": "Satellite started responding"
}
}
}

View File

@@ -124,6 +124,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"binary_sensor",
"climate",
"cover",
"fan",

View File

@@ -69,10 +69,10 @@
},
"preview_features": {
"new_triggers_conditions": {
"description": "Enables new intuitive triggers and conditions that are more user-friendly than technical state-based options.\n\nThese new automation features support targets across your entire home, letting you trigger automations for any entity, device, area, floor, or label (for example, when any light in your living room turned on). Integrations can now also provide their own intuitive triggers and conditions, just like actions.\n\nThis preview also includes a new tree view to help you navigate your home when adding triggers, conditions, and actions.",
"disable_confirmation": "Disabling this preview will cause automations and scripts that use the new intuitive triggers and conditions to fail.\n\nBefore disabling, ensure that your automations or scripts do not rely on this feature.",
"enable_confirmation": "This feature is still in development and may change. These new intuitive triggers and conditions are being refined based on user feedback and are not yet complete.\n\nBy enabling this preview, you'll have early access to these new capabilities, but be aware that they may be modified or updated in future releases.",
"name": "Intuitive triggers and conditions"
"description": "Enables new purpose-specific triggers and conditions that are more user-friendly than technical state-based options.\n\nThese new automation features support targets across your entire home, letting you trigger automations for any entity, device, area, floor, or label (for example, when any light in your living room turned on). Integrations can now also provide their own purpose-specific triggers and conditions, just like actions.\n\nThis preview also includes a new tree view to help you navigate your home when adding triggers, conditions, and actions.",
"disable_confirmation": "Disabling this preview will cause automations and scripts that use the new purpose-specific triggers and conditions to fail.\n\nBefore disabling, ensure that your automations or scripts do not rely on this feature.",
"enable_confirmation": "This feature is still in development and may change. These new purpose-specific triggers and conditions are being refined based on user feedback and are not yet complete.\n\nBy enabling this preview, you'll have early access to these new capabilities, but be aware that they may be modified or updated in future releases.",
"name": "Purpose-specific triggers and conditions"
}
},
"services": {

View File

@@ -174,5 +174,13 @@
"on": "mdi:window-open"
}
}
},
"triggers": {
"occupancy_cleared": {
"trigger": "mdi:home-outline"
},
"occupancy_detected": {
"trigger": "mdi:home"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description_presence": "The behavior of the targeted presence sensors to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"condition_type": {
"is_bat_low": "{entity_name} battery is low",
@@ -317,5 +321,36 @@
}
}
},
"title": "Binary sensor"
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"title": "Binary sensor",
"triggers": {
"occupancy_cleared": {
"description": "Triggers after one or more occupancy sensors stop detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_presence%]",
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
}
},
"name": "Occupancy cleared"
},
"occupancy_detected": {
"description": "Triggers after one ore more occupancy sensors start detecting occupancy.",
"fields": {
"behavior": {
"description": "[%key:component::binary_sensor::common::trigger_behavior_description_presence%]",
"name": "[%key:component::binary_sensor::common::trigger_behavior_name%]"
}
},
"name": "Occupancy detected"
}
}
}

View File

@@ -0,0 +1,67 @@
"""Provides triggers for binary sensors."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import EntityStateTriggerBase, Trigger
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import DOMAIN, BinarySensorDeviceClass
def get_device_class_or_undefined(
hass: HomeAssistant, entity_id: str
) -> str | None | UndefinedType:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return get_device_class(hass, entity_id)
except HomeAssistantError:
return UNDEFINED
class BinarySensorOnOffTrigger(EntityStateTriggerBase):
"""Class for binary sensor on/off triggers."""
_device_class: BinarySensorDeviceClass | None
_domain: str = DOMAIN
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if get_device_class_or_undefined(self._hass, entity_id)
== self._device_class
}
def make_binary_sensor_trigger(
device_class: BinarySensorDeviceClass | None,
to_state: str,
) -> type[BinarySensorOnOffTrigger]:
"""Create an entity state trigger class."""
class CustomTrigger(BinarySensorOnOffTrigger):
"""Trigger for entity state changes."""
_device_class = device_class
_to_state = to_state
return CustomTrigger
TRIGGERS: dict[str, type[Trigger]] = {
"occupancy_detected": make_binary_sensor_trigger(
BinarySensorDeviceClass.OCCUPANCY, STATE_ON
),
"occupancy_cleared": make_binary_sensor_trigger(
BinarySensorDeviceClass.OCCUPANCY, STATE_OFF
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for binary sensors."""
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
occupancy_cleared:
fields: *trigger_common_fields
target:
entity:
domain: binary_sensor
device_class: presence
occupancy_detected:
fields: *trigger_common_fields
target:
entity:
domain: binary_sensor
device_class: presence

View File

@@ -36,6 +36,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/broadlink",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["broadlink"],
"requirements": ["broadlink==0.19.0"]

View File

@@ -299,54 +299,54 @@
"title": "Climate",
"triggers": {
"started_cooling": {
"description": "Triggers when a climate started cooling.",
"description": "Triggers after one or more climate-control devices start cooling.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate started cooling"
"name": "Climate-control device started cooling"
},
"started_drying": {
"description": "Triggers when a climate started drying.",
"description": "Triggers after one or more climate-control devices start drying.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate started drying"
"name": "Climate-control device started drying"
},
"started_heating": {
"description": "Triggers when a climate starts to heat.",
"description": "Triggers after one or more climate-control devices start heating.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate starts to heat"
"name": "Climate-control device started heating"
},
"turned_off": {
"description": "Triggers when a climate is turned off.",
"description": "Triggers after one or more climate-control devices turn off.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate is turned off"
"name": "Climate-control device turned off"
},
"turned_on": {
"description": "Triggers when a climate is turned on.",
"description": "Triggers after one or more climate-control devices turn on, regardless of the mode.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate is turned on"
"name": "Climate-control device turned on"
}
}
}

View File

@@ -4,12 +4,13 @@ from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable
from contextlib import suppress
from datetime import datetime, timedelta
from enum import Enum
import logging
from typing import Any, cast
from hass_nabucasa import Cloud
from hass_nabucasa import Cloud, NabuCasaBaseError
import voluptuous as vol
from homeassistant.components import alexa, google_assistant
@@ -78,13 +79,16 @@ from .subscription import async_subscription_info
DEFAULT_MODE = MODE_PROD
PLATFORMS = [
Platform.AI_TASK,
Platform.BINARY_SENSOR,
Platform.CONVERSATION,
Platform.STT,
Platform.TTS,
]
LLM_PLATFORMS = [
Platform.AI_TASK,
Platform.CONVERSATION,
]
SERVICE_REMOTE_CONNECT = "remote_connect"
SERVICE_REMOTE_DISCONNECT = "remote_disconnect"
@@ -431,7 +435,14 @@ def _handle_prefs_updated(hass: HomeAssistant, cloud: Cloud[CloudClient]) -> Non
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
platforms = PLATFORMS.copy()
if (cloud := hass.data[DATA_CLOUD]).is_logged_in:
with suppress(NabuCasaBaseError):
await cloud.llm.async_ensure_token()
platforms += LLM_PLATFORMS
await hass.config_entries.async_forward_entry_setups(entry, platforms)
entry.runtime_data = {"platforms": platforms}
stt_tts_entities_added = hass.data[DATA_PLATFORMS_SETUP]["stt_tts_entities_added"]
stt_tts_entities_added.set()
@@ -440,7 +451,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
return await hass.config_entries.async_unload_platforms(
entry, entry.runtime_data["platforms"]
)
@callback

View File

@@ -6,7 +6,6 @@ import io
from json import JSONDecodeError
import logging
from hass_nabucasa import NabuCasaBaseError
from hass_nabucasa.llm import (
LLMAuthenticationError,
LLMError,
@@ -20,7 +19,7 @@ from PIL import Image
from homeassistant.components import ai_task, conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.json import json_loads
@@ -94,17 +93,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Home Assistant Cloud AI Task entity."""
if not (cloud := hass.data[DATA_CLOUD]).is_logged_in:
return
try:
await cloud.llm.async_ensure_token()
except (LLMError, NabuCasaBaseError):
return
async_add_entities([CloudLLMTaskEntity(cloud, config_entry)])
cloud = hass.data[DATA_CLOUD]
async_add_entities([CloudAITaskEntity(cloud, config_entry)])
class CloudLLMTaskEntity(ai_task.AITaskEntity, BaseCloudLLMEntity):
class CloudAITaskEntity(BaseCloudLLMEntity, ai_task.AITaskEntity):
"""Home Assistant Cloud AI Task entity."""
_attr_has_entity_name = True
@@ -181,7 +174,7 @@ class CloudLLMTaskEntity(ai_task.AITaskEntity, BaseCloudLLMEntity):
attachments=attachments,
)
except LLMAuthenticationError as err:
raise ConfigEntryAuthFailed("Cloud LLM authentication failed") from err
raise HomeAssistantError("Cloud LLM authentication failed") from err
except LLMRateLimitError as err:
raise HomeAssistantError("Cloud LLM is rate limited") from err
except LLMResponseError as err:

View File

@@ -4,9 +4,6 @@ from __future__ import annotations
from typing import Literal
from hass_nabucasa import NabuCasaBaseError
from hass_nabucasa.llm import LLMError
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import MATCH_ALL
@@ -24,19 +21,13 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Home Assistant Cloud conversation entity."""
if not (cloud := hass.data[DATA_CLOUD]).is_logged_in:
return
try:
await cloud.llm.async_ensure_token()
except (LLMError, NabuCasaBaseError):
return
cloud = hass.data[DATA_CLOUD]
async_add_entities([CloudConversationEntity(cloud, config_entry)])
class CloudConversationEntity(
conversation.ConversationEntity,
BaseCloudLLMEntity,
conversation.ConversationEntity,
):
"""Home Assistant Cloud conversation agent."""

View File

@@ -8,10 +8,9 @@ import logging
import re
from typing import Any, Literal, cast
from hass_nabucasa import Cloud
from hass_nabucasa import Cloud, NabuCasaBaseError
from hass_nabucasa.llm import (
LLMAuthenticationError,
LLMError,
LLMRateLimitError,
LLMResponseError,
LLMServiceError,
@@ -37,7 +36,7 @@ from voluptuous_openapi import convert
from homeassistant.components import conversation
from homeassistant.config_entries import ConfigEntry
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import llm
from homeassistant.helpers.entity import Entity
from homeassistant.util import slugify
@@ -601,14 +600,14 @@ class BaseCloudLLMEntity(Entity):
)
except LLMAuthenticationError as err:
raise ConfigEntryAuthFailed("Cloud LLM authentication failed") from err
raise HomeAssistantError("Cloud LLM authentication failed") from err
except LLMRateLimitError as err:
raise HomeAssistantError("Cloud LLM is rate limited") from err
except LLMResponseError as err:
raise HomeAssistantError(str(err)) from err
except LLMServiceError as err:
raise HomeAssistantError("Error talking to Cloud LLM") from err
except LLMError as err:
except NabuCasaBaseError as err:
raise HomeAssistantError(str(err)) from err
if not chat_log.unresponded_tool_results:

View File

@@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/conversation",
"integration_type": "entity",
"quality_scale": "internal",
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.11.24"]
"requirements": ["hassil==3.4.0", "home-assistant-intents==2025.12.2"]
}

View File

@@ -6,6 +6,7 @@
"config_flow": true,
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["async_upnp_client"],
"requirements": ["async-upnp-client==0.46.0", "getmac==0.9.5"],

View File

@@ -6,6 +6,7 @@
"config_flow": true,
"dependencies": ["ssdp"],
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["async-upnp-client==0.46.0"],
"ssdp": [

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aioecowitt==2025.9.2"]
}

View File

@@ -166,24 +166,24 @@
"title": "Fan",
"triggers": {
"turned_off": {
"description": "Triggers when a fan is turned off.",
"description": "Triggers after one or more fans turn off.",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::trigger_behavior_description%]",
"name": "[%key:component::fan::common::trigger_behavior_name%]"
}
},
"name": "When a fan is turned off"
"name": "Fan turned off"
},
"turned_on": {
"description": "Triggers when a fan is turned on.",
"description": "Triggers after one or more fans turn on.",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::trigger_behavior_description%]",
"name": "[%key:component::fan::common::trigger_behavior_name%]"
}
},
"name": "When a fan is turned on"
"name": "Fan turned on"
}
}
}

View File

@@ -12,7 +12,11 @@ from .coordinator import (
FressnapfTrackerDataUpdateCoordinator,
)
PLATFORMS: list[Platform] = [Platform.DEVICE_TRACKER]
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.DEVICE_TRACKER,
Platform.SENSOR,
]
async def async_setup_entry(

View File

@@ -0,0 +1,69 @@
"""Binary Sensor platform for fressnapf_tracker."""
from collections.abc import Callable
from dataclasses import dataclass
from fressnapftracker import Tracker
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 . import FressnapfTrackerConfigEntry
from .entity import FressnapfTrackerEntity
@dataclass(frozen=True, kw_only=True)
class FressnapfTrackerBinarySensorDescription(BinarySensorEntityDescription):
"""Class describing Fressnapf Tracker binary_sensor entities."""
value_fn: Callable[[Tracker], bool]
BINARY_SENSOR_ENTITY_DESCRIPTIONS: tuple[
FressnapfTrackerBinarySensorDescription, ...
] = (
FressnapfTrackerBinarySensorDescription(
key="charging",
device_class=BinarySensorDeviceClass.BATTERY_CHARGING,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.charging,
),
FressnapfTrackerBinarySensorDescription(
translation_key="deep_sleep",
key="deep_sleep_value",
device_class=BinarySensorDeviceClass.POWER,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: bool(data.deep_sleep_value),
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FressnapfTrackerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fressnapf Tracker binary_sensors."""
async_add_entities(
FressnapfTrackerBinarySensor(coordinator, sensor_description)
for sensor_description in BINARY_SENSOR_ENTITY_DESCRIPTIONS
for coordinator in entry.runtime_data
)
class FressnapfTrackerBinarySensor(FressnapfTrackerEntity, BinarySensorEntity):
"""Fressnapf Tracker binary_sensor for general information."""
entity_description: FressnapfTrackerBinarySensorDescription
@property
def is_on(self) -> bool:
"""Return True if the binary sensor is on."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -1,6 +1,7 @@
"""fressnapf_tracker class."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from . import FressnapfTrackerDataUpdateCoordinator
@@ -25,3 +26,17 @@ class FressnapfTrackerBaseEntity(
manufacturer="Fressnapf",
serial_number=str(self.id),
)
class FressnapfTrackerEntity(FressnapfTrackerBaseEntity):
"""Entity for fressnapf_tracker."""
def __init__(
self,
coordinator: FressnapfTrackerDataUpdateCoordinator,
entity_description: EntityDescription,
) -> None:
"""Initialize the entity."""
super().__init__(coordinator)
self.entity_description = entity_description
self._attr_unique_id = f"{self.id}_{entity_description.key}"

View File

@@ -53,9 +53,7 @@ rules:
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations:
status: exempt
comment: No entities to translate
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: done

View File

@@ -0,0 +1,63 @@
"""Sensor platform for fressnapf_tracker."""
from collections.abc import Callable
from dataclasses import dataclass
from fressnapftracker import Tracker
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import PERCENTAGE, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import FressnapfTrackerConfigEntry
from .entity import FressnapfTrackerEntity
@dataclass(frozen=True, kw_only=True)
class FressnapfTrackerSensorDescription(SensorEntityDescription):
"""Class describing Fressnapf Tracker sensor entities."""
value_fn: Callable[[Tracker], int]
SENSOR_ENTITY_DESCRIPTIONS: tuple[FressnapfTrackerSensorDescription, ...] = (
FressnapfTrackerSensorDescription(
key="battery",
state_class=SensorStateClass.MEASUREMENT,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
value_fn=lambda data: data.battery,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: FressnapfTrackerConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Fressnapf Tracker sensors."""
async_add_entities(
FressnapfTrackerSensor(coordinator, sensor_description)
for sensor_description in SENSOR_ENTITY_DESCRIPTIONS
for coordinator in entry.runtime_data
)
class FressnapfTrackerSensor(FressnapfTrackerEntity, SensorEntity):
"""fressnapf_tracker sensor for general information."""
entity_description: FressnapfTrackerSensorDescription
@property
def native_value(self) -> int:
"""Return the state of the resources if it has been received yet."""
return self.entity_description.value_fn(self.coordinator.data)

View File

@@ -45,5 +45,12 @@
}
}
}
},
"entity": {
"binary_sensor": {
"deep_sleep": {
"name": "Deep Sleep"
}
}
}
}

View File

@@ -9,6 +9,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/fronius",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pyfronius"],
"quality_scale": "platinum",

View File

@@ -23,5 +23,5 @@
"winter_mode": {}
},
"quality_scale": "internal",
"requirements": ["home-assistant-frontend==20251201.0"]
"requirements": ["home-assistant-frontend==20251202.0"]
}

View File

@@ -6,6 +6,7 @@ from dataclasses import dataclass
import logging
from secrets import token_hex
import shutil
from tempfile import mkdtemp
from aiohttp import BasicAuth, ClientSession, UnixConnector
from aiohttp.client_exceptions import ClientConnectionError, ServerConnectionError
@@ -62,11 +63,11 @@ from .const import (
CONF_DEBUG_UI,
DEBUG_UI_URL_MESSAGE,
DOMAIN,
HA_MANAGED_UNIX_SOCKET,
HA_MANAGED_URL,
RECOMMENDED_VERSION,
)
from .server import Server
from .util import get_go2rtc_unix_socket_path
_LOGGER = logging.getLogger(__name__)
@@ -154,10 +155,12 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
auth = BasicAuth(username, password)
# HA will manage the binary
temp_dir = mkdtemp(prefix="go2rtc-")
# Manually created session (not using the helper) needs to be closed manually
# See on_stop listener below
session = ClientSession(
connector=UnixConnector(path=HA_MANAGED_UNIX_SOCKET), auth=auth
connector=UnixConnector(path=get_go2rtc_unix_socket_path(temp_dir)),
auth=auth,
)
server = Server(
hass,
@@ -166,6 +169,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
enable_ui=domain_config.get(CONF_DEBUG_UI, False),
username=username,
password=password,
working_dir=temp_dir,
)
try:
await server.start()

View File

@@ -6,7 +6,6 @@ CONF_DEBUG_UI = "debug_ui"
DEBUG_UI_URL_MESSAGE = "Url and debug_ui cannot be set at the same time."
HA_MANAGED_API_PORT = 11984
HA_MANAGED_URL = f"http://localhost:{HA_MANAGED_API_PORT}/"
HA_MANAGED_UNIX_SOCKET = "/run/go2rtc.sock"
# When changing this version, also update the corresponding SHA hash (_GO2RTC_SHA)
# in script/hassfest/docker.py.
RECOMMENDED_VERSION = "1.9.12"

View File

@@ -12,13 +12,13 @@ from go2rtc_client import Go2RtcRestClient
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from .const import HA_MANAGED_API_PORT, HA_MANAGED_UNIX_SOCKET, HA_MANAGED_URL
from .const import HA_MANAGED_API_PORT, HA_MANAGED_URL
from .util import get_go2rtc_unix_socket_path
_LOGGER = logging.getLogger(__name__)
_TERMINATE_TIMEOUT = 5
_SETUP_TIMEOUT = 30
_SUCCESSFUL_BOOT_MESSAGE = "INF [api] listen addr="
_LOCALHOST_IP = "127.0.0.1"
_LOG_BUFFER_SIZE = 512
_RESPAWN_COOLDOWN = 1
@@ -122,7 +122,9 @@ def _format_list_for_yaml(items: tuple[str, ...]) -> str:
return f"[{formatted_items}]"
def _create_temp_file(enable_ui: bool, username: str, password: str) -> str:
def _create_temp_file(
enable_ui: bool, username: str, password: str, working_dir: str
) -> str:
"""Create temporary config file."""
app_modules: tuple[str, ...] = _APP_MODULES
api_paths: tuple[str, ...] = _API_ALLOW_PATHS
@@ -139,11 +141,13 @@ def _create_temp_file(enable_ui: bool, username: str, password: str) -> str:
# Set delete=False to prevent the file from being deleted when the file is closed
# Linux is clearing tmp folder on reboot, so no need to delete it manually
with NamedTemporaryFile(prefix="go2rtc_", suffix=".yaml", delete=False) as file:
with NamedTemporaryFile(
prefix="go2rtc_", suffix=".yaml", dir=working_dir, delete=False
) as file:
file.write(
_GO2RTC_CONFIG_FORMAT.format(
listen_config=listen_config,
unix_socket=HA_MANAGED_UNIX_SOCKET,
unix_socket=get_go2rtc_unix_socket_path(working_dir),
app_modules=_format_list_for_yaml(app_modules),
api_allow_paths=_format_list_for_yaml(api_paths),
username=username,
@@ -165,6 +169,7 @@ class Server:
enable_ui: bool = False,
username: str,
password: str,
working_dir: str,
) -> None:
"""Initialize the server."""
self._hass = hass
@@ -173,6 +178,7 @@ class Server:
self._enable_ui = enable_ui
self._username = username
self._password = password
self._working_dir = working_dir
self._log_buffer: deque[str] = deque(maxlen=_LOG_BUFFER_SIZE)
self._process: asyncio.subprocess.Process | None = None
self._startup_complete = asyncio.Event()
@@ -190,7 +196,11 @@ class Server:
"""Start the server."""
_LOGGER.debug("Starting go2rtc server")
config_file = await self._hass.async_add_executor_job(
_create_temp_file, self._enable_ui, self._username, self._password
_create_temp_file,
self._enable_ui,
self._username,
self._password,
self._working_dir,
)
self._startup_complete.clear()

View File

@@ -0,0 +1,12 @@
"""Go2rtc utility functions."""
from pathlib import Path
_HA_MANAGED_UNIX_SOCKET_FILE = "go2rtc.sock"
def get_go2rtc_unix_socket_path(path: str | Path) -> str:
"""Get the Go2rtc unix socket path."""
if not isinstance(path, Path):
path = Path(path)
return str(path / _HA_MANAGED_UNIX_SOCKET_FILE)

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/google",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"]

View File

@@ -4,6 +4,7 @@
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/google_translate",
"integration_type": "service",
"iot_class": "cloud_push",
"loggers": ["gtts"],
"requirements": ["gTTS==2.5.3"]

View File

@@ -23,6 +23,6 @@
"iot_class": "cloud_push",
"loggers": ["aiohomeconnect"],
"quality_scale": "platinum",
"requirements": ["aiohomeconnect==0.23.1"],
"requirements": ["aiohomeconnect==0.24.0"],
"zeroconf": ["_homeconnect._tcp.local."]
}

View File

@@ -39,8 +39,6 @@ from .const import (
NABU_CASA_FIRMWARE_RELEASES_URL,
PID,
PRODUCT,
RADIO_TX_POWER_DBM_BY_COUNTRY,
RADIO_TX_POWER_DBM_DEFAULT,
SERIAL_NUMBER,
VID,
)
@@ -114,21 +112,6 @@ class ZBT2FirmwareMixin(ConfigEntryBaseFlow, FirmwareInstallFlowProtocol):
next_step_id="finish_thread_installation",
)
def _extra_zha_hardware_options(self) -> dict[str, Any]:
"""Return extra ZHA hardware options."""
country = self.hass.config.country
if country is None:
tx_power = RADIO_TX_POWER_DBM_DEFAULT
else:
tx_power = RADIO_TX_POWER_DBM_BY_COUNTRY.get(
country, RADIO_TX_POWER_DBM_DEFAULT
)
return {
"tx_power": tx_power,
}
class HomeAssistantConnectZBT2ConfigFlow(
ZBT2FirmwareMixin,

View File

@@ -1,7 +1,5 @@
"""Constants for the Home Assistant Connect ZBT-2 integration."""
from homeassistant.generated.countries import COUNTRIES
DOMAIN = "homeassistant_connect_zbt2"
NABU_CASA_FIRMWARE_RELEASES_URL = (
@@ -19,59 +17,3 @@ VID = "vid"
DEVICE = "device"
HARDWARE_NAME = "Home Assistant Connect ZBT-2"
RADIO_TX_POWER_DBM_DEFAULT = 8
RADIO_TX_POWER_DBM_BY_COUNTRY = {
# EU Member States
"AT": 10,
"BE": 10,
"BG": 10,
"HR": 10,
"CY": 10,
"CZ": 10,
"DK": 10,
"EE": 10,
"FI": 10,
"FR": 10,
"DE": 10,
"GR": 10,
"HU": 10,
"IE": 10,
"IT": 10,
"LV": 10,
"LT": 10,
"LU": 10,
"MT": 10,
"NL": 10,
"PL": 10,
"PT": 10,
"RO": 10,
"SK": 10,
"SI": 10,
"ES": 10,
"SE": 10,
# EEA Members
"IS": 10,
"LI": 10,
"NO": 10,
# Standards harmonized with RED or ETSI
"CH": 10,
"GB": 10,
"TR": 10,
"AL": 10,
"BA": 10,
"GE": 10,
"MD": 10,
"ME": 10,
"MK": 10,
"RS": 10,
"UA": 10,
# Other CEPT nations
"AD": 10,
"AZ": 10,
"MC": 10,
"SM": 10,
"VA": 10,
}
assert set(RADIO_TX_POWER_DBM_BY_COUNTRY) <= COUNTRIES

View File

@@ -457,10 +457,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
# This step is necessary to prevent `user_input` from being passed through
return await self.async_step_continue_zigbee()
def _extra_zha_hardware_options(self) -> dict[str, Any]:
"""Return extra ZHA hardware options."""
return {}
async def async_step_continue_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -483,7 +479,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
},
"radio_type": "ezsp",
"flow_strategy": self._zigbee_flow_strategy,
**self._extra_zha_hardware_options(),
},
)
return self._continue_zha_flow(result)

View File

@@ -11,6 +11,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/ibeacon",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["bleak"],
"requirements": ["ibeacon-ble==1.2.0"],

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["http"],
"documentation": "https://www.home-assistant.io/integrations/konnected",
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["konnected"],
"requirements": ["konnected==1.2.0"],

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
from collections.abc import Callable
import logging
from homeassistant.const import EVENT_LABS_UPDATED
from homeassistant.core import Event, HomeAssistant, callback
from homeassistant.generated.labs import LABS_PREVIEW_FEATURES
from homeassistant.helpers import config_validation as cv
@@ -17,7 +18,7 @@ from homeassistant.helpers.storage import Store
from homeassistant.helpers.typing import ConfigType
from homeassistant.loader import async_get_custom_components
from .const import DOMAIN, EVENT_LABS_UPDATED, LABS_DATA, STORAGE_KEY, STORAGE_VERSION
from .const import DOMAIN, LABS_DATA, STORAGE_KEY, STORAGE_VERSION
from .models import (
EventLabsUpdatedData,
LabPreviewFeature,

View File

@@ -11,6 +11,4 @@ DOMAIN = "labs"
STORAGE_KEY = "core.labs"
STORAGE_VERSION = 1
EVENT_LABS_UPDATED = "labs_updated"
LABS_DATA: HassKey[LabsData] = HassKey(DOMAIN)

View File

@@ -8,9 +8,10 @@ import voluptuous as vol
from homeassistant.components import websocket_api
from homeassistant.components.backup import async_get_manager
from homeassistant.const import EVENT_LABS_UPDATED
from homeassistant.core import HomeAssistant, callback
from .const import EVENT_LABS_UPDATED, LABS_DATA
from .const import LABS_DATA
from .models import EventLabsUpdatedData

View File

@@ -41,44 +41,44 @@
"title": "Lawn mower",
"triggers": {
"docked": {
"description": "Triggers when a lawn mower has docked.",
"description": "Triggers after one or more lawn mowers return to dock.",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
}
},
"name": "When a lawn mower has docked"
"name": "Lawn mower returned to dock"
},
"errored": {
"description": "Triggers when a lawn mower has errored.",
"description": "Triggers after one or more lawn mowers encounter an error.",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
}
},
"name": "When a lawn mower has errored"
"name": "Lawn mower encountered an error"
},
"paused_mowing": {
"description": "Triggers when a lawn mower has paused mowing.",
"description": "Triggers after one or more lawn mowers pause mowing.",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
}
},
"name": "When a lawn mower has paused mowing"
"name": "Lawn mower paused mowing"
},
"started_mowing": {
"description": "Triggers when a lawn mower has started mowing.",
"description": "Triggers after one or more lawn mowers start mowing.",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
}
},
"name": "When a lawn mower has started mowing"
"name": "Lawn mower started mowing"
}
}
}

View File

@@ -510,24 +510,24 @@
"title": "Light",
"triggers": {
"turned_off": {
"description": "Triggers when a light is turned off.",
"description": "Triggers after one or more lights turn off.",
"fields": {
"behavior": {
"description": "[%key:component::light::common::trigger_behavior_description%]",
"name": "[%key:component::light::common::trigger_behavior_name%]"
}
},
"name": "When a light is turned off"
"name": "Light turned off"
},
"turned_on": {
"description": "Triggers when a light is turned on.",
"description": "Triggers after one or more lights turn on.",
"fields": {
"behavior": {
"description": "[%key:component::light::common::trigger_behavior_description%]",
"name": "[%key:component::light::common::trigger_behavior_name%]"
}
},
"name": "When a light is turned on"
"name": "Light turned on"
}
}
}

View File

@@ -381,14 +381,14 @@
"title": "Media player",
"triggers": {
"stopped_playing": {
"description": "Triggers when a media player stops playing.",
"description": "Triggers after one or more media players stop playing media.",
"fields": {
"behavior": {
"description": "[%key:component::media_player::common::trigger_behavior_description%]",
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
}
},
"name": "When a media player stops playing"
"name": "Media player stopped playing"
}
}
}

View File

@@ -4,6 +4,7 @@
"codeowners": ["@danielhiversen"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/met",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["metno"],
"requirements": ["PyMetno==0.13.0"]

View File

@@ -48,6 +48,7 @@ CONDITION_CLASSES: dict[str, list[str]] = {
"Brouillard givrant",
"Bancs de Brouillard",
"Brouillard dense",
"Brouillard dense givrant",
],
ATTR_CONDITION_HAIL: ["Risque de grêle", "Averses de grêle"],
ATTR_CONDITION_LIGHTNING: ["Risque d'orages", "Orages", "Orage avec grêle"],

View File

@@ -182,7 +182,7 @@ class ProgramPhaseWashingMachine(MieleEnum, missing_to_none=True):
drain = 265
spin = 266, 11010
anti_crease = 267, 11029
finished = 268
finished = 268, 11012
venting = 269
starch_stop = 270
freshen_up_and_moisten = 271
@@ -190,6 +190,7 @@ class ProgramPhaseWashingMachine(MieleEnum, missing_to_none=True):
hygiene = 279
drying = 280
disinfecting = 285
flex_load_active = 11047
class ProgramPhaseTumbleDryer(MieleEnum, missing_to_none=True):
@@ -481,8 +482,8 @@ class WashingMachineProgramId(MieleEnum, missing_to_none=True):
express_20 = 122
down_filled_items = 129
cottons_eco = 133
quick_power_wash = 146
eco_40_60 = 190
quick_power_wash = 146, 10031
eco_40_60 = 190, 10007
normal = 10001

View File

@@ -954,6 +954,7 @@
"extra_dry": "Extra dry",
"final_rinse": "Final rinse",
"finished": "Finished",
"flex_load_active": "FlexLoad active",
"freshen_up_and_moisten": "Freshen up & moisten",
"going_to_target_area": "Going to target area",
"grinding": "Grinding",

View File

@@ -378,31 +378,33 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_migrate_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Migrate the options from config entry data."""
_LOGGER.debug("Migrating from version %s:%s", entry.version, entry.minor_version)
_LOGGER.debug("Migrating from version %s.%s", entry.version, entry.minor_version)
data: dict[str, Any] = dict(entry.data)
options: dict[str, Any] = dict(entry.options)
if entry.version > 1:
if entry.version > 2 or (entry.version == 2 and entry.minor_version > 1):
# This means the user has downgraded from a future version
# We allow read support for version 2.1
return False
if entry.version == 1 and entry.minor_version < 2:
# Can be removed when config entry is bumped to version 2.1
# with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1
# From 2026.1 we will write version 2.1
# Can be removed when the config entry is bumped to version 2.1
# with HA Core 2026.7.0. Read support for version 2.1 is expected with 2026.1
# From 2026.7 we will write version 2.1
for key in ENTRY_OPTION_FIELDS:
if key not in data:
continue
options[key] = data.pop(key)
# Write version 1.2 for backwards compatibility
hass.config_entries.async_update_entry(
entry,
data=data,
options=options,
version=CONFIG_ENTRY_VERSION,
minor_version=CONFIG_ENTRY_MINOR_VERSION,
version=1,
minor_version=2,
)
_LOGGER.debug(
"Migration to version %s:%s successful", entry.version, entry.minor_version
"Migration to version %s.%s successful", entry.version, entry.minor_version
)
return True

View File

@@ -3952,9 +3952,8 @@ REAUTH_SCHEMA = vol.Schema(
class FlowHandler(ConfigFlow, domain=DOMAIN):
"""Handle a config flow."""
# Can be bumped to version 2.1 with HA Core 2026.1.0
VERSION = CONFIG_ENTRY_VERSION # 1
MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION # 2
VERSION = CONFIG_ENTRY_VERSION # 2
MINOR_VERSION = CONFIG_ENTRY_MINOR_VERSION # 1
_hassio_discovery: dict[str, Any] | None = None
_addon_manager: AddonManager

View File

@@ -381,13 +381,13 @@ MQTT_PROCESSED_SUBSCRIPTIONS = "mqtt_processed_subscriptions"
PAYLOAD_EMPTY_JSON = "{}"
PAYLOAD_NONE = "None"
CONFIG_ENTRY_VERSION = 1
CONFIG_ENTRY_MINOR_VERSION = 2
CONFIG_ENTRY_VERSION = 2
CONFIG_ENTRY_MINOR_VERSION = 1
# Split mqtt entry data and options
# Can be removed when config entry is bumped to version 2.1
# with HA Core 2026.1.0. Read support for version 2.1 is expected before 2026.1
# From 2026.1 we will write version 2.1
# with HA Core 2026.7.0. Read support for version 2.1 is expected from 2026.1
# From 2026.7 we will write version 2.1
ENTRY_OPTION_FIELDS = (
CONF_DISCOVERY,
CONF_DISCOVERY_PREFIX,

View File

@@ -1562,7 +1562,7 @@
},
"triggers": {
"_": {
"description": "When a specific message is received on a given MQTT topic.",
"description": "Triggers after a specific message is received on a given MQTT topic.",
"fields": {
"payload": {
"description": "The payload to trigger on.",
@@ -1573,7 +1573,7 @@
"name": "Topic"
}
},
"name": "MQTT"
"name": "MQTT message received"
}
}
}

View File

@@ -18,6 +18,7 @@ from music_assistant_models.enums import EventType
from music_assistant_models.errors import (
ActionUnavailable,
AuthenticationFailed,
AuthenticationRequired,
InvalidToken,
MusicAssistantError,
)
@@ -99,7 +100,7 @@ async def async_setup_entry( # noqa: C901
translation_key="invalid_server_version",
)
raise ConfigEntryNotReady(f"Invalid server version: {err}") from err
except (AuthenticationFailed, InvalidToken) as err:
except (AuthenticationRequired, AuthenticationFailed, InvalidToken) as err:
raise ConfigEntryAuthFailed(
f"Authentication failed for {mass_url}: {err}"
) from err

View File

@@ -17,7 +17,12 @@ from music_assistant_models.api import ServerInfoMessage
from music_assistant_models.errors import AuthenticationFailed, InvalidToken
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_REAUTH,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_URL
from homeassistant.core import HomeAssistant
from homeassistant.helpers import aiohttp_client
@@ -165,10 +170,23 @@ class MusicAssistantConfigFlow(ConfigFlow, domain=DOMAIN):
self.token = discovery_info.config["auth_token"]
self.server_info = server_info
await self.async_set_unique_id(server_info.server_id)
self._abort_if_unique_id_configured(
updates={CONF_URL: self.url, CONF_TOKEN: self.token}
)
# Check if there's an existing entry
if entry := await self.async_set_unique_id(server_info.server_id):
# Update the entry with new URL and token
if self.hass.config_entries.async_update_entry(
entry, data={**entry.data, CONF_URL: self.url, CONF_TOKEN: self.token}
):
# Reload the entry if it's in a state that can be reloaded
if entry.state in (
ConfigEntryState.LOADED,
ConfigEntryState.SETUP_ERROR,
ConfigEntryState.SETUP_RETRY,
):
self.hass.config_entries.async_schedule_reload(entry.entry_id)
# Abort since entry already exists
return self.async_abort(reason="already_configured")
return await self.async_step_hassio_confirm()

View File

@@ -6,6 +6,7 @@
"config_flow": true,
"dependencies": ["auth"],
"documentation": "https://www.home-assistant.io/integrations/music_assistant",
"integration_type": "service",
"iot_class": "local_push",
"loggers": ["music_assistant"],
"quality_scale": "bronze",

View File

@@ -4,6 +4,8 @@ from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime, timedelta
from typing import Any
from pymystrom.switch import MyStromSwitch
@@ -13,10 +15,16 @@ from homeassistant.components.sensor import (
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import UnitOfPower, UnitOfTemperature
from homeassistant.const import (
EntityCategory,
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.util.dt import utcnow
from .const import DOMAIN, MANUFACTURER
from .models import MyStromConfigEntry
@@ -44,6 +52,15 @@ SENSOR_TYPES: tuple[MyStromSwitchSensorEntityDescription, ...] = (
native_unit_of_measurement=UnitOfPower.WATT,
value_fn=lambda device: device.consumption,
),
MyStromSwitchSensorEntityDescription(
key="energy_since_boot",
translation_key="energy_since_boot",
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
native_unit_of_measurement=UnitOfEnergy.JOULE,
suggested_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
value_fn=lambda device: device.energy_since_boot,
),
MyStromSwitchSensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
@@ -62,20 +79,44 @@ async def async_setup_entry(
"""Set up the myStrom entities."""
device: MyStromSwitch = entry.runtime_data.device
async_add_entities(
entities: list[MyStromSensorBase] = [
MyStromSwitchSensor(device, entry.title, description)
for description in SENSOR_TYPES
if description.value_fn(device) is not None
)
]
if device.time_since_boot is not None:
entities.append(MyStromSwitchUptimeSensor(device, entry.title))
async_add_entities(entities)
class MyStromSwitchSensor(SensorEntity):
class MyStromSensorBase(SensorEntity):
"""Base class for myStrom sensors."""
_attr_has_entity_name = True
def __init__(
self,
device: MyStromSwitch,
name: str,
key: str,
) -> None:
"""Initialize the sensor."""
self._attr_unique_id = f"{device.mac}-{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.mac)},
name=name,
manufacturer=MANUFACTURER,
sw_version=device.firmware,
)
class MyStromSwitchSensor(MyStromSensorBase):
"""Representation of the consumption or temperature of a myStrom switch/plug."""
entity_description: MyStromSwitchSensorEntityDescription
_attr_has_entity_name = True
def __init__(
self,
device: MyStromSwitch,
@@ -83,18 +124,61 @@ class MyStromSwitchSensor(SensorEntity):
description: MyStromSwitchSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(device, name, description.key)
self.device = device
self.entity_description = description
self._attr_unique_id = f"{device.mac}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.mac)},
name=name,
manufacturer=MANUFACTURER,
sw_version=device.firmware,
)
@property
def native_value(self) -> float | None:
"""Return the value of the sensor."""
return self.entity_description.value_fn(self.device)
class MyStromSwitchUptimeSensor(MyStromSensorBase):
"""Representation of a MyStrom Switch uptime sensor."""
entity_description = SensorEntityDescription(
key="time_since_boot",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=EntityCategory.DIAGNOSTIC,
translation_key="time_since_boot",
)
def __init__(
self,
device: MyStromSwitch,
name: str,
) -> None:
"""Initialize the uptime sensor."""
super().__init__(device, name, self.entity_description.key)
self.device = device
self._last_value: datetime | None = None
self._last_attributes: dict[str, Any] = {}
@property
def native_value(self) -> datetime | None:
"""Return the uptime of the device as a datetime."""
if self.device.time_since_boot is None or self.device.boot_id is None:
return None
# Return cached value if boot_id hasn't changed
if (
self._last_value is not None
and self._last_attributes.get("boot_id") == self.device.boot_id
):
return self._last_value
self._last_value = utcnow() - timedelta(seconds=self.device.time_since_boot)
return self._last_value
@property
def extra_state_attributes(self) -> dict[str, Any]:
"""Return the optional state attributes."""
self._last_attributes = {
"boot_id": self.device.boot_id,
}
return self._last_attributes

View File

@@ -22,6 +22,12 @@
"sensor": {
"avg_consumption": {
"name": "Average consumption"
},
"energy_since_boot": {
"name": "Energy since boot"
},
"time_since_boot": {
"name": "Last restart"
}
}
}

View File

@@ -10,6 +10,47 @@
"is_going": {
"default": "mdi:bell-cancel-outline"
}
},
"sensor": {
"arrival_platform_actual": {
"default": "mdi:logout"
},
"arrival_platform_planned": {
"default": "mdi:logout"
},
"arrival_time_actual": {
"default": "mdi:clock"
},
"arrival_time_planned": {
"default": "mdi:calendar-clock"
},
"departure": {
"default": "mdi:train"
},
"departure_platform_actual": {
"default": "mdi:login"
},
"departure_platform_planned": {
"default": "mdi:login"
},
"departure_time_actual": {
"default": "mdi:clock"
},
"departure_time_planned": {
"default": "mdi:calendar-clock"
},
"next_departure_time": {
"default": "mdi:train"
},
"route": {
"default": "mdi:transit-connection-variant"
},
"status": {
"default": "mdi:information"
},
"transfers": {
"default": "mdi:swap-horizontal"
}
}
}
}

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
from datetime import datetime
import logging
from typing import Any
@@ -13,9 +15,10 @@ from homeassistant.components.sensor import (
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import CONF_API_KEY, CONF_NAME
from homeassistant.const import CONF_API_KEY, CONF_NAME, EntityCategory
from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import config_validation as cv, issue_registry as ir
@@ -24,9 +27,10 @@ from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, StateType
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .binary_sensor import get_delay
from .const import (
CONF_FROM,
CONF_ROUTES,
@@ -40,7 +44,7 @@ from .const import (
from .coordinator import NSConfigEntry, NSDataUpdateCoordinator
def _get_departure_time(trip: Trip | None) -> datetime | None:
def get_departure_time(trip: Trip | None) -> datetime | None:
"""Get next departure time from trip data."""
return trip.departure_time_actual or trip.departure_time_planned if trip else None
@@ -61,13 +65,15 @@ def _get_route(trip: Trip | None) -> list[str]:
return route
def _get_delay(planned: datetime | None, actual: datetime | None) -> bool:
"""Return True if delay is present, False otherwise."""
return bool(planned and actual and planned != actual)
TRIP_STATUS = {
"NORMAL": "normal",
"CANCELLED": "cancelled",
}
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0 # since we use coordinator pattern
ROUTE_SCHEMA = vol.Schema(
{
vol.Required(CONF_NAME): cv.string,
@@ -85,6 +91,110 @@ PLATFORM_SCHEMA = SENSOR_PLATFORM_SCHEMA.extend(
)
@dataclass(frozen=True, kw_only=True)
class NSSensorEntityDescription(SensorEntityDescription):
"""Describes Nederlandse Spoorwegen sensor entity."""
is_next: bool = False
value_fn: Callable[[Trip], datetime | str | int | None]
entity_category: EntityCategory | None = EntityCategory.DIAGNOSTIC
# Entity descriptions for all the different sensors we create per route
SENSOR_DESCRIPTIONS: tuple[NSSensorEntityDescription, ...] = (
NSSensorEntityDescription(
key="actual_departure",
translation_key="departure",
device_class=SensorDeviceClass.TIMESTAMP,
entity_category=None,
value_fn=get_departure_time,
),
NSSensorEntityDescription(
key="next_departure",
translation_key="next_departure_time",
device_class=SensorDeviceClass.TIMESTAMP,
is_next=True,
value_fn=get_departure_time,
entity_registry_enabled_default=False,
),
# Platform information
NSSensorEntityDescription(
key="departure_platform_planned",
translation_key="departure_platform_planned",
value_fn=lambda trip: getattr(trip, "departure_platform_planned", None),
entity_registry_enabled_default=False,
),
NSSensorEntityDescription(
key="departure_platform_actual",
translation_key="departure_platform_actual",
value_fn=lambda trip: trip.departure_platform_actual,
entity_registry_enabled_default=False,
),
NSSensorEntityDescription(
key="arrival_platform_planned",
translation_key="arrival_platform_planned",
value_fn=lambda trip: trip.arrival_platform_planned,
entity_registry_enabled_default=False,
),
NSSensorEntityDescription(
key="arrival_platform_actual",
translation_key="arrival_platform_actual",
value_fn=lambda trip: trip.arrival_platform_actual,
entity_registry_enabled_default=False,
),
NSSensorEntityDescription(
key="departure_time_planned",
translation_key="departure_time_planned",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda trip: trip.departure_time_planned,
entity_registry_enabled_default=False,
),
NSSensorEntityDescription(
key="departure_time_actual",
translation_key="departure_time_actual",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda trip: trip.departure_time_actual,
entity_registry_enabled_default=False,
),
NSSensorEntityDescription(
key="arrival_time_planned",
translation_key="arrival_time_planned",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda trip: trip.arrival_time_planned,
entity_registry_enabled_default=False,
),
NSSensorEntityDescription(
key="arrival_time_actual",
translation_key="arrival_time_actual",
device_class=SensorDeviceClass.TIMESTAMP,
value_fn=lambda trip: trip.arrival_time_actual,
entity_registry_enabled_default=False,
),
# Trip information
NSSensorEntityDescription(
key="status",
translation_key="status",
device_class=SensorDeviceClass.ENUM,
options=list(TRIP_STATUS.values()),
value_fn=lambda trip: TRIP_STATUS.get(trip.status),
entity_registry_enabled_default=False,
),
NSSensorEntityDescription(
key="transfers",
translation_key="transfers",
value_fn=lambda trip: trip.nr_transfers if trip else 0,
entity_registry_enabled_default=False,
),
# Route info sensors
NSSensorEntityDescription(
key="route",
translation_key="route",
value_fn=lambda trip: ", ".join(_get_route(trip)),
entity_registry_enabled_default=False,
),
)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
@@ -144,58 +254,61 @@ async def async_setup_entry(
coordinators = config_entry.runtime_data
for subentry_id, coordinator in coordinators.items():
# Build entity from coordinator fields directly
entity = NSDepartureSensor(
subentry_id,
coordinator,
async_add_entities(
[
NSSensor(coordinator, subentry_id, description)
for description in SENSOR_DESCRIPTIONS
],
config_subentry_id=subentry_id,
)
# Add entity with proper subentry association
async_add_entities([entity], config_subentry_id=subentry_id)
class NSSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity):
"""Generic NS sensor based on entity description."""
class NSDepartureSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity):
"""Implementation of a NS Departure Sensor (legacy)."""
_attr_device_class = SensorDeviceClass.TIMESTAMP
_attr_has_entity_name = True
_attr_attribution = "Data provided by NS"
_attr_icon = "mdi:train"
entity_description: NSSensorEntityDescription
def __init__(
self,
subentry_id: str,
coordinator: NSDataUpdateCoordinator,
subentry_id: str,
description: NSSensorEntityDescription,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._name = coordinator.name
self.entity_description = description
self._attr_entity_category = description.entity_category
self._subentry_id = subentry_id
self._attr_unique_id = f"{subentry_id}-actual_departure"
self._attr_unique_id = f"{subentry_id}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, self._subentry_id)},
name=self._name,
identifiers={(DOMAIN, subentry_id)},
name=coordinator.name,
manufacturer=INTEGRATION_TITLE,
model=ROUTE_MODEL,
)
@property
def name(self) -> str:
"""Return the name of the sensor."""
return self._name
@property
def native_value(self) -> datetime | None:
def native_value(self) -> StateType | datetime:
"""Return the native value of the sensor."""
route_data = self.coordinator.data
if not route_data.first_trip:
data = (
self.coordinator.data.first_trip
if not self.entity_description.is_next
else self.coordinator.data.next_trip
)
if data is None:
return None
first_trip = route_data.first_trip
return _get_departure_time(first_trip)
return self.entity_description.value_fn(data)
@property
def extra_state_attributes(self) -> dict[str, Any] | None:
"""Return the state attributes."""
if self.entity_description.key != "actual_departure":
return None
first_trip = self.coordinator.data.first_trip
next_trip = self.coordinator.data.next_trip
@@ -204,11 +317,12 @@ class NSDepartureSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity
status = first_trip.status
# Static attributes
return {
"going": first_trip.going,
"departure_time_planned": _get_time_str(first_trip.departure_time_planned),
"departure_time_actual": _get_time_str(first_trip.departure_time_actual),
"departure_delay": _get_delay(
"departure_delay": get_delay(
first_trip.departure_time_planned,
first_trip.departure_time_actual,
),
@@ -216,13 +330,13 @@ class NSDepartureSensor(CoordinatorEntity[NSDataUpdateCoordinator], SensorEntity
"departure_platform_actual": first_trip.departure_platform_actual,
"arrival_time_planned": _get_time_str(first_trip.arrival_time_planned),
"arrival_time_actual": _get_time_str(first_trip.arrival_time_actual),
"arrival_delay": _get_delay(
"arrival_delay": get_delay(
first_trip.arrival_time_planned,
first_trip.arrival_time_actual,
),
"arrival_platform_planned": first_trip.arrival_platform_planned,
"arrival_platform_actual": first_trip.arrival_platform_actual,
"next": _get_time_str(_get_departure_time(next_trip)),
"next": _get_time_str(get_departure_time(next_trip)),
"status": status.lower() if status else None,
"transfers": first_trip.nr_transfers,
"route": _get_route(first_trip),

View File

@@ -75,6 +75,57 @@
"is_going": {
"name": "Going"
}
},
"sensor": {
"arrival_platform_actual": {
"name": "Actual arrival platform"
},
"arrival_platform_planned": {
"name": "Planned arrival platform"
},
"arrival_time_actual": {
"name": "Actual arrival time"
},
"arrival_time_planned": {
"name": "Planned arrival time"
},
"departure": {
"name": "Departure"
},
"departure_platform_actual": {
"name": "Actual departure platform"
},
"departure_platform_planned": {
"name": "Planned departure platform"
},
"departure_time_actual": {
"name": "Actual departure time"
},
"departure_time_planned": {
"name": "Planned departure time"
},
"next_departure_time": {
"name": "Next departure"
},
"route": {
"name": "Route"
},
"route_from": {
"name": "Route from"
},
"route_to": {
"name": "Route to"
},
"status": {
"name": "Status",
"state": {
"cancelled": "Cancelled",
"normal": "Normal"
}
},
"transfers": {
"name": "Transfers"
}
}
},
"issues": {

View File

@@ -4,6 +4,7 @@
"codeowners": ["@jpbede"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/ping",
"integration_type": "service",
"iot_class": "local_polling",
"loggers": ["icmplib"],
"quality_scale": "internal",

View File

@@ -17,6 +17,7 @@ from homeassistant.const import (
Platform,
)
from homeassistant.core import callback
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.schema_config_entry_flow import (
SchemaCommonFlowHandler,
SchemaConfigFlowHandler,
@@ -59,8 +60,8 @@ def _generate_schema(domain: str, flow_type: _FlowType) -> vol.Schema:
if domain == Platform.SENSOR:
schema.update(
{
vol.Optional(CONF_MINIMUM, default=DEFAULT_MIN): vol.Coerce(int),
vol.Optional(CONF_MAXIMUM, default=DEFAULT_MAX): vol.Coerce(int),
vol.Optional(CONF_MINIMUM, default=DEFAULT_MIN): cv.positive_int,
vol.Optional(CONF_MAXIMUM, default=DEFAULT_MAX): cv.positive_int,
vol.Optional(CONF_DEVICE_CLASS): SelectSelector(
SelectSelectorConfig(
options=[

View File

@@ -3,6 +3,7 @@
"name": "RESTful",
"codeowners": [],
"documentation": "https://www.home-assistant.io/integrations/rest",
"integration_type": "service",
"iot_class": "local_polling",
"requirements": ["jsonpath==0.82.2", "xmltodict==1.0.2"]
}

View File

@@ -27,6 +27,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/ring",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["ring_doorbell"],
"quality_scale": "bronze",

View File

@@ -15,6 +15,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/roborock",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["roborock"],
"quality_scale": "silver",

View File

@@ -28,9 +28,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool:
try:
await box.authenticate(username=username, password=password)
except SFRBoxAuthenticationError as err:
raise ConfigEntryAuthFailed from err
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="invalid_credentials",
) from err
except SFRBoxError as err:
raise ConfigEntryNotReady from err
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="unknown_error",
translation_placeholders={"error": str(err)},
) from err
platforms = PLATFORMS_WITH_AUTH
data = SFRRuntimeData(
@@ -65,9 +72,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: SFRConfigEntry) -> bool:
device_registry = dr.async_get(hass)
device_registry.async_get_or_create(
config_entry_id=entry.entry_id,
connections={(dr.CONNECTION_NETWORK_MAC, system_info.mac_addr)},
identifiers={(DOMAIN, system_info.mac_addr)},
name="SFR Box",
model=system_info.product_id,
model=None,
model_id=system_info.product_id,
sw_version=system_info.version_mainfirmware,
configuration_url=f"http://{entry.data[CONF_HOST]}",

View File

@@ -21,6 +21,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import DOMAIN
from .coordinator import SFRConfigEntry
from .entity import SFREntity
@@ -44,7 +45,11 @@ def with_error_wrapping[**_P, _R](
try:
return await func(self, *args, **kwargs)
except SFRBoxError as err:
raise HomeAssistantError(err) from err
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unknown_error",
translation_placeholders={"error": str(err)},
) from err
return wrapper

View File

@@ -10,7 +10,12 @@ from sfrbox_api.bridge import SFRBox
from sfrbox_api.exceptions import SFRBoxAuthenticationError, SFRBoxError
import voluptuous as vol
from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult
from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.helpers import selector
from homeassistant.helpers.aiohttp_client import async_get_clientsession
@@ -63,12 +68,19 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN):
if TYPE_CHECKING:
assert system_info is not None
await self.async_set_unique_id(system_info.mac_addr)
self._abort_if_unique_id_configured()
if self.source == SOURCE_RECONFIGURE:
self._abort_if_unique_id_mismatch()
else:
self._abort_if_unique_id_configured()
self._box = box
self._config.update(user_input)
return await self.async_step_choose_auth()
data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, user_input)
suggested_values: Mapping[str, Any] | None = user_input
if suggested_values is None and self.source == SOURCE_RECONFIGURE:
suggested_values = self._get_reconfigure_entry().data
data_schema = self.add_suggested_values_to_schema(DATA_SCHEMA, suggested_values)
return self.async_show_form(
step_id="user",
data_schema=data_schema,
@@ -107,11 +119,18 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN):
self._get_reauth_entry(), data_updates=user_input
)
self._config.update(user_input)
if self.source == SOURCE_RECONFIGURE:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data=self._config
)
return self.async_create_entry(title="SFR Box", data=self._config)
suggested_values: Mapping[str, Any] | None = user_input
if self.source == SOURCE_REAUTH and not suggested_values:
suggested_values = self._get_reauth_entry().data
if suggested_values is None:
if self.source == SOURCE_REAUTH:
suggested_values = self._get_reauth_entry().data
elif self.source == SOURCE_RECONFIGURE:
suggested_values = self._get_reconfigure_entry().data
data_schema = self.add_suggested_values_to_schema(AUTH_SCHEMA, suggested_values)
return self.async_show_form(
@@ -122,6 +141,10 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, str] | None = None
) -> ConfigFlowResult:
"""Skip authentication."""
if self.source == SOURCE_RECONFIGURE:
return self.async_update_reload_and_abort(
self._get_reconfigure_entry(), data=self._config
)
return self.async_create_entry(title="SFR Box", data=self._config)
async def async_step_reauth(
@@ -132,3 +155,9 @@ class SFRBoxFlowHandler(ConfigFlow, domain=DOMAIN):
ip=entry_data[CONF_HOST], client=async_get_clientsession(self.hass)
)
return await self.async_step_auth()
async def async_step_reconfigure(
self, user_input: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle reconfiguration."""
return await self.async_step_user()

View File

@@ -16,6 +16,8 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
_SCAN_INTERVAL = timedelta(minutes=1)
@@ -63,5 +65,12 @@ class SFRDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
if data := await self._method(self.box):
return data
except SFRBoxError as err:
raise UpdateFailed from err
raise UpdateFailed("No data received from SFR Box")
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="unknown_error",
translation_placeholders={"error": str(err)},
) from err
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="no_data",
)

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/sfr_box",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"quality_scale": "silver",
"requirements": ["sfrbox-api==0.1.0"]
}

View File

@@ -34,24 +34,15 @@ rules:
parallel-updates: done
test-coverage: done
integration-owner: done
docs-installation-parameters:
status: todo
comment: not yet documented
docs-installation-parameters: done
docs-configuration-parameters:
status: exempt
comment: No options flow
## Gold
entity-translations: done
entity-device-class:
status: todo
comment: |
What does DSL counter count?
What is the state of CRC?
line_status and training and net_infra and mode -> unknown shouldn't be an option and the entity should return None instead
devices:
status: todo
comment: MAC address can be set to the connections
entity-device-class: done
devices: done
entity-category: done
entity-disabled-by-default: done
discovery:
@@ -59,13 +50,9 @@ rules:
comment: Should be possible
stale-devices: done
diagnostics: done
exception-translations:
status: todo
comment: not yet documented
exception-translations: done
icon-translations: done
reconfiguration-flow:
status: todo
comment: Need to be able to manually change the IP address
reconfiguration-flow: done
dynamic-devices: done
discovery-update-info:
status: todo

View File

@@ -49,14 +49,14 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = (
key="counter",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="dsl_counter",
translation_key="dsl_connect_count",
value_fn=lambda x: x.counter,
),
SFRBoxSensorEntityDescription[DslInfo](
key="crc",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
translation_key="dsl_crc",
translation_key="dsl_crc_error_count",
value_fn=lambda x: x.crc,
),
SFRBoxSensorEntityDescription[DslInfo](
@@ -126,7 +126,6 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = (
"loss_of_signal",
"loss_of_power",
"loss_of_signal_quality",
"unknown",
],
translation_key="dsl_line_status",
value_fn=lambda x: _value_to_option(x.line_status),
@@ -146,7 +145,6 @@ DSL_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[DslInfo], ...] = (
"g_993_channel_analysis",
"g_993_message_exchange",
"showtime",
"unknown",
],
translation_key="dsl_training",
value_fn=lambda x: _value_to_option(x.training),
@@ -162,10 +160,9 @@ SYSTEM_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[SystemInfo], ...] = (
"adsl",
"ftth",
"gprs",
"unknown",
],
translation_key="net_infra",
value_fn=lambda x: x.net_infra,
value_fn=lambda x: _value_to_option(x.net_infra),
),
SFRBoxSensorEntityDescription[SystemInfo](
key="alimvoltage",
@@ -197,18 +194,17 @@ WAN_SENSOR_TYPES: tuple[SFRBoxSensorEntityDescription[WanInfo], ...] = (
"adsl_routed",
"ftth_routed",
"grps_ppp",
"unknown",
],
translation_key="wan_mode",
value_fn=lambda x: x.mode.replace("/", "_"),
value_fn=lambda x: _value_to_option(x.mode),
),
)
def _value_to_option(value: str | None) -> str | None:
if value is None:
return value
return value.lower().replace(" ", "_").replace(".", "_")
if value is None or value == "Unknown":
return None
return value.lower().replace(" ", "_").replace(".", "_").replace("/", "_")
def _get_temperature(value: float | None) -> float | None:

View File

@@ -2,7 +2,9 @@
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]",
"unique_id_mismatch": "Please ensure you reconfigure against the same device."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -56,11 +58,13 @@
"dsl_attenuation_up": {
"name": "DSL attenuation up"
},
"dsl_counter": {
"name": "DSL counter"
"dsl_connect_count": {
"name": "DSL connect count",
"unit_of_measurement": "connects"
},
"dsl_crc": {
"name": "DSL CRC"
"dsl_crc_error_count": {
"name": "DSL CRC error count",
"unit_of_measurement": "errors"
},
"dsl_line_status": {
"name": "DSL line status",
@@ -69,8 +73,7 @@
"loss_of_power": "Loss of power",
"loss_of_signal": "Loss of signal",
"loss_of_signal_quality": "Loss of signal quality",
"no_defect": "No defect",
"unknown": "Unknown"
"no_defect": "No defect"
}
},
"dsl_linemode": {
@@ -99,8 +102,7 @@
"g_993_started": "G.993 Started",
"g_994_training": "G.994 Training",
"idle": "[%key:common::state::idle%]",
"showtime": "Showtime",
"unknown": "Unknown"
"showtime": "Showtime"
}
},
"net_infra": {
@@ -108,8 +110,7 @@
"state": {
"adsl": "ADSL",
"ftth": "FTTH",
"gprs": "GPRS",
"unknown": "Unknown"
"gprs": "GPRS"
}
},
"wan_mode": {
@@ -118,10 +119,20 @@
"adsl_ppp": "ADSL (PPP)",
"adsl_routed": "ADSL (Routed)",
"ftth_routed": "FTTH (Routed)",
"grps_ppp": "GPRS (PPP)",
"unknown": "Unknown"
"grps_ppp": "GPRS (PPP)"
}
}
}
},
"exceptions": {
"invalid_credentials": {
"message": "The provided credentials are invalid."
},
"no_data": {
"message": "No data received from the SFR box."
},
"unknown_error": {
"message": "An unknown error occurred while communicating with the SFR box: {error}"
}
}
}

View File

@@ -27,6 +27,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/smartthings",
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pysmartthings"],
"quality_scale": "bronze",

View File

@@ -125,6 +125,15 @@ OVEN_MODE = {
"Rinse": "rinse",
}
HEALTH_CONCERN = {
"good": "good",
"moderate": "moderate",
"slightlyUnhealthy": "slightly_unhealthy",
"unhealthy": "unhealthy",
"veryUnhealthy": "very_unhealthy",
"hazardous": "hazardous",
}
WASHER_OPTIONS = ["pause", "run", "stop"]
@@ -426,6 +435,17 @@ CAPABILITY_TO_SENSORS: dict[
)
],
},
Capability.DUST_HEALTH_CONCERN: {
Attribute.DUST_HEALTH_CONCERN: [
SmartThingsSensorEntityDescription(
key=Attribute.DUST_HEALTH_CONCERN,
translation_key="pm10_health_concern",
device_class=SensorDeviceClass.ENUM,
options=list(HEALTH_CONCERN.values()),
value_fn=HEALTH_CONCERN.get,
)
]
},
Capability.DUST_SENSOR: {
Attribute.DUST_LEVEL: [
SmartThingsSensorEntityDescription(
@@ -476,6 +496,17 @@ CAPABILITY_TO_SENSORS: dict[
)
]
},
Capability.FINE_DUST_HEALTH_CONCERN: {
Attribute.FINE_DUST_HEALTH_CONCERN: [
SmartThingsSensorEntityDescription(
key=Attribute.FINE_DUST_HEALTH_CONCERN,
translation_key="pm25_health_concern",
device_class=SensorDeviceClass.ENUM,
options=list(HEALTH_CONCERN.values()),
value_fn=HEALTH_CONCERN.get,
)
]
},
Capability.FINE_DUST_SENSOR: {
Attribute.FINE_DUST_LEVEL: [
SmartThingsSensorEntityDescription(
@@ -1018,6 +1049,17 @@ CAPABILITY_TO_SENSORS: dict[
)
]
},
Capability.VERY_FINE_DUST_HEALTH_CONCERN: {
Attribute.VERY_FINE_DUST_HEALTH_CONCERN: [
SmartThingsSensorEntityDescription(
key=Attribute.VERY_FINE_DUST_HEALTH_CONCERN,
translation_key="pm1_health_concern",
device_class=SensorDeviceClass.ENUM,
options=list(HEALTH_CONCERN.values()),
value_fn=HEALTH_CONCERN.get,
)
]
},
Capability.VERY_FINE_DUST_SENSOR: {
Attribute.VERY_FINE_DUST_LEVEL: [
SmartThingsSensorEntityDescription(

View File

@@ -532,6 +532,39 @@
"oven_setpoint": {
"name": "Setpoint"
},
"pm10_health_concern": {
"name": "PM10 health concern",
"state": {
"good": "[%key:component::smartthings::entity::sensor::pm25_health_concern::state::good%]",
"hazardous": "[%key:component::smartthings::entity::sensor::pm25_health_concern::state::hazardous%]",
"moderate": "[%key:component::smartthings::entity::sensor::pm25_health_concern::state::moderate%]",
"slightly_unhealthy": "[%key:component::smartthings::entity::sensor::pm25_health_concern::state::slightly_unhealthy%]",
"unhealthy": "[%key:component::smartthings::entity::sensor::pm25_health_concern::state::unhealthy%]",
"very_unhealthy": "[%key:component::smartthings::entity::sensor::pm25_health_concern::state::very_unhealthy%]"
}
},
"pm1_health_concern": {
"name": "PM1 health concern",
"state": {
"good": "[%key:component::smartthings::entity::sensor::pm25_health_concern::state::good%]",
"hazardous": "[%key:component::smartthings::entity::sensor::pm25_health_concern::state::hazardous%]",
"moderate": "[%key:component::smartthings::entity::sensor::pm25_health_concern::state::moderate%]",
"slightly_unhealthy": "[%key:component::smartthings::entity::sensor::pm25_health_concern::state::slightly_unhealthy%]",
"unhealthy": "[%key:component::smartthings::entity::sensor::pm25_health_concern::state::unhealthy%]",
"very_unhealthy": "[%key:component::smartthings::entity::sensor::pm25_health_concern::state::very_unhealthy%]"
}
},
"pm25_health_concern": {
"name": "PM2.5 health concern",
"state": {
"good": "Good",
"hazardous": "Hazardous",
"moderate": "Moderate",
"slightly_unhealthy": "Slightly unhealthy",
"unhealthy": "Unhealthy",
"very_unhealthy": "Very unhealthy"
}
},
"power_energy": {
"name": "Power energy"
},

View File

@@ -4,6 +4,7 @@
"codeowners": ["@rohankapoorcom", "@engrbm87"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/speedtestdotnet",
"integration_type": "service",
"iot_class": "cloud_polling",
"requirements": ["speedtest-cli==2.1.3"]
}

View File

@@ -38,6 +38,7 @@
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/switchbot",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["switchbot"],
"quality_scale": "gold",

View File

@@ -5,6 +5,7 @@
"config_flow": true,
"dependencies": ["application_credentials", "http"],
"documentation": "https://www.home-assistant.io/integrations/tesla_fleet",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
"requirements": ["tesla-fleet-api==1.2.5"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@Bre77"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/teslemetry",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tesla-fleet-api"],
"requirements": ["tesla-fleet-api==1.2.5", "teslemetry-stream==0.7.10"]

View File

@@ -4,7 +4,7 @@
"codeowners": ["@Bre77"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/tessie",
"integration_type": "device",
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["tessie", "tesla-fleet-api"],
"requirements": ["tessie-api==0.1.1", "tesla-fleet-api==1.2.5"]

View File

@@ -45,8 +45,8 @@
"title": "Text",
"triggers": {
"changed": {
"description": "Triggers when the text changes.",
"name": "When the text changes"
"description": "Triggers after one or more texts change.",
"name": "Text changed"
}
}
}

View File

@@ -1,20 +1,30 @@
"""Support for Tibber."""
from __future__ import annotations
from dataclasses import dataclass
import logging
import aiohttp
from aiohttp.client_exceptions import ClientError, ClientResponseError
import tibber
from tibber import data_api as tibber_data_api
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, EVENT_HOMEASSISTANT_STOP, Platform
from homeassistant.core import Event, HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
ImplementationUnavailableError,
OAuth2Session,
async_get_config_entry_implementation,
)
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util, ssl as ssl_util
from .const import DATA_HASS_CONFIG, DOMAIN
from .const import AUTH_IMPLEMENTATION, DATA_HASS_CONFIG, DOMAIN
from .services import async_setup_services
PLATFORMS = [Platform.NOTIFY, Platform.SENSOR]
@@ -24,6 +34,34 @@ CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
_LOGGER = logging.getLogger(__name__)
@dataclass(slots=True)
class TibberRuntimeData:
"""Runtime data for Tibber API entries."""
tibber_connection: tibber.Tibber
session: OAuth2Session | None = None
_client: tibber_data_api.TibberDataAPI | None = None
async def async_get_client(
self, hass: HomeAssistant
) -> tibber_data_api.TibberDataAPI:
"""Return an authenticated Tibber Data API client."""
if self.session is None:
raise ConfigEntryAuthFailed("OAuth session not available")
await self.session.async_ensure_token_valid()
token = self.session.token
access_token = token.get(CONF_ACCESS_TOKEN)
if not access_token:
raise ConfigEntryAuthFailed("Access token missing from OAuth session")
if self._client is None:
self._client = tibber_data_api.TibberDataAPI(
access_token,
websession=async_get_clientsession(hass),
)
self._client.set_access_token(access_token)
return self._client
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Tibber component."""
@@ -37,13 +75,19 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up a config entry."""
if AUTH_IMPLEMENTATION not in entry.data:
entry.async_start_reauth(hass)
raise ConfigEntryAuthFailed(
translation_domain=DOMAIN,
translation_key="data_api_reauth_required",
)
tibber_connection = tibber.Tibber(
access_token=entry.data[CONF_ACCESS_TOKEN],
websession=async_get_clientsession(hass),
time_zone=dt_util.get_default_time_zone(),
ssl=ssl_util.get_default_context(),
)
hass.data[DOMAIN] = tibber_connection
async def _close(event: Event) -> None:
await tibber_connection.rt_disconnect()
@@ -52,7 +96,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
try:
await tibber_connection.update_info()
except (
TimeoutError,
aiohttp.ClientError,
@@ -65,8 +108,32 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
except tibber.FatalHttpExceptionError:
return False
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
try:
implementation = await async_get_config_entry_implementation(hass, entry)
except ImplementationUnavailableError as err:
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="oauth2_implementation_unavailable",
) from err
session = OAuth2Session(hass, entry, implementation)
try:
await session.async_ensure_token_valid()
except ClientResponseError as err:
if 400 <= err.status < 500:
raise ConfigEntryAuthFailed(
"OAuth session is not valid, reauthentication required"
) from err
raise ConfigEntryNotReady from err
except ClientError as err:
raise ConfigEntryNotReady from err
hass.data[DOMAIN] = TibberRuntimeData(
tibber_connection=tibber_connection,
session=session,
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -76,6 +143,6 @@ async def async_unload_entry(hass: HomeAssistant, config_entry: ConfigEntry) ->
config_entry, PLATFORMS
)
if unload_ok:
tibber_connection = hass.data[DOMAIN]
await tibber_connection.rt_disconnect()
if runtime := hass.data.pop(DOMAIN, None):
await runtime.tibber_connection.rt_disconnect()
return unload_ok

View File

@@ -0,0 +1,15 @@
"""Application credentials platform for Tibber."""
from homeassistant.components.application_credentials import AuthorizationServer
from homeassistant.core import HomeAssistant
AUTHORIZE_URL = "https://thewall.tibber.com/connect/authorize"
TOKEN_URL = "https://thewall.tibber.com/connect/token"
async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer:
"""Return authorization server for Tibber Data API."""
return AuthorizationServer(
authorize_url=AUTHORIZE_URL,
token_url=TOKEN_URL,
)

View File

@@ -2,17 +2,21 @@
from __future__ import annotations
from collections.abc import Mapping
import logging
from typing import Any
import aiohttp
import tibber
from tibber import data_api as tibber_data_api
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import AbstractOAuth2FlowHandler
from .const import DOMAIN
from .const import AUTH_IMPLEMENTATION, DATA_API_DEFAULT_SCOPES, DOMAIN
DATA_SCHEMA = vol.Schema({vol.Required(CONF_ACCESS_TOKEN): str})
ERR_TIMEOUT = "timeout"
@@ -20,62 +24,145 @@ ERR_CLIENT = "cannot_connect"
ERR_TOKEN = "invalid_access_token"
TOKEN_URL = "https://developer.tibber.com/settings/access-token"
_LOGGER = logging.getLogger(__name__)
class TibberConfigFlow(ConfigFlow, domain=DOMAIN):
class TibberConfigFlow(AbstractOAuth2FlowHandler, domain=DOMAIN):
"""Handle a config flow for Tibber integration."""
VERSION = 1
DOMAIN = DOMAIN
def __init__(self) -> None:
"""Initialize the config flow."""
super().__init__()
self._access_token: str | None = None
self._title = ""
@property
def logger(self) -> logging.Logger:
"""Return the logger."""
return _LOGGER
@property
def extra_authorize_data(self) -> dict[str, Any]:
"""Extra data appended to the authorize URL."""
return {
**super().extra_authorize_data,
"scope": " ".join(DATA_API_DEFAULT_SCOPES),
}
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
self._async_abort_entries_match()
if user_input is not None:
access_token = user_input[CONF_ACCESS_TOKEN].replace(" ", "")
tibber_connection = tibber.Tibber(
access_token=access_token,
websession=async_get_clientsession(self.hass),
if user_input is None:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_ACCESS_TOKEN: self._access_token or ""}
)
errors = {}
try:
await tibber_connection.update_info()
except TimeoutError:
errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT
except tibber.InvalidLoginError:
errors[CONF_ACCESS_TOKEN] = ERR_TOKEN
except (
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
tibber.FatalHttpExceptionError,
):
errors[CONF_ACCESS_TOKEN] = ERR_CLIENT
if errors:
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
unique_id = tibber_connection.user_id
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=tibber_connection.name,
data={CONF_ACCESS_TOKEN: access_token},
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors={},
)
return self.async_show_form(
step_id="user",
data_schema=DATA_SCHEMA,
description_placeholders={"url": TOKEN_URL},
errors={},
self._access_token = user_input[CONF_ACCESS_TOKEN].replace(" ", "")
tibber_connection = tibber.Tibber(
access_token=self._access_token,
websession=async_get_clientsession(self.hass),
)
self._title = tibber_connection.name or "Tibber"
errors: dict[str, str] = {}
try:
await tibber_connection.update_info()
except TimeoutError:
errors[CONF_ACCESS_TOKEN] = ERR_TIMEOUT
except tibber.InvalidLoginError:
errors[CONF_ACCESS_TOKEN] = ERR_TOKEN
except (
aiohttp.ClientError,
tibber.RetryableHttpExceptionError,
tibber.FatalHttpExceptionError,
):
errors[CONF_ACCESS_TOKEN] = ERR_CLIENT
if errors:
data_schema = self.add_suggested_values_to_schema(
DATA_SCHEMA, {CONF_ACCESS_TOKEN: self._access_token or ""}
)
return self.async_show_form(
step_id=SOURCE_USER,
data_schema=data_schema,
description_placeholders={"url": TOKEN_URL},
errors=errors,
)
await self.async_set_unique_id(tibber_connection.user_id)
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
self._abort_if_unique_id_mismatch(
reason="wrong_account",
description_placeholders={"title": reauth_entry.title},
)
else:
self._abort_if_unique_id_configured()
self._async_abort_entries_match({AUTH_IMPLEMENTATION: DOMAIN})
return await self.async_step_pick_implementation()
async def async_step_reauth(
self, entry_data: Mapping[str, Any]
) -> ConfigFlowResult:
"""Handle a reauth flow."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_ACCESS_TOKEN)
self._title = reauth_entry.title
if reauth_entry.unique_id is not None:
await self.async_set_unique_id(reauth_entry.unique_id)
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauthentication by reusing the user step."""
reauth_entry = self._get_reauth_entry()
self._access_token = reauth_entry.data.get(CONF_ACCESS_TOKEN)
self._title = reauth_entry.title
if user_input is None:
return self.async_show_form(
step_id="reauth_confirm",
)
return await self.async_step_user()
async def async_oauth_create_entry(self, data: dict) -> ConfigFlowResult:
"""Finalize the OAuth flow and create the config entry."""
if self._access_token is None:
return self.async_abort(reason="missing_configuration")
data[CONF_ACCESS_TOKEN] = self._access_token
access_token = data[CONF_TOKEN][CONF_ACCESS_TOKEN]
data_api_client = tibber_data_api.TibberDataAPI(
access_token,
websession=async_get_clientsession(self.hass),
)
try:
await data_api_client.get_userinfo()
except (aiohttp.ClientError, TimeoutError):
return self.async_abort(reason="cannot_connect")
if self.source == SOURCE_REAUTH:
reauth_entry = self._get_reauth_entry()
return self.async_update_reload_and_abort(
reauth_entry,
data=data,
title=self._title,
)
return self.async_create_entry(title=self._title, data=data)

View File

@@ -1,5 +1,19 @@
"""Constants for Tibber integration."""
AUTH_IMPLEMENTATION = "auth_implementation"
DATA_HASS_CONFIG = "tibber_hass_config"
DOMAIN = "tibber"
MANUFACTURER = "Tibber"
DATA_API_DEFAULT_SCOPES = [
"openid",
"profile",
"email",
"offline_access",
"data-api-user-read",
"data-api-chargers-read",
"data-api-energy-systems-read",
"data-api-homes-read",
"data-api-thermostats-read",
"data-api-vehicles-read",
"data-api-inverters-read",
]

View File

@@ -4,9 +4,14 @@ from __future__ import annotations
from datetime import timedelta
import logging
from typing import cast
from typing import TYPE_CHECKING, cast
from aiohttp.client_exceptions import ClientError
import tibber
from tibber.data_api import TibberDataAPI, TibberDevice
if TYPE_CHECKING:
from . import TibberRuntimeData
from homeassistant.components.recorder import get_instance
from homeassistant.components.recorder.models import (
@@ -22,6 +27,7 @@ from homeassistant.components.recorder.statistics import (
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import UnitOfEnergy
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from homeassistant.util import dt as dt_util
from homeassistant.util.unit_conversion import EnergyConverter
@@ -187,3 +193,45 @@ class TibberDataCoordinator(DataUpdateCoordinator[None]):
unit_of_measurement=unit,
)
async_add_external_statistics(self.hass, metadata, statistics)
class TibberDataAPICoordinator(DataUpdateCoordinator[dict[str, TibberDevice]]):
"""Fetch and cache Tibber Data API device capabilities."""
def __init__(
self,
hass: HomeAssistant,
entry: ConfigEntry,
runtime_data: TibberRuntimeData,
) -> None:
"""Initialize the coordinator."""
super().__init__(
hass,
_LOGGER,
name=f"{DOMAIN} Data API",
update_interval=timedelta(minutes=1),
config_entry=entry,
)
self._runtime_data = runtime_data
async def _async_get_client(self) -> TibberDataAPI:
"""Get the Tibber Data API client with error handling."""
try:
return await self._runtime_data.async_get_client(self.hass)
except ConfigEntryAuthFailed:
raise
except (ClientError, TimeoutError, tibber.UserAgentMissingError) as err:
raise UpdateFailed(
f"Unable to create Tibber Data API client: {err}"
) from err
async def _async_setup(self) -> None:
"""Initial load of Tibber Data API devices."""
client = await self._async_get_client()
self.data = await client.get_all_devices()
async def _async_update_data(self) -> dict[str, TibberDevice]:
"""Fetch the latest device capabilities from the Tibber Data API."""
client = await self._async_get_client()
devices: dict[str, TibberDevice] = await client.update_devices()
return devices

View File

@@ -2,23 +2,30 @@
from __future__ import annotations
from typing import Any
from typing import TYPE_CHECKING, Any, cast
import aiohttp
import tibber
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from .const import DOMAIN
if TYPE_CHECKING:
from . import TibberRuntimeData
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, config_entry: ConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
tibber_connection: tibber.Tibber = hass.data[DOMAIN]
return {
runtime = cast("TibberRuntimeData | None", hass.data.get(DOMAIN))
if runtime is None:
return {"homes": []}
result: dict[str, Any] = {
"homes": [
{
"last_data_timestamp": home.last_data_timestamp,
@@ -27,6 +34,38 @@ async def async_get_config_entry_diagnostics(
"last_cons_data_timestamp": home.last_cons_data_timestamp,
"country": home.country,
}
for home in tibber_connection.get_homes(only_active=False)
for home in runtime.tibber_connection.get_homes(only_active=False)
]
}
if runtime.session:
devices: dict[str, Any] = {}
error: str | None = None
try:
client = await runtime.async_get_client(hass)
devices = await client.get_all_devices()
except ConfigEntryAuthFailed:
error = "Authentication failed"
except TimeoutError:
error = "Timeout error"
except aiohttp.ClientError:
error = "Client error"
except tibber.InvalidLoginError:
error = "Invalid login"
except tibber.RetryableHttpExceptionError as err:
error = f"Retryable HTTP error ({err.status})"
except tibber.FatalHttpExceptionError as err:
error = f"Fatal HTTP error ({err.status})"
result["error"] = error
result["devices"] = [
{
"id": device.id,
"name": device.name,
"brand": device.brand,
"model": device.model,
}
for device in devices.values()
]
return result

View File

@@ -3,9 +3,9 @@
"name": "Tibber",
"codeowners": ["@danielhiversen"],
"config_flow": true,
"dependencies": ["recorder"],
"dependencies": ["application_credentials", "recorder"],
"documentation": "https://www.home-assistant.io/integrations/tibber",
"iot_class": "cloud_polling",
"loggers": ["tibber"],
"requirements": ["pyTibber==0.32.2"]
"requirements": ["pyTibber==0.33.1"]
}

View File

@@ -2,6 +2,8 @@
from __future__ import annotations
from typing import TYPE_CHECKING, cast
from tibber import Tibber
from homeassistant.components.notify import (
@@ -14,7 +16,10 @@ from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import DOMAIN
from .const import DOMAIN
if TYPE_CHECKING:
from . import TibberRuntimeData
async def async_setup_entry(
@@ -39,7 +44,10 @@ class TibberNotificationEntity(NotifyEntity):
async def async_send_message(self, message: str, title: str | None = None) -> None:
"""Send a message to Tibber devices."""
tibber_connection: Tibber = self.hass.data[DOMAIN]
runtime = cast("TibberRuntimeData | None", self.hass.data.get(DOMAIN))
if runtime is None:
raise HomeAssistantError("Tibber integration is not initialized")
tibber_connection: Tibber = runtime.tibber_connection
try:
await tibber_connection.send_notification(
title or ATTR_TITLE_DEFAULT, message

View File

@@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
import datetime
from datetime import timedelta
@@ -10,7 +11,8 @@ from random import randrange
from typing import Any
import aiohttp
import tibber
from tibber import FatalHttpExceptionError, RetryableHttpExceptionError, TibberHome
from tibber.data_api import TibberDevice
from homeassistant.components.sensor import (
SensorDeviceClass,
@@ -27,6 +29,7 @@ from homeassistant.const import (
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfLength,
UnitOfPower,
)
from homeassistant.core import Event, HomeAssistant, callback
@@ -42,7 +45,7 @@ from homeassistant.helpers.update_coordinator import (
from homeassistant.util import Throttle, dt as dt_util
from .const import DOMAIN, MANUFACTURER
from .coordinator import TibberDataCoordinator
from .coordinator import TibberDataAPICoordinator, TibberDataCoordinator
_LOGGER = logging.getLogger(__name__)
@@ -260,6 +263,58 @@ SENSORS: tuple[SensorEntityDescription, ...] = (
)
DATA_API_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="storage.stateOfCharge",
translation_key="storage_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="storage.targetStateOfCharge",
translation_key="storage_target_state_of_charge",
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="connector.status",
translation_key="connector_status",
device_class=SensorDeviceClass.ENUM,
options=["connected", "disconnected", "unknown"],
),
SensorEntityDescription(
key="charging.status",
translation_key="charging_status",
device_class=SensorDeviceClass.ENUM,
options=["charging", "idle", "unknown"],
),
SensorEntityDescription(
key="range.remaining",
translation_key="range_remaining",
device_class=SensorDeviceClass.DISTANCE,
native_unit_of_measurement=UnitOfLength.KILOMETERS,
state_class=SensorStateClass.MEASUREMENT,
suggested_display_precision=1,
),
SensorEntityDescription(
key="charging.current.max",
translation_key="charging_current_max",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="charging.current.offlineFallback",
translation_key="charging_current_offline_fallback",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
@@ -267,7 +322,23 @@ async def async_setup_entry(
) -> None:
"""Set up the Tibber sensor."""
tibber_connection = hass.data[DOMAIN]
await asyncio.gather(
_async_setup_data_api_sensors(hass, entry, async_add_entities),
_async_setup_graphql_sensors(hass, entry, async_add_entities),
)
async def _async_setup_graphql_sensors(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Tibber sensor."""
runtime = hass.data.get(DOMAIN)
if runtime is None:
raise PlatformNotReady("Tibber runtime is not ready")
tibber_connection = runtime.tibber_connection
entity_registry = er.async_get(hass)
device_registry = dr.async_get(hass)
@@ -280,7 +351,11 @@ async def async_setup_entry(
except TimeoutError as err:
_LOGGER.error("Timeout connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
except (tibber.RetryableHttpExceptionError, aiohttp.ClientError) as err:
except (
RetryableHttpExceptionError,
FatalHttpExceptionError,
aiohttp.ClientError,
) as err:
_LOGGER.error("Error connecting to Tibber home: %s ", err)
raise PlatformNotReady from err
@@ -328,14 +403,93 @@ async def async_setup_entry(
async_add_entities(entities, True)
async def _async_setup_data_api_sensors(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors backed by the Tibber Data API."""
runtime = hass.data.get(DOMAIN)
if runtime is None:
raise PlatformNotReady("Tibber runtime is not ready")
coordinator = TibberDataAPICoordinator(hass, entry, runtime)
await coordinator.async_config_entry_first_refresh()
entities: list[TibberDataAPISensor] = []
api_sensors = {sensor.key: sensor for sensor in DATA_API_SENSORS}
for device in coordinator.data.values():
for sensor in device.sensors:
description: SensorEntityDescription | None = api_sensors.get(sensor.id)
if description is None:
_LOGGER.debug(
"Sensor %s not found in DATA_API_SENSORS, skipping", sensor
)
continue
entities.append(
TibberDataAPISensor(
coordinator, device, description, sensor.description
)
)
async_add_entities(entities)
class TibberDataAPISensor(CoordinatorEntity[TibberDataAPICoordinator], SensorEntity):
"""Representation of a Tibber Data API capability sensor."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: TibberDataAPICoordinator,
device: TibberDevice,
entity_description: SensorEntityDescription,
name: str,
) -> None:
"""Initialize the sensor."""
super().__init__(coordinator)
self._device_id: str = device.id
self.entity_description = entity_description
self._attr_name = name
self._attr_unique_id = f"{device.external_id}_{self.entity_description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, device.external_id)},
name=device.name,
manufacturer=device.brand,
model=device.model,
)
@property
def native_value(
self,
) -> StateType:
"""Return the value reported by the device."""
device = self.coordinator.data.get(self._device_id)
if device is None:
return None
for sensor in device.sensors:
if sensor.id == self.entity_description.key:
return sensor.value
return None
@property
def available(self) -> bool:
"""Return whether the sensor is available."""
return super().available and self.native_value is not None
class TibberSensor(SensorEntity):
"""Representation of a generic Tibber sensor."""
_attr_has_entity_name = True
def __init__(
self, *args: Any, tibber_home: tibber.TibberHome, **kwargs: Any
) -> None:
def __init__(self, *args: Any, tibber_home: TibberHome, **kwargs: Any) -> None:
"""Initialize the sensor."""
super().__init__(*args, **kwargs)
self._tibber_home = tibber_home
@@ -366,7 +520,7 @@ class TibberSensorElPrice(TibberSensor):
_attr_state_class = SensorStateClass.MEASUREMENT
_attr_translation_key = "electricity_price"
def __init__(self, tibber_home: tibber.TibberHome) -> None:
def __init__(self, tibber_home: TibberHome) -> None:
"""Initialize the sensor."""
super().__init__(tibber_home=tibber_home)
self._last_updated: datetime.datetime | None = None
@@ -443,7 +597,7 @@ class TibberDataSensor(TibberSensor, CoordinatorEntity[TibberDataCoordinator]):
def __init__(
self,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
coordinator: TibberDataCoordinator,
entity_description: SensorEntityDescription,
) -> None:
@@ -470,7 +624,7 @@ class TibberSensorRT(TibberSensor, CoordinatorEntity["TibberRtDataCoordinator"])
def __init__(
self,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
description: SensorEntityDescription,
initial_state: float,
coordinator: TibberRtDataCoordinator,
@@ -532,7 +686,7 @@ class TibberRtEntityCreator:
def __init__(
self,
async_add_entities: AddConfigEntryEntitiesCallback,
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
entity_registry: er.EntityRegistry,
) -> None:
"""Initialize the data handler."""
@@ -618,7 +772,7 @@ class TibberRtDataCoordinator(DataUpdateCoordinator): # pylint: disable=hass-en
hass: HomeAssistant,
config_entry: ConfigEntry,
add_sensor_callback: Callable[[TibberRtDataCoordinator, Any], None],
tibber_home: tibber.TibberHome,
tibber_home: TibberHome,
) -> None:
"""Initialize the data handler."""
self._add_sensor_callback = add_sensor_callback

View File

@@ -33,7 +33,8 @@ SERVICE_SCHEMA: Final = vol.Schema(
async def __get_prices(call: ServiceCall) -> ServiceResponse:
tibber_connection = call.hass.data[DOMAIN]
runtime = call.hass.data[DOMAIN]
tibber_connection = runtime.tibber_connection
start = __get_date(call.data.get(ATTR_START), "start")
end = __get_date(call.data.get(ATTR_END), "end")

View File

@@ -1,7 +1,11 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]",
"missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]",
"missing_credentials": "[%key:common::config_flow::abort::oauth2_missing_credentials%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "The connected account does not match {title}. Sign in with the same Tibber account and try again."
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
@@ -9,6 +13,10 @@
"timeout": "[%key:common::config_flow::error::timeout_connect%]"
},
"step": {
"reauth_confirm": {
"description": "Reconnect your Tibber account to refresh access.",
"title": "[%key:common::config_flow::title::reauth%]"
},
"user": {
"data": {
"access_token": "[%key:common::config_flow::data::access_token%]"
@@ -40,6 +48,28 @@
"average_power": {
"name": "Average power"
},
"charging_current_max": {
"name": "Maximum charging current"
},
"charging_current_offline_fallback": {
"name": "Offline fallback charging current"
},
"charging_status": {
"name": "Charging status",
"state": {
"charging": "Charging",
"idle": "Idle",
"unknown": "Unknown"
}
},
"connector_status": {
"name": "Connector status",
"state": {
"connected": "Connected",
"disconnected": "Disconnected",
"unknown": "Unknown"
}
},
"current_l1": {
"name": "Current L1"
},
@@ -88,9 +118,18 @@
"power_production": {
"name": "Power production"
},
"range_remaining": {
"name": "Remaining range"
},
"signal_strength": {
"name": "Signal strength"
},
"storage_state_of_charge": {
"name": "Storage state of charge"
},
"storage_target_state_of_charge": {
"name": "Storage target state of charge"
},
"voltage_phase1": {
"name": "Voltage phase1"
},
@@ -103,9 +142,15 @@
}
},
"exceptions": {
"data_api_reauth_required": {
"message": "Reconnect Tibber so Home Assistant can enable the new Tibber Data API features."
},
"invalid_date": {
"message": "Invalid datetime provided {date}"
},
"oauth2_implementation_unavailable": {
"message": "[%key:common::exceptions::oauth2_implementation_unavailable::message%]"
},
"send_message_timeout": {
"message": "Timeout sending message with Tibber"
}

View File

@@ -298,6 +298,7 @@
}
],
"documentation": "https://www.home-assistant.io/integrations/tplink",
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["kasa"],
"quality_scale": "platinum",

View File

@@ -3,7 +3,7 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
from typing import Any, Self
from tuya_sharing import CustomerDevice, Manager
@@ -32,7 +32,12 @@ from .const import (
DPCode,
)
from .entity import TuyaEntity
from .models import DPCodeBooleanWrapper, DPCodeEnumWrapper, DPCodeIntegerWrapper
from .models import (
DeviceWrapper,
DPCodeBooleanWrapper,
DPCodeEnumWrapper,
DPCodeIntegerWrapper,
)
TUYA_HVAC_TO_HA = {
"auto": HVACMode.HEAT_COOL,
@@ -56,6 +61,84 @@ class _RoundedIntegerWrapper(DPCodeIntegerWrapper):
return round(value)
@dataclass(kw_only=True)
class _SwingModeWrapper(DeviceWrapper):
"""Wrapper for managing climate swing mode operations across multiple DPCodes."""
on_off: DPCodeBooleanWrapper | None = None
horizontal: DPCodeBooleanWrapper | None = None
vertical: DPCodeBooleanWrapper | None = None
modes: list[str]
@classmethod
def find_dpcode(cls, device: CustomerDevice) -> Self | None:
"""Find and return a _SwingModeWrapper for the given DP codes."""
on_off = DPCodeBooleanWrapper.find_dpcode(
device, (DPCode.SWING, DPCode.SHAKE), prefer_function=True
)
horizontal = DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SWITCH_HORIZONTAL, prefer_function=True
)
vertical = DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SWITCH_VERTICAL, prefer_function=True
)
if on_off or horizontal or vertical:
modes = [SWING_OFF]
if on_off:
modes.append(SWING_ON)
if horizontal:
modes.append(SWING_HORIZONTAL)
if vertical:
modes.append(SWING_VERTICAL)
return cls(
on_off=on_off,
horizontal=horizontal,
vertical=vertical,
modes=modes,
)
return None
def read_device_status(self, device: CustomerDevice) -> str | None:
"""Read the device swing mode."""
if self.on_off and self.on_off.read_device_status(device):
return SWING_ON
horizontal = (
self.horizontal.read_device_status(device) if self.horizontal else None
)
vertical = self.vertical.read_device_status(device) if self.vertical else None
if horizontal and vertical:
return SWING_BOTH
if horizontal:
return SWING_HORIZONTAL
if vertical:
return SWING_VERTICAL
return SWING_OFF
def get_update_commands(
self, device: CustomerDevice, value: str
) -> list[dict[str, Any]]:
"""Set new target swing operation."""
commands = []
if self.on_off:
commands.extend(self.on_off.get_update_commands(device, value == SWING_ON))
if self.vertical:
commands.extend(
self.vertical.get_update_commands(
device, value in (SWING_BOTH, SWING_VERTICAL)
)
)
if self.horizontal:
commands.extend(
self.horizontal.get_update_commands(
device, value in (SWING_BOTH, SWING_HORIZONTAL)
)
)
return commands
@dataclass(frozen=True, kw_only=True)
class TuyaClimateEntityDescription(ClimateEntityDescription):
"""Describe an Tuya climate entity."""
@@ -205,15 +288,7 @@ async def async_setup_entry(
device, DPCode.MODE, prefer_function=True
),
set_temperature_wrapper=temperature_wrappers[1],
swing_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, (DPCode.SWING, DPCode.SHAKE), prefer_function=True
),
swing_h_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SWITCH_HORIZONTAL, prefer_function=True
),
swing_v_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SWITCH_VERTICAL, prefer_function=True
),
swing_wrapper=_SwingModeWrapper.find_dpcode(device),
switch_wrapper=DPCodeBooleanWrapper.find_dpcode(
device, DPCode.SWITCH, prefer_function=True
),
@@ -250,9 +325,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
fan_mode_wrapper: DPCodeEnumWrapper | None,
hvac_mode_wrapper: DPCodeEnumWrapper | None,
set_temperature_wrapper: DPCodeIntegerWrapper | None,
swing_wrapper: DPCodeBooleanWrapper | None,
swing_h_wrapper: DPCodeBooleanWrapper | None,
swing_v_wrapper: DPCodeBooleanWrapper | None,
swing_wrapper: _SwingModeWrapper | None,
switch_wrapper: DPCodeBooleanWrapper | None,
target_humidity_wrapper: _RoundedIntegerWrapper | None,
temperature_unit: UnitOfTemperature,
@@ -268,8 +341,6 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self._hvac_mode_wrapper = hvac_mode_wrapper
self._set_temperature = set_temperature_wrapper
self._swing_wrapper = swing_wrapper
self._swing_h_wrapper = swing_h_wrapper
self._swing_v_wrapper = swing_v_wrapper
self._switch_wrapper = switch_wrapper
self._target_humidity_wrapper = target_humidity_wrapper
self._attr_temperature_unit = temperature_unit
@@ -324,17 +395,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
self._attr_fan_modes = fan_mode_wrapper.type_information.range
# Determine swing modes
if swing_wrapper or swing_h_wrapper or swing_v_wrapper:
if swing_wrapper:
self._attr_supported_features |= ClimateEntityFeature.SWING_MODE
self._attr_swing_modes = [SWING_OFF]
if swing_wrapper:
self._attr_swing_modes.append(SWING_ON)
if swing_h_wrapper:
self._attr_swing_modes.append(SWING_HORIZONTAL)
if swing_v_wrapper:
self._attr_swing_modes.append(SWING_VERTICAL)
self._attr_swing_modes = swing_wrapper.modes
if switch_wrapper:
self._attr_supported_features |= (
@@ -372,27 +435,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
async def async_set_swing_mode(self, swing_mode: str) -> None:
"""Set new target swing operation."""
commands = []
if self._swing_wrapper:
commands.extend(
self._swing_wrapper.get_update_commands(
self.device, swing_mode == SWING_ON
)
)
if self._swing_v_wrapper:
commands.extend(
self._swing_v_wrapper.get_update_commands(
self.device, swing_mode in (SWING_BOTH, SWING_VERTICAL)
)
)
if self._swing_h_wrapper:
commands.extend(
self._swing_h_wrapper.get_update_commands(
self.device, swing_mode in (SWING_BOTH, SWING_HORIZONTAL)
)
)
if commands:
await self._async_send_commands(commands)
await self._async_send_wrapper_updates(self._swing_wrapper, swing_mode)
async def async_set_temperature(self, **kwargs: Any) -> None:
"""Set new target temperature."""
@@ -457,21 +500,9 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
return self._read_wrapper(self._fan_mode_wrapper)
@property
def swing_mode(self) -> str:
def swing_mode(self) -> str | None:
"""Return swing mode."""
if self._read_wrapper(self._swing_wrapper):
return SWING_ON
horizontal = self._read_wrapper(self._swing_h_wrapper)
vertical = self._read_wrapper(self._swing_v_wrapper)
if horizontal and vertical:
return SWING_BOTH
if horizontal:
return SWING_HORIZONTAL
if vertical:
return SWING_VERTICAL
return SWING_OFF
return self._read_wrapper(self._swing_wrapper)
async def async_turn_on(self) -> None:
"""Turn the device on, retaining current HVAC (if supported)."""

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