Compare commits

..

77 Commits

Author SHA1 Message Date
abmantis
1d2a5f1092 Fix todo None state updates 2026-03-23 19:34:19 +00:00
abmantis
6dc43b6755 Merge branch 'dev' of github.com:home-assistant/core into todo_triggers 2026-03-23 19:18:40 +00:00
Norbert Rittel
c645bbb3f8 Replace "grid return" with "grid export" in opower issue (#165888)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-23 12:12:30 -07:00
Erik Montnemery
319f9fda92 Add air quality triggers (#166248)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-23 19:31:29 +01:00
cdnninja
f9525ebda7 Add switch tests to vesync (#163325) 2026-03-23 17:46:35 +00:00
Artur Pragacz
579dd6785d Add entity name template function (#166078) 2026-03-23 18:33:45 +01:00
Abílio Costa
84992b875a Allow TODO entity listeners to handle None state (#166276) 2026-03-23 17:32:41 +00:00
Magnus Nordseth
18a6478d9a Add config flow to touchline integration (#165790)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-23 17:50:46 +01:00
epenet
3d1a8fb08c Use SensorDeviceClass.PH in mysensors (#166274) 2026-03-23 17:43:59 +01:00
Erik Montnemery
3657a8eb07 Adjust humidity triggers (#166261)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-23 17:31:52 +01:00
epenet
83e8d1878b Simplify Tuya climate entity initialisation (#166277) 2026-03-23 17:17:04 +01:00
mettolen
6f635adb6b Bump pyliebherrhomeapi to 0.4.1 (#166269) 2026-03-23 17:14:08 +01:00
epenet
b3f4805afe Add missing type hint to Camera entity description (#166273) 2026-03-23 17:11:49 +01:00
Joakim Sørensen
b70651a811 Bump hass-nabucasa from 2.0.0 to 2.2.0 (#166267) 2026-03-23 17:10:16 +01:00
epenet
dc1e330e4a Simplify Tuya entity initialisation (#166266) 2026-03-23 16:59:56 +01:00
Erik Montnemery
a45da11ec1 Adjust light triggers (#166263)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-23 16:48:33 +01:00
Erik Montnemery
31c7553e68 Minor improvements of occupancy trigger tests (#166265) 2026-03-23 16:22:25 +01:00
Erik Montnemery
44e704a6e0 Minor improvements of motion trigger tests (#166264) 2026-03-23 16:21:16 +01:00
Erik Montnemery
2824919a20 Minor improvements of temperature trigger tests (#166259) 2026-03-23 16:17:59 +01:00
Erik Montnemery
ebe0e3ace7 Minor improvements of cover trigger tests (#166256) 2026-03-23 16:03:27 +01:00
Erik Montnemery
e151c9c78c Adjust temperature trigger translations (#166260) 2026-03-23 14:52:30 +01:00
Erik Montnemery
7287c847f4 Remove redundant humidity trigger test (#166257) 2026-03-23 14:31:24 +01:00
Petro31
152e17aee7 Update state template framework to support options other than state (#162737) 2026-03-23 14:26:21 +01:00
Petro31
c53adcb73b Correct validation of scripts in template entities (#165226) 2026-03-23 14:08:11 +01:00
Abílio Costa
dab4a72128 Add copilot-specific instructions (#166254)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-03-23 13:06:48 +00:00
abmantis
054570d468 Merge branch 'todo_triggers' of github.com:home-assistant/core into todo_triggers 2026-03-23 12:54:40 +00:00
hanwg
c94e10efa7 Improve subentry error handling for Telegram bot (#165863)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-23 13:46:13 +01:00
Bram Kragten
ca5ea9ea35 Update frontend to 20260312.1 (#166251) 2026-03-23 13:38:04 +01:00
Mike Degatano
63a09d8e28 Replace calls to set options in Supervisor with aiohasupervisor (#165872)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-23 13:18:37 +01:00
Martin Hjelmare
b5a3c2c014 Fix trane for Python 3.14.3 (#166252) 2026-03-23 13:17:49 +01:00
puddly
ef887c8edc Use device-specific firmware flashers for Yellow/ZBT-1/ZBT-2 (#164695) 2026-03-23 13:01:10 +01:00
epenet
d0eb90274d Cleanup deprecated YAML import from vera (#165659) 2026-03-23 13:00:20 +01:00
Copilot
9fe327ea15 Handle missing/invalid entities per-entity in todo trigger _start_listening (#166247)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: abmantis <974569+abmantis@users.noreply.github.com>
2026-03-23 11:50:24 +00:00
Paulus Schoutsen
cac375dafb Only start Assist Pipeline debug thread when capturing audio (#166190) 2026-03-23 12:46:23 +01:00
AlCalzone
2c20b62229 Create repair issue for legacy Z-Wave Door state sensors that are still in use (#165363)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2026-03-23 12:00:47 +01:00
Tommy Goode
b5c84b6b7a Fix zwave_js fan speed mapping for GE/Jasco Enbrighten 55258 / ZW4002 (#166169) 2026-03-23 11:55:46 +01:00
Raphael Hehl
e5f9668ded Bump py-unifi-access to 1.1.3 (#166177)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
2026-03-23 11:54:03 +01:00
abmantis
1549de466d Replace legacy service key 2026-03-23 10:45:39 +00:00
Martin Hjelmare
e214ce690a Refactor Z-Wave discovery schemas for sensor platform (#165254)
Co-authored-by: AlCalzone <d.griesel@gmx.net>
2026-03-23 11:43:30 +01:00
Erik Montnemery
a2c64f65e1 Add support for input_boolean to switch triggers (#166242) 2026-03-23 11:42:29 +01:00
Matrix
8bad30234a Add YoLink YS7A06 support (#165987) 2026-03-23 11:19:27 +01:00
dependabot[bot]
c4545b42d8 Bump dorny/paths-filter from 3.0.2 to 4.0.1 (#166237)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-03-23 11:08:25 +01:00
Eli Sand
b0a60d1c42 Fixes generic_thermostat config flow validation (#165680) 2026-03-23 10:18:45 +01:00
Norbert Rittel
e1e14bee10 Clarify description of motion_blinds.set_absolute_position action (#166243) 2026-03-23 10:10:10 +01:00
Erik Montnemery
3529aff4b1 Revert "Add turned off and turned on triggers to input boolean (#158824)" (#166240) 2026-03-23 08:46:03 +01:00
Matrix
16e314ccf1 Bump yolink-api to 0.6.3 (#166232) 2026-03-23 08:34:37 +01:00
Erik Montnemery
d634fbcad7 Add unit of measurement handling to numeric climate triggers (#166211) 2026-03-23 08:29:01 +01:00
Ray Xue
b84ca80d55 Add Linptech PS1BB pressure sensor support to xiaomi_ble (#166095) 2026-03-23 00:21:28 +01:00
David Bonnes
41c2c621f0 Bump evohome-async to 1.2.0 (#166227) 2026-03-23 00:10:38 +01:00
Peter Grauvogel
b230e62868 Bump greenplanet-energy-api from 0.1.4 to 0.1.10 (#166217) 2026-03-22 22:08:06 +01:00
Ludovic BOUÉ
12528ec128 Update python-roborock to 5.0.0 (#166219) 2026-03-22 13:38:31 -07:00
Manu
7f4a7670a2 Bump pyrate-limiter to 4.1.0 (#166221) 2026-03-22 13:38:19 -07:00
Erwin Douna
9bdc1b777e Add async_setup and yarl to Immich coordinator (#165900)
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-22 21:36:16 +01:00
Stathogon
995e982d7f Add shutdown button for VMs in ProxmoxVE (#165890) 2026-03-22 21:29:59 +01:00
MarkGodwin
b92698e3d5 Bump tplink-omada-client to fix breaking changes in Omada API (#166206) 2026-03-22 19:44:38 +01:00
Ludovic BOUÉ
225052b932 feat(roborock): Remove unnecessary type check for Q10 update coordinator in button setup (#166214) 2026-03-22 19:42:18 +01:00
Raphael Hehl
34ae51677f Add a reauthentication flow to the UniFi Access integration (#165859)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2026-03-22 18:09:46 +01:00
Abílio Costa
a861827d9a Merge branch 'dev' into todo_triggers 2026-03-19 23:05:50 +00:00
abmantis
da0d0186b5 Fix triggers when entity starts as unavailable 2026-03-19 23:04:58 +00:00
abmantis
fdc8fd3ca8 Add test for new entity on target 2026-03-18 22:26:37 +00:00
abmantis
5217dde1b9 Make items sorted 2026-03-18 22:20:59 +00:00
abmantis
2ebfcfa070 Improve TodoItem addition in tests 2026-03-18 22:14:35 +00:00
abmantis
be66e3c7e9 Fix initial list items not seeding the trigger 2026-03-18 22:02:13 +00:00
abmantis
ed482f4a15 Fix unsub 2026-03-18 17:19:20 +00:00
abmantis
8a8b2a9b82 Merge branch 'dev' of github.com:home-assistant/core into todo_triggers 2026-03-18 15:59:27 +00:00
abmantis
12930a6670 Add todo triggers 2026-03-18 15:56:38 +00:00
abmantis
0709e053c0 Merge branch 'dev' of github.com:home-assistant/core into todo_triggers 2026-03-17 19:26:10 +00:00
Abílio Costa
df9cb4d35d Merge branch 'dev' into todo_listener_arg_none 2026-03-17 18:47:28 +00:00
abmantis
44dcdc53b5 Drop deprecation 2026-03-17 18:46:52 +00:00
abmantis
8f62e2334e Use copy.copy 2026-03-17 17:51:39 +00:00
Abílio Costa
692265cec3 Merge branch 'dev' into todo_listener_arg_none 2026-03-17 17:27:35 +00:00
abmantis
dd437bf822 Add tests 2026-03-17 17:27:07 +00:00
abmantis
32a5d8965c Keep old sig 2026-03-17 16:17:10 +00:00
abmantis
cbe1ec6f3e Copy items 2026-03-17 16:12:10 +00:00
abmantis
d5a6283f4f Use TodoItem in listeners 2026-03-17 15:50:16 +00:00
abmantis
ebdbe25751 Merge branch 'dev' of github.com:home-assistant/core into todo_listener_arg_none 2026-03-17 15:32:29 +00:00
abmantis
33355a1bf1 Remove unecessary optional from TODO listener argument 2026-03-17 15:01:11 +00:00
202 changed files with 8368 additions and 2434 deletions

View File

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

View File

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

View File

@@ -241,6 +241,7 @@ DEFAULT_INTEGRATIONS = {
*BASE_PLATFORMS,
#
# Integrations providing triggers and conditions for base platforms:
"air_quality",
"door",
"garage_door",
"gate",

View File

@@ -3,5 +3,103 @@
"_": {
"default": "mdi:air-filter"
}
},
"triggers": {
"co2_changed": {
"trigger": "mdi:molecule-co2"
},
"co2_crossed_threshold": {
"trigger": "mdi:molecule-co2"
},
"co_changed": {
"trigger": "mdi:molecule-co"
},
"co_cleared": {
"trigger": "mdi:check-circle"
},
"co_crossed_threshold": {
"trigger": "mdi:molecule-co"
},
"co_detected": {
"trigger": "mdi:molecule-co"
},
"gas_cleared": {
"trigger": "mdi:check-circle"
},
"gas_detected": {
"trigger": "mdi:gas-cylinder"
},
"n2o_changed": {
"trigger": "mdi:factory"
},
"n2o_crossed_threshold": {
"trigger": "mdi:factory"
},
"no2_changed": {
"trigger": "mdi:factory"
},
"no2_crossed_threshold": {
"trigger": "mdi:factory"
},
"no_changed": {
"trigger": "mdi:factory"
},
"no_crossed_threshold": {
"trigger": "mdi:factory"
},
"ozone_changed": {
"trigger": "mdi:weather-sunny-alert"
},
"ozone_crossed_threshold": {
"trigger": "mdi:weather-sunny-alert"
},
"pm10_changed": {
"trigger": "mdi:blur"
},
"pm10_crossed_threshold": {
"trigger": "mdi:blur"
},
"pm1_changed": {
"trigger": "mdi:blur"
},
"pm1_crossed_threshold": {
"trigger": "mdi:blur"
},
"pm25_changed": {
"trigger": "mdi:blur"
},
"pm25_crossed_threshold": {
"trigger": "mdi:blur"
},
"pm4_changed": {
"trigger": "mdi:blur"
},
"pm4_crossed_threshold": {
"trigger": "mdi:blur"
},
"smoke_cleared": {
"trigger": "mdi:check-circle"
},
"smoke_detected": {
"trigger": "mdi:smoke-detector-variant"
},
"so2_changed": {
"trigger": "mdi:factory"
},
"so2_crossed_threshold": {
"trigger": "mdi:factory"
},
"voc_changed": {
"trigger": "mdi:air-filter"
},
"voc_crossed_threshold": {
"trigger": "mdi:air-filter"
},
"voc_ratio_changed": {
"trigger": "mdi:air-filter"
},
"voc_ratio_crossed_threshold": {
"trigger": "mdi:air-filter"
}
}
}

View File

@@ -0,0 +1,626 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
"trigger_behavior_name": "Behavior",
"trigger_changed_above_name": "Above",
"trigger_changed_below_name": "Below",
"trigger_threshold_lower_limit_description": "The lower limit of the threshold.",
"trigger_threshold_lower_limit_name": "Lower limit",
"trigger_threshold_type_description": "The type of threshold to use.",
"trigger_threshold_type_name": "Threshold type",
"trigger_threshold_upper_limit_description": "The upper limit of the threshold.",
"trigger_threshold_upper_limit_name": "Upper limit",
"trigger_unit_description": "All values will be converted to this unit when evaluating the trigger.",
"trigger_unit_name": "Unit of measurement"
},
"selector": {
"number_or_entity": {
"choices": {
"entity": "Entity",
"number": "Number"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
},
"trigger_threshold_type": {
"options": {
"above": "Above",
"below": "Below",
"between": "Between",
"outside": "Outside"
}
}
},
"title": "Air Quality",
"triggers": {
"co2_changed": {
"description": "Triggers after one or more carbon dioxide levels change.",
"fields": {
"above": {
"description": "Only trigger when carbon dioxide level is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when carbon dioxide level is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
}
},
"name": "Carbon dioxide level changed"
},
"co2_crossed_threshold": {
"description": "Triggers after one or more carbon dioxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "Carbon dioxide level crossed threshold"
},
"co_changed": {
"description": "Triggers after one or more carbon monoxide levels change.",
"fields": {
"above": {
"description": "Only trigger when carbon monoxide level is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when carbon monoxide level is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
}
},
"name": "Carbon monoxide level changed"
},
"co_cleared": {
"description": "Triggers after one or more carbon monoxide sensors stop detecting carbon monoxide.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Carbon monoxide cleared"
},
"co_crossed_threshold": {
"description": "Triggers after one or more carbon monoxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "Carbon monoxide level crossed threshold"
},
"co_detected": {
"description": "Triggers after one or more carbon monoxide sensors start detecting carbon monoxide.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Carbon monoxide detected"
},
"gas_cleared": {
"description": "Triggers after one or more gas sensors stop detecting gas.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Gas cleared"
},
"gas_detected": {
"description": "Triggers after one or more gas sensors start detecting gas.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Gas detected"
},
"n2o_changed": {
"description": "Triggers after one or more nitrous oxide levels change.",
"fields": {
"above": {
"description": "Only trigger when nitrous oxide level is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when nitrous oxide level is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
}
},
"name": "Nitrous oxide level changed"
},
"n2o_crossed_threshold": {
"description": "Triggers after one or more nitrous oxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "Nitrous oxide level crossed threshold"
},
"no2_changed": {
"description": "Triggers after one or more nitrogen dioxide levels change.",
"fields": {
"above": {
"description": "Only trigger when nitrogen dioxide level is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when nitrogen dioxide level is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
}
},
"name": "Nitrogen dioxide level changed"
},
"no2_crossed_threshold": {
"description": "Triggers after one or more nitrogen dioxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "Nitrogen dioxide level crossed threshold"
},
"no_changed": {
"description": "Triggers after one or more nitrogen monoxide levels change.",
"fields": {
"above": {
"description": "Only trigger when nitrogen monoxide level is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when nitrogen monoxide level is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
}
},
"name": "Nitrogen monoxide level changed"
},
"no_crossed_threshold": {
"description": "Triggers after one or more nitrogen monoxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "Nitrogen monoxide level crossed threshold"
},
"ozone_changed": {
"description": "Triggers after one or more ozone levels change.",
"fields": {
"above": {
"description": "Only trigger when ozone level is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when ozone level is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
}
},
"name": "Ozone level changed"
},
"ozone_crossed_threshold": {
"description": "Triggers after one or more ozone levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "Ozone level crossed threshold"
},
"pm10_changed": {
"description": "Triggers after one or more PM10 levels change.",
"fields": {
"above": {
"description": "Only trigger when PM10 level is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when PM10 level is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
}
},
"name": "PM10 level changed"
},
"pm10_crossed_threshold": {
"description": "Triggers after one or more PM10 levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "PM10 level crossed threshold"
},
"pm1_changed": {
"description": "Triggers after one or more PM1 levels change.",
"fields": {
"above": {
"description": "Only trigger when PM1 level is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when PM1 level is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
}
},
"name": "PM1 level changed"
},
"pm1_crossed_threshold": {
"description": "Triggers after one or more PM1 levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "PM1 level crossed threshold"
},
"pm25_changed": {
"description": "Triggers after one or more PM2.5 levels change.",
"fields": {
"above": {
"description": "Only trigger when PM2.5 level is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when PM2.5 level is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
}
},
"name": "PM2.5 level changed"
},
"pm25_crossed_threshold": {
"description": "Triggers after one or more PM2.5 levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "PM2.5 level crossed threshold"
},
"pm4_changed": {
"description": "Triggers after one or more PM4 levels change.",
"fields": {
"above": {
"description": "Only trigger when PM4 level is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when PM4 level is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
}
},
"name": "PM4 level changed"
},
"pm4_crossed_threshold": {
"description": "Triggers after one or more PM4 levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "PM4 level crossed threshold"
},
"smoke_cleared": {
"description": "Triggers after one or more smoke sensors stop detecting smoke.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Smoke cleared"
},
"smoke_detected": {
"description": "Triggers after one or more smoke sensors start detecting smoke.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
}
},
"name": "Smoke detected"
},
"so2_changed": {
"description": "Triggers after one or more sulphur dioxide levels change.",
"fields": {
"above": {
"description": "Only trigger when sulphur dioxide level is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when sulphur dioxide level is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
}
},
"name": "Sulphur dioxide level changed"
},
"so2_crossed_threshold": {
"description": "Triggers after one or more sulphur dioxide levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "Sulphur dioxide level crossed threshold"
},
"voc_changed": {
"description": "Triggers after one or more volatile organic compound levels change.",
"fields": {
"above": {
"description": "Only trigger when volatile organic compounds level is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when volatile organic compounds level is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
}
},
"name": "Volatile organic compounds level changed"
},
"voc_crossed_threshold": {
"description": "Triggers after one or more volatile organic compounds levels cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "Volatile organic compounds level crossed threshold"
},
"voc_ratio_changed": {
"description": "Triggers after one or more volatile organic compound ratios change.",
"fields": {
"above": {
"description": "Only trigger when volatile organic compounds ratio is above this value.",
"name": "[%key:component::air_quality::common::trigger_changed_above_name%]"
},
"below": {
"description": "Only trigger when volatile organic compounds ratio is below this value.",
"name": "[%key:component::air_quality::common::trigger_changed_below_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
}
},
"name": "Volatile organic compounds ratio changed"
},
"voc_ratio_crossed_threshold": {
"description": "Triggers after one or more volatile organic compounds ratios cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::air_quality::common::trigger_behavior_description%]",
"name": "[%key:component::air_quality::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_lower_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_lower_limit_name%]"
},
"threshold_type": {
"description": "[%key:component::air_quality::common::trigger_threshold_type_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_type_name%]"
},
"unit": {
"description": "[%key:component::air_quality::common::trigger_unit_description%]",
"name": "[%key:component::air_quality::common::trigger_unit_name%]"
},
"upper_limit": {
"description": "[%key:component::air_quality::common::trigger_threshold_upper_limit_description%]",
"name": "[%key:component::air_quality::common::trigger_threshold_upper_limit_name%]"
}
},
"name": "Volatile organic compounds ratio crossed threshold"
}
}
}

View File

@@ -0,0 +1,241 @@
"""Provides triggers for air quality."""
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_BILLION,
CONCENTRATION_PARTS_PER_MILLION,
STATE_OFF,
STATE_ON,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.trigger import (
EntityTargetStateTriggerBase,
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_changed_with_unit_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_numerical_state_crossed_threshold_with_unit_trigger,
)
from homeassistant.util.unit_conversion import (
CarbonMonoxideConcentrationConverter,
MassVolumeConcentrationConverter,
NitrogenDioxideConcentrationConverter,
NitrogenMonoxideConcentrationConverter,
OzoneConcentrationConverter,
SulphurDioxideConcentrationConverter,
UnitlessRatioConverter,
)
def _make_detected_trigger(
device_class: BinarySensorDeviceClass,
) -> type[EntityTargetStateTriggerBase]:
"""Create a detected trigger for a binary sensor device class."""
class DetectedTrigger(EntityTargetStateTriggerBase):
_domain_specs = {BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}
_to_states = {STATE_ON}
return DetectedTrigger
def _make_cleared_trigger(
device_class: BinarySensorDeviceClass,
) -> type[EntityTargetStateTriggerBase]:
"""Create a cleared trigger for a binary sensor device class."""
class ClearedTrigger(EntityTargetStateTriggerBase):
_domain_specs = {BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}
_to_states = {STATE_OFF}
return ClearedTrigger
TRIGGERS: dict[str, type[Trigger]] = {
# Binary sensor triggers (detected/cleared)
"gas_detected": _make_detected_trigger(BinarySensorDeviceClass.GAS),
"gas_cleared": _make_cleared_trigger(BinarySensorDeviceClass.GAS),
"co_detected": _make_detected_trigger(BinarySensorDeviceClass.CO),
"co_cleared": _make_cleared_trigger(BinarySensorDeviceClass.CO),
"smoke_detected": _make_detected_trigger(BinarySensorDeviceClass.SMOKE),
"smoke_cleared": _make_cleared_trigger(BinarySensorDeviceClass.SMOKE),
# Numerical sensor triggers with unit conversion
"co_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"co_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CarbonMonoxideConcentrationConverter,
),
"ozone_changed": make_entity_numerical_state_changed_with_unit_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"ozone_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.OZONE)},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
OzoneConcentrationConverter,
),
"voc_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"voc_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
MassVolumeConcentrationConverter,
),
"voc_ratio_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"voc_ratio_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.VOLATILE_ORGANIC_COMPOUNDS_PARTS
)
},
CONCENTRATION_PARTS_PER_BILLION,
UnitlessRatioConverter,
),
"no_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROGEN_MONOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROGEN_MONOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenMonoxideConcentrationConverter,
),
"no2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROGEN_DIOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"no2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROGEN_DIOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
NitrogenDioxideConcentrationConverter,
),
"so2_changed": make_entity_numerical_state_changed_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.SULPHUR_DIOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
"so2_crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.SULPHUR_DIOXIDE
)
},
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
SulphurDioxideConcentrationConverter,
),
# Numerical sensor triggers without unit conversion (single-unit device classes)
"co2_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"co2_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.CO2)},
valid_unit=CONCENTRATION_PARTS_PER_MILLION,
),
"pm1_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm1_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM1)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm25_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm25_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM25)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm4_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm4_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM4)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm10_changed": make_entity_numerical_state_changed_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"pm10_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.PM10)},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"n2o_changed": make_entity_numerical_state_changed_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROUS_OXIDE
)
},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
"n2o_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{
SENSOR_DOMAIN: NumericalDomainSpec(
device_class=SensorDeviceClass.NITROUS_OXIDE
)
},
valid_unit=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for air quality."""
return TRIGGERS

View File

@@ -0,0 +1,692 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
.number_or_entity_co: &number_or_entity_co
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "ppb"
- "ppm"
- "mg/m³"
- "μg/m³"
- domain: sensor
device_class: carbon_monoxide
- domain: number
device_class: carbon_monoxide
translation_key: number_or_entity
.number_or_entity_co2: &number_or_entity_co2
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "ppm"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "ppm"
- domain: sensor
device_class: carbon_dioxide
- domain: number
device_class: carbon_dioxide
translation_key: number_or_entity
.number_or_entity_pm1: &number_or_entity_pm1
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "μg/m³"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm1
- domain: number
device_class: pm1
translation_key: number_or_entity
.number_or_entity_pm25: &number_or_entity_pm25
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "μg/m³"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm25
- domain: number
device_class: pm25
translation_key: number_or_entity
.number_or_entity_pm4: &number_or_entity_pm4
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "μg/m³"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm4
- domain: number
device_class: pm4
translation_key: number_or_entity
.number_or_entity_pm10: &number_or_entity_pm10
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "μg/m³"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: pm10
- domain: number
device_class: pm10
translation_key: number_or_entity
.number_or_entity_ozone: &number_or_entity_ozone
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "ppb"
- "ppm"
- "μg/m³"
- domain: sensor
device_class: ozone
- domain: number
device_class: ozone
translation_key: number_or_entity
.number_or_entity_voc: &number_or_entity_voc
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "μg/m³"
- "mg/m³"
- domain: sensor
device_class: volatile_organic_compounds
- domain: number
device_class: volatile_organic_compounds
translation_key: number_or_entity
.number_or_entity_voc_ratio: &number_or_entity_voc_ratio
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "ppb"
- "ppm"
- domain: sensor
device_class: volatile_organic_compounds_parts
- domain: number
device_class: volatile_organic_compounds_parts
translation_key: number_or_entity
.number_or_entity_no: &number_or_entity_no
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "ppb"
- "μg/m³"
- domain: sensor
device_class: nitrogen_monoxide
- domain: number
device_class: nitrogen_monoxide
translation_key: number_or_entity
.number_or_entity_no2: &number_or_entity_no2
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "ppb"
- "ppm"
- "μg/m³"
- domain: sensor
device_class: nitrogen_dioxide
- domain: number
device_class: nitrogen_dioxide
translation_key: number_or_entity
.number_or_entity_n2o: &number_or_entity_n2o
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "μg/m³"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "μg/m³"
- domain: sensor
device_class: nitrous_oxide
- domain: number
device_class: nitrous_oxide
translation_key: number_or_entity
.number_or_entity_so2: &number_or_entity_so2
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "ppb"
- "μg/m³"
- domain: sensor
device_class: sulphur_dioxide
- domain: number
device_class: sulphur_dioxide
translation_key: number_or_entity
.unit_co: &unit_co
required: false
selector:
select:
options:
- "ppb"
- "ppm"
- "mg/m³"
- "μg/m³"
.unit_ozone: &unit_ozone
required: false
selector:
select:
options:
- "ppb"
- "ppm"
- "μg/m³"
.unit_no2: &unit_no2
required: false
selector:
select:
options:
- "ppb"
- "ppm"
- "μg/m³"
.unit_no: &unit_no
required: false
selector:
select:
options:
- "ppb"
- "μg/m³"
.unit_so2: &unit_so2
required: false
selector:
select:
options:
- "ppb"
- "μg/m³"
.unit_voc: &unit_voc
required: false
selector:
select:
options:
- "μg/m³"
- "mg/m³"
.unit_voc_ratio: &unit_voc_ratio
required: false
selector:
select:
options:
- "ppb"
- "ppm"
.trigger_threshold_type: &trigger_threshold_type
required: true
default: above
selector:
select:
options:
- above
- below
- between
- outside
translation_key: trigger_threshold_type
# Binary sensor detected/cleared trigger fields
.trigger_binary_fields: &trigger_binary_fields
behavior: *trigger_behavior
# --- Binary sensor targets ---
.target_gas: &target_gas
entity:
- domain: binary_sensor
device_class: gas
.target_co_binary: &target_co_binary
entity:
- domain: binary_sensor
device_class: carbon_monoxide
.target_smoke: &target_smoke
entity:
- domain: binary_sensor
device_class: smoke
# --- Sensor targets ---
.target_co_sensor: &target_co_sensor
entity:
- domain: sensor
device_class: carbon_monoxide
.target_co2: &target_co2
entity:
- domain: sensor
device_class: carbon_dioxide
.target_pm1: &target_pm1
entity:
- domain: sensor
device_class: pm1
.target_pm25: &target_pm25
entity:
- domain: sensor
device_class: pm25
.target_pm4: &target_pm4
entity:
- domain: sensor
device_class: pm4
.target_pm10: &target_pm10
entity:
- domain: sensor
device_class: pm10
.target_ozone: &target_ozone
entity:
- domain: sensor
device_class: ozone
.target_voc: &target_voc
entity:
- domain: sensor
device_class: volatile_organic_compounds
.target_voc_ratio: &target_voc_ratio
entity:
- domain: sensor
device_class: volatile_organic_compounds_parts
.target_no: &target_no
entity:
- domain: sensor
device_class: nitrogen_monoxide
.target_no2: &target_no2
entity:
- domain: sensor
device_class: nitrogen_dioxide
.target_n2o: &target_n2o
entity:
- domain: sensor
device_class: nitrous_oxide
.target_so2: &target_so2
entity:
- domain: sensor
device_class: sulphur_dioxide
# --- Binary sensor triggers ---
gas_detected:
fields: *trigger_binary_fields
target: *target_gas
gas_cleared:
fields: *trigger_binary_fields
target: *target_gas
co_detected:
fields: *trigger_binary_fields
target: *target_co_binary
co_cleared:
fields: *trigger_binary_fields
target: *target_co_binary
smoke_detected:
fields: *trigger_binary_fields
target: *target_smoke
smoke_cleared:
fields: *trigger_binary_fields
target: *target_smoke
# --- Numerical sensor triggers ---
co_changed:
target: *target_co_sensor
fields:
above: *number_or_entity_co
below: *number_or_entity_co
unit: *unit_co
co_crossed_threshold:
target: *target_co_sensor
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_co
upper_limit: *number_or_entity_co
unit: *unit_co
co2_changed:
target: *target_co2
fields:
above: *number_or_entity_co2
below: *number_or_entity_co2
co2_crossed_threshold:
target: *target_co2
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_co2
upper_limit: *number_or_entity_co2
pm1_changed:
target: *target_pm1
fields:
above: *number_or_entity_pm1
below: *number_or_entity_pm1
pm1_crossed_threshold:
target: *target_pm1
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_pm1
upper_limit: *number_or_entity_pm1
pm25_changed:
target: *target_pm25
fields:
above: *number_or_entity_pm25
below: *number_or_entity_pm25
pm25_crossed_threshold:
target: *target_pm25
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_pm25
upper_limit: *number_or_entity_pm25
pm4_changed:
target: *target_pm4
fields:
above: *number_or_entity_pm4
below: *number_or_entity_pm4
pm4_crossed_threshold:
target: *target_pm4
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_pm4
upper_limit: *number_or_entity_pm4
pm10_changed:
target: *target_pm10
fields:
above: *number_or_entity_pm10
below: *number_or_entity_pm10
pm10_crossed_threshold:
target: *target_pm10
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_pm10
upper_limit: *number_or_entity_pm10
ozone_changed:
target: *target_ozone
fields:
above: *number_or_entity_ozone
below: *number_or_entity_ozone
unit: *unit_ozone
ozone_crossed_threshold:
target: *target_ozone
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_ozone
upper_limit: *number_or_entity_ozone
unit: *unit_ozone
voc_changed:
target: *target_voc
fields:
above: *number_or_entity_voc
below: *number_or_entity_voc
unit: *unit_voc
voc_crossed_threshold:
target: *target_voc
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_voc
upper_limit: *number_or_entity_voc
unit: *unit_voc
voc_ratio_changed:
target: *target_voc_ratio
fields:
above: *number_or_entity_voc_ratio
below: *number_or_entity_voc_ratio
unit: *unit_voc_ratio
voc_ratio_crossed_threshold:
target: *target_voc_ratio
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_voc_ratio
upper_limit: *number_or_entity_voc_ratio
unit: *unit_voc_ratio
no_changed:
target: *target_no
fields:
above: *number_or_entity_no
below: *number_or_entity_no
unit: *unit_no
no_crossed_threshold:
target: *target_no
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_no
upper_limit: *number_or_entity_no
unit: *unit_no
no2_changed:
target: *target_no2
fields:
above: *number_or_entity_no2
below: *number_or_entity_no2
unit: *unit_no2
no2_crossed_threshold:
target: *target_no2
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_no2
upper_limit: *number_or_entity_no2
unit: *unit_no2
n2o_changed:
target: *target_n2o
fields:
above: *number_or_entity_n2o
below: *number_or_entity_n2o
n2o_crossed_threshold:
target: *target_n2o
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_n2o
upper_limit: *number_or_entity_n2o
so2_changed:
target: *target_so2
fields:
above: *number_or_entity_so2
below: *number_or_entity_so2
unit: *unit_so2
so2_crossed_threshold:
target: *target_so2
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity_so2
upper_limit: *number_or_entity_so2
unit: *unit_so2

View File

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

View File

@@ -143,6 +143,7 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
}
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
"air_quality",
"alarm_control_panel",
"assist_satellite",
"button",
@@ -155,7 +156,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"gate",
"humidifier",
"humidity",
"input_boolean",
"lawn_mower",
"light",
"lock",

View File

@@ -432,6 +432,7 @@ class Camera(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
)
# Entity Properties
entity_description: CameraEntityDescription
_attr_brand: str | None = None
_attr_frame_interval: float = MIN_STREAM_INTERVAL
_attr_is_on: bool = True

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -64,9 +64,6 @@
}
},
"triggers": {
"mode_changed": {
"trigger": "mdi:air-humidifier"
},
"started_drying": {
"trigger": "mdi:arrow-down-bold"
},

View File

@@ -199,20 +199,6 @@
},
"title": "Humidifier",
"triggers": {
"mode_changed": {
"description": "Triggers after the operation mode of one or more humidifiers changes.",
"fields": {
"behavior": {
"description": "[%key:component::humidifier::common::trigger_behavior_description%]",
"name": "[%key:component::humidifier::common::trigger_behavior_name%]"
},
"mode": {
"description": "The operation modes to trigger on.",
"name": "Mode"
}
},
"name": "Humidifier mode changed"
},
"started_drying": {
"description": "Triggers after one or more humidifiers start drying.",
"fields": {

View File

@@ -1,46 +1,13 @@
"""Provides triggers for humidifiers."""
import voluptuous as vol
from homeassistant.const import ATTR_MODE, CONF_OPTIONS, STATE_OFF, STATE_ON
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST,
EntityTargetStateTriggerBase,
Trigger,
TriggerConfig,
make_entity_target_state_trigger,
)
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import ATTR_ACTION, DOMAIN, HumidifierAction
CONF_MODE = "mode"
MODE_CHANGED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA_FIRST_LAST.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_MODE): vol.All(cv.ensure_list, vol.Length(min=1), [str]),
},
}
)
class ModeChangedTrigger(EntityTargetStateTriggerBase):
"""Trigger for humidifier mode changes."""
_domain_specs = {DOMAIN: DomainSpec(value_source=ATTR_MODE)}
_schema = MODE_CHANGED_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the mode trigger."""
super().__init__(hass, config)
self._to_states = set(self._options[CONF_MODE])
TRIGGERS: dict[str, type[Trigger]] = {
"mode_changed": ModeChangedTrigger,
"started_drying": make_entity_target_state_trigger(
{DOMAIN: DomainSpec(value_source=ATTR_ACTION)}, HumidifierAction.DRYING
),

View File

@@ -1,9 +1,9 @@
.trigger_common: &trigger_common
target: &trigger_humidifier_target
target:
entity:
domain: humidifier
fields:
behavior: &trigger_behavior
behavior:
required: true
default: any
selector:
@@ -18,16 +18,3 @@ started_drying: *trigger_common
started_humidifying: *trigger_common
turned_on: *trigger_common
turned_off: *trigger_common
mode_changed:
target: *trigger_humidifier_target
fields:
behavior: *trigger_behavior
mode:
context:
filter_target: target
required: true
selector:
state:
attribute: available_modes
multiple: true

View File

@@ -29,7 +29,7 @@
"title": "Humidity",
"triggers": {
"changed": {
"description": "Triggers when the relative humidity changes.",
"description": "Triggers after one or more relative humidity values change.",
"fields": {
"above": {
"description": "Only trigger when relative humidity is above this value.",
@@ -43,7 +43,7 @@
"name": "Relative humidity changed"
},
"crossed_threshold": {
"description": "Triggers when the relative humidity crosses a threshold.",
"description": "Triggers after one or more relative humidity values cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::humidity::common::trigger_behavior_description%]",

View File

@@ -39,9 +39,11 @@ HUMIDITY_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
}
TRIGGERS: dict[str, type[Trigger]] = {
"changed": make_entity_numerical_state_changed_trigger(HUMIDITY_DOMAIN_SPECS),
"changed": make_entity_numerical_state_changed_trigger(
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
HUMIDITY_DOMAIN_SPECS
HUMIDITY_DOMAIN_SPECS, valid_unit="%"
),
}

View File

@@ -24,10 +24,12 @@
selector:
entity:
filter:
domain:
- input_number
- number
- sensor
- domain: input_number
unit_of_measurement: "%"
- domain: number
device_class: humidity
- domain: sensor
device_class: humidity
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -30,10 +30,10 @@ BRIGHTNESS_DOMAIN_SPECS = {
TRIGGERS: dict[str, type[Trigger]] = {
"brightness_changed": make_entity_numerical_state_changed_trigger(
BRIGHTNESS_DOMAIN_SPECS
BRIGHTNESS_DOMAIN_SPECS, valid_unit="%"
),
"brightness_crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
BRIGHTNESS_DOMAIN_SPECS
BRIGHTNESS_DOMAIN_SPECS, valid_unit="%"
),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),

View File

@@ -30,10 +30,12 @@
selector:
entity:
filter:
domain:
- input_number
- number
- sensor
- domain: input_number
unit_of_measurement: "%"
- domain: number
unit_of_measurement: "%"
- domain: sensor
unit_of_measurement: "%"
translation_key: number_or_entity
turned_on: *trigger_common

View File

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

View File

@@ -185,7 +185,7 @@ SENSORS: dict[str, SensorEntityDescription] = {
),
"V_PH": SensorEntityDescription(
key="V_PH",
native_unit_of_measurement="pH",
device_class=SensorDeviceClass.PH,
),
"V_ORP": SensorEntityDescription(
key="V_ORP",

View File

@@ -126,8 +126,8 @@
},
"issues": {
"return_to_grid_migration": {
"description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those in separate return statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Return to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue.",
"title": "Return to grid statistics for account: {utility_account_id}"
"description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those into separate export statistics for a better experience in the Energy dashboard.\n\nPlease visit the [Energy configuration page]({energy_settings}) to add the following statistics in the **Energy exported to grid** section:\n\n{target_ids}\n\nOnce you have added them, ignore this issue.",
"title": "Export to grid statistics for account: {utility_account_id}"
},
"unsupported_utility": {
"fix_flow": {

View File

@@ -90,5 +90,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"quality_scale": "bronze",
"requirements": ["PSNAWP==3.0.3", "pyrate-limiter==4.0.2"]
"requirements": ["PSNAWP==3.0.3", "pyrate-limiter==4.1.0"]
}

View File

@@ -125,6 +125,14 @@ VM_BUTTONS: tuple[ProxmoxVMButtonEntityDescription, ...] = (
),
entity_category=EntityCategory.CONFIG,
),
ProxmoxVMButtonEntityDescription(
key="shutdown",
translation_key="shutdown",
press_action=lambda coordinator, node, vmid: (
coordinator.proxmox.nodes(node).qemu(vmid).status.shutdown.post()
),
entity_category=EntityCategory.CONFIG,
),
)
CONTAINER_BUTTONS: tuple[ProxmoxContainerButtonEntityDescription, ...] = (

View File

@@ -7,6 +7,9 @@
"reset": {
"default": "mdi:restart"
},
"shutdown": {
"default": "mdi:power"
},
"start": {
"default": "mdi:play"
},

View File

@@ -159,7 +159,6 @@ async def async_setup_entry(
description,
)
for coordinator in config_entry.runtime_data.b01_q10
if isinstance(coordinator, RoborockB01Q10UpdateCoordinator)
for description in Q10_BUTTON_DESCRIPTIONS
),
)

View File

@@ -20,7 +20,7 @@
"loggers": ["roborock"],
"quality_scale": "silver",
"requirements": [
"python-roborock==4.26.3",
"python-roborock==5.0.0",
"vacuum-map-parser-roborock==0.1.4"
]
}

View File

@@ -80,23 +80,23 @@ Q7_STATE_CODE_TO_STATE = {
}
Q10_STATE_CODE_TO_STATE = {
YXDeviceState.SLEEP_STATE: VacuumActivity.IDLE,
YXDeviceState.STANDBY_STATE: VacuumActivity.IDLE,
YXDeviceState.CLEANING_STATE: VacuumActivity.CLEANING,
YXDeviceState.TO_CHARGE_STATE: VacuumActivity.RETURNING,
YXDeviceState.REMOTEING_STATE: VacuumActivity.CLEANING,
YXDeviceState.CHARGING_STATE: VacuumActivity.DOCKED,
YXDeviceState.PAUSE_STATE: VacuumActivity.PAUSED,
YXDeviceState.FAULT_STATE: VacuumActivity.ERROR,
YXDeviceState.UPGRADE_STATE: VacuumActivity.DOCKED,
YXDeviceState.DUSTING: VacuumActivity.DOCKED,
YXDeviceState.CREATING_MAP_STATE: VacuumActivity.CLEANING,
YXDeviceState.RE_LOCATION_STATE: VacuumActivity.CLEANING,
YXDeviceState.ROBOT_SWEEPING: VacuumActivity.CLEANING,
YXDeviceState.ROBOT_MOPING: VacuumActivity.CLEANING,
YXDeviceState.ROBOT_SWEEP_AND_MOPING: VacuumActivity.CLEANING,
YXDeviceState.ROBOT_TRANSITIONING: VacuumActivity.CLEANING,
YXDeviceState.ROBOT_WAIT_CHARGE: VacuumActivity.DOCKED,
YXDeviceState.SLEEPING: VacuumActivity.IDLE,
YXDeviceState.IDLE: VacuumActivity.IDLE,
YXDeviceState.CLEANING: VacuumActivity.CLEANING,
YXDeviceState.RETURNING_HOME: VacuumActivity.RETURNING,
YXDeviceState.REMOTE_CONTROL_ACTIVE: VacuumActivity.CLEANING,
YXDeviceState.CHARGING: VacuumActivity.DOCKED,
YXDeviceState.PAUSED: VacuumActivity.PAUSED,
YXDeviceState.ERROR: VacuumActivity.ERROR,
YXDeviceState.UPDATING: VacuumActivity.DOCKED,
YXDeviceState.EMPTYING_THE_BIN: VacuumActivity.DOCKED,
YXDeviceState.MAPPING: VacuumActivity.CLEANING,
YXDeviceState.RELOCATING: VacuumActivity.CLEANING,
YXDeviceState.SWEEPING: VacuumActivity.CLEANING,
YXDeviceState.MOPPING: VacuumActivity.CLEANING,
YXDeviceState.SWEEP_AND_MOP: VacuumActivity.CLEANING,
YXDeviceState.TRANSITIONING: VacuumActivity.CLEANING,
YXDeviceState.WAITING_TO_CHARGE: VacuumActivity.DOCKED,
}
PARALLEL_UPDATES = 0

View File

@@ -1,14 +1,18 @@
"""Provides triggers for switch platform."""
from homeassistant.components.input_boolean import DOMAIN as INPUT_BOOLEAN_DOMAIN
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN
SWITCH_DOMAIN_SPECS = {DOMAIN: DomainSpec(), INPUT_BOOLEAN_DOMAIN: DomainSpec()}
TRIGGERS: dict[str, type[Trigger]] = {
"turned_on": make_entity_target_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_target_state_trigger(DOMAIN, STATE_OFF),
"turned_on": make_entity_target_state_trigger(SWITCH_DOMAIN_SPECS, STATE_ON),
"turned_off": make_entity_target_state_trigger(SWITCH_DOMAIN_SPECS, STATE_OFF),
}

View File

@@ -1,7 +1,8 @@
.trigger_common: &trigger_common
target:
entity:
domain: switch
- domain: switch
- domain: input_boolean
fields:
behavior:
required: true

View File

@@ -153,6 +153,7 @@ STEP_WEBHOOKS_DATA_SCHEMA: vol.Schema = vol.Schema(
vol.Required(CONF_TRUSTED_NETWORKS): vol.Coerce(str),
}
)
SUBENTRY_SCHEMA: vol.Schema = vol.Schema({vol.Required(CONF_CHAT_ID): vol.Coerce(int)})
OPTIONS_SCHEMA: vol.Schema = vol.Schema(
{
vol.Required(
@@ -598,24 +599,30 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow):
)
errors: dict[str, str] = {}
description_placeholders = DESCRIPTION_PLACEHOLDERS.copy()
if user_input is not None:
config_entry: TelegramBotConfigEntry = self._get_entry()
bot = config_entry.runtime_data.bot
# validate chat id
chat_id: int = user_input[CONF_CHAT_ID]
chat_name = await _async_get_chat_name(bot, chat_id)
if chat_name:
try:
chat_info: ChatFullInfo = await bot.get_chat(chat_id)
except BadRequest:
errors["base"] = "chat_not_found"
except TelegramError as err:
errors["base"] = "telegram_error"
description_placeholders[ERROR_MESSAGE] = str(err)
if not errors:
return self.async_create_entry(
title=f"{chat_name} ({chat_id})",
title=chat_info.effective_name or str(chat_id),
data={CONF_CHAT_ID: chat_id},
unique_id=str(chat_id),
)
errors["base"] = "chat_not_found"
service: TelegramNotificationService = self._get_entry().runtime_data
description_placeholders = DESCRIPTION_PLACEHOLDERS.copy()
description_placeholders["bot_username"] = f"@{service.bot.username}"
description_placeholders["bot_url"] = f"https://t.me/{service.bot.username}"
@@ -639,7 +646,7 @@ class AllowedChatIdsSubEntryFlowHandler(ConfigSubentryFlow):
return self.async_show_form(
step_id="user",
data_schema=self.add_suggested_values_to_schema(
vol.Schema({vol.Required(CONF_CHAT_ID): vol.Coerce(int)}),
SUBENTRY_SCHEMA,
suggested_values,
),
description_placeholders=description_placeholders,
@@ -677,11 +684,3 @@ async def _get_most_recent_chat(
)
return None
async def _async_get_chat_name(bot: Bot, chat_id: int) -> str:
try:
chat_info: ChatFullInfo = await bot.get_chat(chat_id)
return chat_info.effective_name or str(chat_id)
except BadRequest:
return ""

View File

@@ -92,7 +92,8 @@
},
"entry_type": "Allowed chat ID",
"error": {
"chat_not_found": "Chat not found"
"chat_not_found": "Chat not found",
"telegram_error": "[%key:component::telegram_bot::config::error::telegram_error%]"
},
"initiate_flow": {
"user": "Add allowed chat ID"

View File

@@ -29,7 +29,7 @@
"title": "Temperature",
"triggers": {
"changed": {
"description": "Triggers when the temperature changes.",
"description": "Triggers after one or more temperatures change.",
"fields": {
"above": {
"description": "Only trigger when temperature is above this value.",
@@ -47,7 +47,7 @@
"name": "Temperature changed"
},
"crossed_threshold": {
"description": "Triggers when the temperature crosses a threshold.",
"description": "Triggers after one or more temperatures cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::temperature::common::trigger_behavior_description%]",

View File

@@ -80,6 +80,16 @@ LEGACY_FIELDS = {
CONF_VALUE_TEMPLATE: CONF_STATE,
}
SCRIPT_FIELDS = (
CONF_ARM_AWAY_ACTION,
CONF_ARM_CUSTOM_BYPASS_ACTION,
CONF_ARM_HOME_ACTION,
CONF_ARM_NIGHT_ACTION,
CONF_ARM_VACATION_ACTION,
CONF_DISARM_ACTION,
CONF_TRIGGER_ACTION,
)
DEFAULT_NAME = "Template Alarm Control Panel"
ALARM_CONTROL_PANEL_COMMON_SCHEMA = vol.Schema(
@@ -152,6 +162,7 @@ async def async_setup_entry(
StateAlarmControlPanelEntity,
ALARM_CONTROL_PANEL_CONFIG_ENTRY_SCHEMA,
True,
script_options=SCRIPT_FIELDS,
)
@@ -172,6 +183,7 @@ async def async_setup_platform(
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_ALARM_CONTROL_PANELS,
script_options=SCRIPT_FIELDS,
)
@@ -197,6 +209,7 @@ class AbstractTemplateAlarmControlPanel(
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity calls AbstractTemplateEntity.__init__.
def __init__(self, name: str) -> None: # pylint: disable=super-init-not-called
@@ -206,7 +219,6 @@ class AbstractTemplateAlarmControlPanel(
self._attr_code_format = self._config[CONF_CODE_FORMAT].value
self.setup_state_template(
CONF_STATE,
"_attr_alarm_state",
validator=tcv.strenum(self, CONF_STATE, AlarmControlPanelState),
)

View File

@@ -176,6 +176,7 @@ class AbstractTemplateBinarySensor(
"""Representation of a template binary sensor features."""
_entity_id_format = ENTITY_ID_FORMAT
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -189,7 +190,6 @@ class AbstractTemplateBinarySensor(
self._delay_cancel: CALLBACK_TYPE | None = None
self.setup_state_template(
CONF_STATE,
"_attr_is_on",
on_update=self._update_state,
)

View File

@@ -36,6 +36,8 @@ _LOGGER = logging.getLogger(__name__)
DEFAULT_NAME = "Template Button"
DEFAULT_OPTIMISTIC = False
SCRIPT_FIELDS = (CONF_PRESS,)
BUTTON_YAML_SCHEMA = vol.Schema(
{
vol.Required(CONF_PRESS): cv.SCRIPT_SCHEMA,
@@ -66,6 +68,7 @@ async def async_setup_platform(
None,
async_add_entities,
discovery_info,
script_options=SCRIPT_FIELDS,
)
@@ -81,6 +84,7 @@ async def async_setup_entry(
async_add_entities,
StateButtonEntity,
BUTTON_CONFIG_ENTRY_SCHEMA,
script_options=SCRIPT_FIELDS,
)

View File

@@ -71,6 +71,14 @@ CONF_TILT_OPTIMISTIC = "tilt_optimistic"
CONF_OPEN_AND_CLOSE = "open_or_close"
SCRIPT_FIELDS = (
CLOSE_ACTION,
OPEN_ACTION,
POSITION_ACTION,
STOP_ACTION,
TILT_ACTION,
)
TILT_FEATURES = (
CoverEntityFeature.OPEN_TILT
| CoverEntityFeature.CLOSE_TILT
@@ -165,6 +173,7 @@ async def async_setup_platform(
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_COVERS,
script_options=SCRIPT_FIELDS,
)
@@ -181,6 +190,7 @@ async def async_setup_entry(
StateCoverEntity,
COVER_CONFIG_ENTRY_SCHEMA,
True,
script_options=SCRIPT_FIELDS,
)
@@ -205,6 +215,7 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_extra_optimistic_options = (CONF_POSITION,)
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -212,7 +223,6 @@ class AbstractTemplateCover(AbstractTemplateEntity, CoverEntity):
"""Initialize the features."""
self.setup_state_template(
CONF_STATE,
"_attr_current_cover_position",
template_validators.strenum(
self, CONF_STATE, CoverState, CoverState.OPEN, CoverState.CLOSED

View File

@@ -3,10 +3,9 @@
from abc import abstractmethod
from collections.abc import Callable, Sequence
from dataclasses import dataclass
import logging
from typing import Any
from homeassistant.const import CONF_DEVICE_ID, CONF_OPTIMISTIC, CONF_STATE
from homeassistant.const import CONF_DEVICE_ID, CONF_OPTIMISTIC
from homeassistant.core import Context, HomeAssistant, callback
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.entity import Entity, async_generate_entity_id
@@ -16,8 +15,6 @@ from homeassistant.helpers.typing import ConfigType
from .const import CONF_DEFAULT_ENTITY_ID
_LOGGER = logging.getLogger(__name__)
@dataclass
class EntityTemplate:
@@ -36,7 +33,7 @@ class AbstractTemplateEntity(Entity):
_entity_id_format: str
_optimistic_entity: bool = False
_extra_optimistic_options: tuple[str, ...] | None = None
_template: Template | None = None
_state_option: str | None = None
def __init__(
self,
@@ -53,19 +50,18 @@ class AbstractTemplateEntity(Entity):
if self._optimistic_entity:
optimistic = config.get(CONF_OPTIMISTIC)
self._template = config.get(CONF_STATE)
if self._state_option is not None:
assumed_optimistic = config.get(self._state_option) is None
if self._extra_optimistic_options:
assumed_optimistic = assumed_optimistic and all(
config.get(option) is None
for option in self._extra_optimistic_options
)
assumed_optimistic = self._template is None
if self._extra_optimistic_options:
assumed_optimistic = assumed_optimistic and all(
config.get(option) is None
for option in self._extra_optimistic_options
self._attr_assumed_state = optimistic or (
optimistic is None and assumed_optimistic
)
self._attr_assumed_state = optimistic or (
optimistic is None and assumed_optimistic
)
if (default_entity_id := config.get(CONF_DEFAULT_ENTITY_ID)) is not None:
_, _, object_id = default_entity_id.partition(".")
self.entity_id = async_generate_entity_id(
@@ -89,12 +85,16 @@ class AbstractTemplateEntity(Entity):
@abstractmethod
def setup_state_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
) -> None:
"""Set up a template that manages the main state of the entity."""
"""Set up a template that manages the main state of the entity.
Requires _state_option to be set on the inheriting class. _state_option represents
the configuration option that derives the state. E.g. Template weather entities main state option
is 'condition', where switch is 'state'.
"""
@abstractmethod
def setup_template(

View File

@@ -87,6 +87,15 @@ LEGACY_FIELDS = {
DEFAULT_NAME = "Template Fan"
SCRIPT_FIELDS = (
CONF_OFF_ACTION,
CONF_ON_ACTION,
CONF_SET_DIRECTION_ACTION,
CONF_SET_OSCILLATING_ACTION,
CONF_SET_PERCENTAGE_ACTION,
CONF_SET_PRESET_MODE_ACTION,
)
FAN_COMMON_SCHEMA = vol.Schema(
{
vol.Optional(CONF_DIRECTION): cv.template,
@@ -159,6 +168,7 @@ async def async_setup_platform(
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_FANS,
script_options=SCRIPT_FIELDS,
)
@@ -174,6 +184,7 @@ async def async_setup_entry(
async_add_entities,
StateFanEntity,
FAN_CONFIG_ENTRY_SCHEMA,
script_options=SCRIPT_FIELDS,
)
@@ -196,13 +207,13 @@ class AbstractTemplateFan(AbstractTemplateEntity, FanEntity):
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
def __init__(self, name: str, config: dict[str, Any]) -> None: # pylint: disable=super-init-not-called
"""Initialize the features."""
self.setup_state_template(
CONF_STATE,
"_attr_is_on",
template_validators.boolean(self, CONF_STATE),
)

View File

@@ -8,6 +8,7 @@ import logging
from typing import Any
import voluptuous as vol
from voluptuous.humanize import humanize_error
from homeassistant.components import blueprint
from homeassistant.config_entries import ConfigEntry
@@ -25,7 +26,7 @@ from homeassistant.const import (
SERVICE_RELOAD,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import PlatformNotReady
from homeassistant.exceptions import HomeAssistantError, PlatformNotReady
from homeassistant.helpers import issue_registry as ir, template
from homeassistant.helpers.entity import Entity
from homeassistant.helpers.entity_platform import (
@@ -34,6 +35,7 @@ from homeassistant.helpers.entity_platform import (
async_get_platforms,
)
from homeassistant.helpers.issue_registry import IssueSeverity
from homeassistant.helpers.script import async_validate_actions_config
from homeassistant.helpers.script_variables import ScriptVariables
from homeassistant.helpers.singleton import singleton
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
@@ -208,6 +210,21 @@ def _format_template(value: Any, field: str | None = None) -> Any:
return str(value)
def _get_config_breadcrumbs(config: ConfigType) -> str:
"""Try to coerce entity information from the config."""
breadcrumb = "Template Entity"
# Default entity id should be in most legacy configuration because
# it's created from the legacy slug. Vacuum and Lock do not have a
# slug, therefore we need to use the name or unique_id.
if (default_entity_id := config.get(CONF_DEFAULT_ENTITY_ID)) is not None:
breadcrumb = default_entity_id.split(".")[-1]
elif (unique_id := config.get(CONF_UNIQUE_ID)) is not None:
breadcrumb = f"unique_id: {unique_id}"
elif (name := config.get(CONF_NAME)) and isinstance(name, template.Template):
breadcrumb = name.template
return breadcrumb
def format_migration_config(
config: ConfigType | list[ConfigType], depth: int = 0
) -> ConfigType | list[ConfigType]:
@@ -252,16 +269,7 @@ def create_legacy_template_issue(
if domain not in PLATFORMS:
return
breadcrumb = "Template Entity"
# Default entity id should be in most legacy configuration because
# it's created from the legacy slug. Vacuum and Lock do not have a
# slug, therefore we need to use the name or unique_id.
if (default_entity_id := config.get(CONF_DEFAULT_ENTITY_ID)) is not None:
breadcrumb = default_entity_id.split(".")[-1]
elif (unique_id := config.get(CONF_UNIQUE_ID)) is not None:
breadcrumb = f"unique_id: {unique_id}"
elif (name := config.get(CONF_NAME)) and isinstance(name, template.Template):
breadcrumb = name.template
breadcrumb = _get_config_breadcrumbs(config)
issue_id = f"{LEGACY_TEMPLATE_DEPRECATION_KEY}_{domain}_{breadcrumb}_{hashlib.md5(','.join(config.keys()).encode()).hexdigest()}"
@@ -296,6 +304,39 @@ def create_legacy_template_issue(
)
async def validate_template_scripts(
hass: HomeAssistant,
config: ConfigType,
script_options: tuple[str, ...] | None = None,
) -> None:
"""Validate template scripts."""
if not script_options:
return
def _humanize(err: Exception, data: Any) -> str:
"""Humanize vol.Invalid, stringify other exceptions."""
if isinstance(err, vol.Invalid):
return humanize_error(data, err)
return str(err)
breadcrumb: str | None = None
for script_option in script_options:
if (script_config := config.pop(script_option, None)) is not None:
try:
config[script_option] = await async_validate_actions_config(
hass, script_config
)
except (vol.Invalid, HomeAssistantError) as err:
if not breadcrumb:
breadcrumb = _get_config_breadcrumbs(config)
_LOGGER.error(
"The '%s' actions for %s failed to setup: %s",
script_option,
breadcrumb,
_humanize(err, script_config),
)
async def async_setup_template_platform(
hass: HomeAssistant,
domain: str,
@@ -306,6 +347,7 @@ async def async_setup_template_platform(
discovery_info: DiscoveryInfoType | None,
legacy_fields: dict[str, str] | None = None,
legacy_key: str | None = None,
script_options: tuple[str, ...] | None = None,
) -> None:
"""Set up the Template platform."""
if discovery_info is None:
@@ -337,10 +379,14 @@ async def async_setup_template_platform(
# Trigger Configuration
if "coordinator" in discovery_info:
if trigger_entity_cls:
entities = [
trigger_entity_cls(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
]
entities = []
for entity_config in discovery_info["entities"]:
await validate_template_scripts(hass, entity_config, script_options)
entities.append(
trigger_entity_cls(
hass, discovery_info["coordinator"], entity_config
)
)
async_add_entities(entities)
else:
raise PlatformNotReady(
@@ -349,6 +395,9 @@ async def async_setup_template_platform(
return
# Modern Configuration
for entity_config in discovery_info["entities"]:
await validate_template_scripts(hass, entity_config, script_options)
async_create_template_tracking_entities(
state_entity_cls,
async_add_entities,
@@ -365,6 +414,7 @@ async def async_setup_template_entry(
state_entity_cls: type[TemplateEntity],
config_schema: vol.Schema | vol.All,
replace_value_template: bool = False,
script_options: tuple[str, ...] | None = None,
) -> None:
"""Setup the Template from a config entry."""
options = dict(config_entry.options)
@@ -377,6 +427,7 @@ async def async_setup_template_entry(
options[CONF_STATE] = options.pop(CONF_VALUE_TEMPLATE)
validated_config = config_schema(options)
await validate_template_scripts(hass, validated_config, script_options)
async_add_entities(
[state_entity_cls(hass, validated_config, config_entry.entry_id)]

View File

@@ -129,6 +129,18 @@ LEGACY_FIELDS = {
DEFAULT_NAME = "Template Light"
SCRIPT_FIELDS = (
CONF_EFFECT_ACTION,
CONF_HS_ACTION,
CONF_LEVEL_ACTION,
CONF_OFF_ACTION,
CONF_ON_ACTION,
CONF_RGB_ACTION,
CONF_RGBW_ACTION,
CONF_RGBWW_ACTION,
CONF_TEMPERATURE_ACTION,
)
LIGHT_COMMON_SCHEMA = vol.Schema(
{
vol.Inclusive(CONF_EFFECT_ACTION, "effect"): cv.SCRIPT_SCHEMA,
@@ -142,8 +154,6 @@ LIGHT_COMMON_SCHEMA = vol.Schema(
vol.Optional(CONF_MIN_MIREDS): cv.template,
vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA,
vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_RGB_ACTION): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_RGB): cv.template,
vol.Optional(CONF_RGBW_ACTION): cv.SCRIPT_SCHEMA,
@@ -226,6 +236,7 @@ async def async_setup_platform(
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_LIGHTS,
script_options=SCRIPT_FIELDS,
)
@@ -242,6 +253,7 @@ async def async_setup_entry(
StateLightEntity,
LIGHT_CONFIG_ENTRY_SCHEMA,
True,
script_options=SCRIPT_FIELDS,
)
@@ -347,6 +359,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
_optimistic_entity = True
_attr_max_color_temp_kelvin = DEFAULT_MAX_KELVIN
_attr_min_color_temp_kelvin = DEFAULT_MIN_KELVIN
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -357,7 +370,7 @@ class AbstractTemplateLight(AbstractTemplateEntity, LightEntity):
# Setup state and brightness
self.setup_state_template(
CONF_STATE, "_attr_is_on", template_validators.boolean(self, CONF_STATE)
"_attr_is_on", template_validators.boolean(self, CONF_STATE)
)
self.setup_template(
CONF_LEVEL,

View File

@@ -64,6 +64,13 @@ LEGACY_FIELDS = {
CONF_VALUE_TEMPLATE: CONF_STATE,
}
SCRIPT_FIELDS = (
CONF_LOCK,
CONF_OPEN,
CONF_UNLOCK,
)
LOCK_COMMON_SCHEMA = vol.Schema(
{
vol.Optional(CONF_CODE_FORMAT): cv.template,
@@ -112,6 +119,7 @@ async def async_setup_platform(
async_add_entities,
discovery_info,
LEGACY_FIELDS,
script_options=SCRIPT_FIELDS,
)
@@ -127,6 +135,7 @@ async def async_setup_entry(
async_add_entities,
StateLockEntity,
LOCK_CONFIG_ENTRY_SCHEMA,
script_options=SCRIPT_FIELDS,
)
@@ -149,6 +158,7 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -157,7 +167,6 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
self._code_format_template_error: TemplateError | None = None
self.setup_state_template(
CONF_STATE,
"_lock_state",
template_validators.strenum(
self, CONF_STATE, LockState, LockState.LOCKED, LockState.UNLOCKED
@@ -183,16 +192,18 @@ class AbstractTemplateLock(AbstractTemplateEntity, LockEntity):
self._attr_supported_features |= supported_feature
def _set_state(self, state: LockState | None) -> None:
if state is None:
self._attr_is_locked = None
return
self._attr_is_jammed = state == LockState.JAMMED
self._attr_is_opening = state == LockState.OPENING
self._attr_is_locking = state == LockState.LOCKING
self._attr_is_open = state == LockState.OPEN
self._attr_is_unlocking = state == LockState.UNLOCKING
self._attr_is_locked = state == LockState.LOCKED
# All other parameters need to be set False in order
# for the lock to be unknown.
if state is None:
self._attr_is_locked = state
else:
self._attr_is_locked = state == LockState.LOCKED
@callback
def _update_code_format(self, render: str | TemplateError | None):

View File

@@ -46,6 +46,8 @@ CONF_SET_VALUE = "set_value"
DEFAULT_NAME = "Template Number"
DEFAULT_OPTIMISTIC = False
SCRIPT_FIELDS = (CONF_SET_VALUE,)
NUMBER_COMMON_SCHEMA = vol.Schema(
{
vol.Optional(CONF_MAX, default=DEFAULT_MAX_VALUE): cv.template,
@@ -81,6 +83,7 @@ async def async_setup_platform(
TriggerNumberEntity,
async_add_entities,
discovery_info,
script_options=SCRIPT_FIELDS,
)
@@ -96,6 +99,7 @@ async def async_setup_entry(
async_add_entities,
StateNumberEntity,
NUMBER_CONFIG_ENTRY_SCHEMA,
script_options=SCRIPT_FIELDS,
)
@@ -114,6 +118,7 @@ class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity):
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -125,7 +130,6 @@ class AbstractTemplateNumber(AbstractTemplateEntity, NumberEntity):
self._attr_native_max_value = DEFAULT_MAX_VALUE
self.setup_state_template(
CONF_STATE,
"_attr_native_value",
template_validators.number(self, CONF_STATE),
)

View File

@@ -47,6 +47,8 @@ CONF_SELECT_OPTION = "select_option"
DEFAULT_NAME = "Template Select"
SCRIPT_FIELDS = (CONF_SELECT_OPTION,)
SELECT_COMMON_SCHEMA = vol.Schema(
{
vol.Required(ATTR_OPTIONS): cv.template,
@@ -79,6 +81,7 @@ async def async_setup_platform(
TriggerSelectEntity,
async_add_entities,
discovery_info,
script_options=SCRIPT_FIELDS,
)
@@ -94,6 +97,7 @@ async def async_setup_entry(
async_add_entities,
TemplateSelect,
SELECT_CONFIG_ENTRY_SCHEMA,
script_options=SCRIPT_FIELDS,
)
@@ -112,6 +116,7 @@ class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity):
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -120,7 +125,6 @@ class AbstractTemplateSelect(AbstractTemplateEntity, SelectEntity):
self._attr_options = []
self.setup_state_template(
CONF_STATE,
"_attr_current_option",
cv.string,
)

View File

@@ -229,6 +229,7 @@ class AbstractTemplateSensor(AbstractTemplateEntity, RestoreSensor):
"""Representation of a template sensor features."""
_entity_id_format = ENTITY_ID_FORMAT
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -240,7 +241,6 @@ class AbstractTemplateSensor(AbstractTemplateEntity, RestoreSensor):
self._attr_last_reset = None
self.setup_state_template(
CONF_STATE,
"_attr_native_value",
self._validate_state,
)

View File

@@ -57,11 +57,16 @@ LEGACY_FIELDS = {
DEFAULT_NAME = "Template Switch"
SCRIPT_FIELDS = (
CONF_TURN_OFF,
CONF_TURN_ON,
)
SWITCH_COMMON_SCHEMA = vol.Schema(
{
vol.Optional(CONF_STATE): cv.template,
vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_TURN_OFF): cv.SCRIPT_SCHEMA,
vol.Optional(CONF_TURN_ON): cv.SCRIPT_SCHEMA,
}
)
@@ -109,6 +114,7 @@ async def async_setup_platform(
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_SWITCHES,
script_options=SCRIPT_FIELDS,
)
@@ -125,6 +131,7 @@ async def async_setup_entry(
StateSwitchEntity,
SWITCH_CONFIG_ENTRY_SCHEMA,
True,
script_options=SCRIPT_FIELDS,
)
@@ -148,6 +155,7 @@ class AbstractTemplateSwitch(AbstractTemplateEntity, SwitchEntity, RestoreEntity
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -155,7 +163,6 @@ class AbstractTemplateSwitch(AbstractTemplateEntity, SwitchEntity, RestoreEntity
"""Initialize the features."""
self.setup_state_template(
CONF_STATE,
"_attr_is_on",
template_validators.boolean(self, CONF_STATE),
)

View File

@@ -292,12 +292,16 @@ class TemplateEntity(AbstractTemplateEntity):
def setup_state_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
) -> None:
"""Set up a template that manages the main state of the entity."""
"""Set up a template that manages the main state of the entity.
Requires _state_option to be set on the inheriting class. _state_option represents
the configuration option that derives the state. E.g. Template weather entities main state option
is 'condition', where switch is 'state'.
"""
@callback
def _update_state(result: Any) -> None:
@@ -314,13 +318,22 @@ class TemplateEntity(AbstractTemplateEntity):
self._attr_available = True
state = validator(result) if validator else result
if on_update:
on_update(state)
else:
setattr(self, attribute, state)
if self._state_option is None:
raise NotImplementedError(
f"{self.__class__.__name__} does not implement '_state_option' for 'setup_state_template'."
)
self.add_template(
option, attribute, on_update=_update_state, none_on_template_error=False
self._state_option,
attribute,
on_update=_update_state,
none_on_template_error=False,
)
def setup_template(

View File

@@ -5,7 +5,7 @@ from __future__ import annotations
from collections.abc import Callable
from typing import Any
from homeassistant.const import CONF_STATE, CONF_VARIABLES
from homeassistant.const import CONF_VARIABLES
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import TemplateError
from homeassistant.helpers.script_variables import ScriptVariables
@@ -60,17 +60,30 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
def setup_state_template(
self,
option: str,
attribute: str,
validator: Callable[[Any], Any] | None = None,
on_update: Callable[[Any], None] | None = None,
) -> None:
"""Set up a template that manages the main state of the entity."""
"""Set up a template that manages the main state of the entity.
Requires _state_option to be set on the inheriting class. _state_option represents
the configuration option that derives the state. E.g. Template weather entities main state option
is 'condition', where switch is 'state'.
"""
if self._state_option is None:
raise NotImplementedError(
f"{self.__class__.__name__} does not implement '_state_option' for 'setup_state_template'."
)
if self.add_template(
option, attribute, validator, on_update, none_on_template_error=False
self._state_option,
attribute,
validator,
on_update,
none_on_template_error=False,
):
self._to_render_simple.append(option)
self._parse_result.add(option)
self._to_render_simple.append(self._state_option)
self._parse_result.add(self._state_option)
def setup_template(
self,
@@ -149,7 +162,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
# Filter out state templates because they have unique behavior
# with none_on_template_error.
if (
key != CONF_STATE
key != self._state_option
and key in self._templates
and not self._templates[key].none_on_template_error
):
@@ -164,17 +177,21 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
# If state fails to render, the entity should go unavailable. Render the
# state as a simple template because the result should always be a string or None.
if CONF_STATE in self._to_render_simple:
if (
state_option := self._state_option
) is not None and state_option in self._to_render_simple:
if (
result := self._render_single_template(CONF_STATE, variables)
result := self._render_single_template(state_option, variables)
) is _SENTINEL:
self._rendered = self._static_rendered
self._state_render_error = True
return
rendered[CONF_STATE] = result
rendered[state_option] = result
self._render_single_templates(rendered, variables, [CONF_STATE])
self._render_single_templates(
rendered, variables, [state_option] if state_option else []
)
self._render_attributes(rendered, variables)
self._rendered = rendered
@@ -182,6 +199,10 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
"""Get a rendered result and return the value."""
# Handle any templates.
write_state = False
if self._state_render_error:
# The state errored and the entity is unavailable, do not process any values.
return True
for option, entity_template in self._templates.items():
# Capture templates that did not render a result due to an exception and
# ensure the state object updates. _SENTINEL is used to differentiate
@@ -225,18 +246,7 @@ class TriggerEntity( # pylint: disable=hass-enforce-class-module
if self._render_availability_template(variables):
self._render_templates(variables)
write_state = False
# While transitioning platforms to the new framework, this
# if-statement is necessary for backward compatibility with existing
# trigger based platforms.
if self._templates:
# Handle any results that were rendered.
write_state = self._handle_rendered_results()
# Check availability after rendering the results because the state
# template could render the entity unavailable
if not self.available:
write_state = True
write_state = self._handle_rendered_results()
if len(self._rendered) > 0:
# In some cases, the entity may be state optimistic or

View File

@@ -65,6 +65,8 @@ CONF_SPECIFIC_VERSION = "specific_version"
CONF_TITLE = "title"
CONF_UPDATE_PERCENTAGE = "update_percentage"
SCRIPT_FIELDS = (CONF_INSTALL,)
UPDATE_COMMON_SCHEMA = vol.Schema(
{
vol.Optional(CONF_BACKUP, default=False): cv.boolean,
@@ -105,6 +107,7 @@ async def async_setup_platform(
TriggerUpdateEntity,
async_add_entities,
discovery_info,
script_options=SCRIPT_FIELDS,
)
@@ -120,6 +123,7 @@ async def async_setup_entry(
async_add_entities,
StateUpdateEntity,
UPDATE_CONFIG_ENTRY_SCHEMA,
script_options=SCRIPT_FIELDS,
)

View File

@@ -76,6 +76,16 @@ LEGACY_FIELDS = {
CONF_VALUE_TEMPLATE: CONF_STATE,
}
SCRIPT_FIELDS = (
SERVICE_CLEAN_SPOT,
SERVICE_LOCATE,
SERVICE_PAUSE,
SERVICE_RETURN_TO_BASE,
SERVICE_SET_FAN_SPEED,
SERVICE_START,
SERVICE_STOP,
)
VACUUM_COMMON_SCHEMA = vol.Schema(
{
vol.Optional(CONF_BATTERY_LEVEL): cv.template,
@@ -150,6 +160,7 @@ async def async_setup_platform(
discovery_info,
LEGACY_FIELDS,
legacy_key=CONF_VACUUMS,
script_options=SCRIPT_FIELDS,
)
@@ -165,6 +176,7 @@ async def async_setup_entry(
async_add_entities,
TemplateStateVacuumEntity,
VACUUM_CONFIG_ENTRY_SCHEMA,
script_options=SCRIPT_FIELDS,
)
@@ -207,6 +219,7 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
_entity_id_format = ENTITY_ID_FORMAT
_optimistic_entity = True
_state_option = CONF_STATE
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
# This ensures that the __init__ on AbstractTemplateEntity is not called twice.
@@ -216,7 +229,6 @@ class AbstractTemplateVacuum(AbstractTemplateEntity, StateVacuumEntity):
# List of valid fan speeds
self._attr_fan_speed_list = config[CONF_FAN_SPEED_LIST]
self.setup_state_template(
CONF_STATE,
"_attr_activity",
template_validators.strenum(self, CONF_STATE, VacuumActivity),
)

View File

@@ -389,6 +389,7 @@ class AbstractTemplateWeather(AbstractTemplateEntity, WeatherEntity):
"""Representation of a template weathers features."""
_entity_id_format = ENTITY_ID_FORMAT
_state_option = CONF_CONDITION
_optimistic_entity = True
# The super init is not called because TemplateEntity and TriggerEntity will call AbstractTemplateEntity.__init__.
@@ -399,8 +400,7 @@ class AbstractTemplateWeather(AbstractTemplateEntity, WeatherEntity):
"""Initialize the features."""
# Required options
self.setup_template(
CONF_CONDITION,
self.setup_state_template(
"_attr_condition",
template_validators.item_in_list(self, CONF_CONDITION, CONDITION_CLASSES),
)

View File

@@ -240,7 +240,7 @@ class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
"""An entity that represents a To-do list."""
_attr_todo_items: list[TodoItem] | None = None
_update_listeners: list[Callable[[list[TodoItem]], None]] | None = None
_update_listeners: list[Callable[[list[TodoItem] | None], None]] | None = None
@property
def state(self) -> int | None:
@@ -281,7 +281,7 @@ class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
@final
@callback
def async_subscribe_updates(
self, listener: Callable[[list[TodoItem]], None]
self, listener: Callable[[list[TodoItem] | None], None]
) -> CALLBACK_TYPE:
"""Subscribe to To-do list item updates."""
if self._update_listeners is None:
@@ -302,7 +302,12 @@ class TodoListEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_):
if not self._update_listeners:
return
todo_items = [copy.copy(item) for item in self.todo_items or []]
todo_items = (
[copy.copy(item) for item in self.todo_items]
if self.todo_items is not None
else None
)
for listener in self._update_listeners:
listener(todo_items)
@@ -335,14 +340,13 @@ async def websocket_handle_subscribe_todo_items(
return
@callback
def todo_item_listener(todo_items: list[TodoItem]) -> None:
def todo_item_listener(todo_items: list[TodoItem] | None) -> None:
"""Push updated To-do list items to websocket."""
items = [dataclasses.asdict(item) for item in todo_items or []]
connection.send_message(
websocket_api.event_message(
msg["id"],
{
"items": [dataclasses.asdict(item) for item in todo_items],
},
{"items": items},
)
)

View File

@@ -20,5 +20,16 @@
"update_item": {
"service": "mdi:clipboard-edit"
}
},
"triggers": {
"item_added": {
"trigger": "mdi:clipboard-plus"
},
"item_completed": {
"trigger": "mdi:clipboard-check"
},
"item_removed": {
"trigger": "mdi:clipboard-minus"
}
}
}

View File

@@ -98,5 +98,19 @@
"name": "Update item"
}
},
"title": "To-do list"
"title": "To-do list",
"triggers": {
"item_added": {
"description": "Triggers when a to-do item is added to a list.",
"name": "To-do item added"
},
"item_completed": {
"description": "Triggers when a to-do item is marked as done.",
"name": "To-do item completed"
},
"item_removed": {
"description": "Triggers when a to-do item is removed from a list.",
"name": "To-do item removed"
}
}
}

View File

@@ -0,0 +1,293 @@
"""Provides triggers for todo platform."""
import abc
import asyncio
from collections.abc import Callable
from dataclasses import dataclass
import functools
import logging
from typing import TYPE_CHECKING, cast, override
import voluptuous as vol
from homeassistant.const import ATTR_ENTITY_ID, CONF_TARGET
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback, split_entity_id
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.target import TargetEntityChangeTracker, TargetSelection
from homeassistant.helpers.trigger import Trigger, TriggerActionRunner, TriggerConfig
from homeassistant.helpers.typing import ConfigType
from . import TodoItem, TodoListEntity
from .const import DATA_COMPONENT, DOMAIN, TodoItemStatus
ITEM_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
_LOGGER = logging.getLogger(__name__)
def get_entity(hass: HomeAssistant, entity_id: str) -> TodoListEntity:
"""Get the todo entity for the provided entity_id."""
component: EntityComponent[TodoListEntity] = hass.data[DATA_COMPONENT]
if not (entity := component.get_entity(entity_id)) or not isinstance(
entity, TodoListEntity
):
raise HomeAssistantError(
f"Entity does not exist {entity_id} or is not a todo entity"
)
return entity
@dataclass(frozen=True, slots=True)
class TodoItemChangeEvent:
"""Data class for todo item change event."""
entity_id: str
items: list[TodoItem] | None
class ItemChangeListener(TargetEntityChangeTracker):
"""Helper class to listen to todo item changes for target entities."""
def __init__(
self,
hass: HomeAssistant,
target_selection: TargetSelection,
listener: Callable[[TodoItemChangeEvent], None],
) -> None:
"""Initialize the item change tracker."""
def entity_filter(entities: set[str]) -> set[str]:
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == DOMAIN
}
super().__init__(hass, target_selection, entity_filter)
self._listener = listener
self._pending_listener_task: asyncio.Task[None] | None = None
self._unsubscribe_listeners: list[CALLBACK_TYPE] = []
@override
@callback
def _handle_entities_update(self, tracked_entities: set[str]) -> None:
"""Restart the listeners when the list of entities of the tracked targets is updated."""
if self._pending_listener_task:
self._pending_listener_task.cancel()
self._pending_listener_task = self._hass.async_create_task(
self._start_listening(tracked_entities)
)
async def _start_listening(self, tracked_entities: set[str]) -> None:
"""Start listening for todo item changes."""
_LOGGER.debug("Tracking items for todos: %s", tracked_entities)
for unsub in self._unsubscribe_listeners:
unsub()
def _listener_wrapper(entity_id: str, items: list[TodoItem] | None) -> None:
self._listener(TodoItemChangeEvent(entity_id=entity_id, items=items))
self._unsubscribe_listeners = []
for entity_id in tracked_entities:
try:
entity = get_entity(self._hass, entity_id)
except HomeAssistantError:
_LOGGER.debug(
"Skipping entity %s: not found or not a todo entity", entity_id
)
continue
_listener_wrapper(entity_id, entity.todo_items)
unsub = entity.async_subscribe_updates(
functools.partial(_listener_wrapper, entity_id)
)
self._unsubscribe_listeners.append(unsub)
@override
@callback
def _unsubscribe(self) -> None:
"""Unsubscribe from all events."""
super()._unsubscribe()
if self._pending_listener_task:
self._pending_listener_task.cancel()
self._pending_listener_task = None
for unsub in self._unsubscribe_listeners:
unsub()
class ItemTriggerBase(Trigger, abc.ABC):
"""todo item trigger base."""
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, ITEM_TRIGGER_SCHEMA(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.target is not None
self._target = config.target
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach a trigger."""
target_selection = TargetSelection(self._target)
if not target_selection.has_any_target:
raise HomeAssistantError(f"No target defined in {self._target}")
listener = ItemChangeListener(
self._hass,
target_selection,
functools.partial(self._handle_item_change, run_action=run_action),
)
return listener.async_setup()
@callback
@abc.abstractmethod
def _handle_item_change(
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
) -> None:
"""Handle todo item change event."""
class ItemAddedTrigger(ItemTriggerBase):
"""todo item added trigger."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
self._entity_item_ids: dict[str, set[str] | None] = {}
@override
@callback
def _handle_item_change(
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
) -> None:
"""Listen for todo item changes."""
if event.items is None:
self._entity_item_ids[event.entity_id] = None
return
old_item_ids = self._entity_item_ids.get(event.entity_id)
current_item_ids = {item.uid for item in event.items if item.uid is not None}
self._entity_item_ids[event.entity_id] = current_item_ids
if old_item_ids is None:
# Entity just became available, so no old items to compare against
return
added_item_ids = current_item_ids - old_item_ids
if added_item_ids:
_LOGGER.debug(
"Detected added items with ids %s for entity %s",
added_item_ids,
event.entity_id,
)
payload = {
ATTR_ENTITY_ID: event.entity_id,
"item_ids": sorted(added_item_ids),
}
run_action(payload, description="todo item added trigger")
class ItemRemovedTrigger(ItemTriggerBase):
"""todo item removed trigger."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
self._entity_item_ids: dict[str, set[str] | None] = {}
@override
@callback
def _handle_item_change(
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
) -> None:
"""Listen for todo item changes."""
if event.items is None:
self._entity_item_ids[event.entity_id] = None
return
old_item_ids = self._entity_item_ids.get(event.entity_id)
current_item_ids = {item.uid for item in event.items if item.uid is not None}
self._entity_item_ids[event.entity_id] = current_item_ids
if old_item_ids is None:
# Entity just became available, so no old items to compare against
return
removed_item_ids = old_item_ids - current_item_ids
if removed_item_ids:
_LOGGER.debug(
"Detected removed items with ids %s for entity %s",
removed_item_ids,
event.entity_id,
)
payload = {
ATTR_ENTITY_ID: event.entity_id,
"item_ids": sorted(removed_item_ids),
}
run_action(payload, description="todo item removed trigger")
class ItemCompletedTrigger(ItemTriggerBase):
"""todo item completed trigger."""
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize trigger."""
super().__init__(hass, config)
self._entity_completed_item_ids: dict[str, set[str] | None] = {}
@override
@callback
def _handle_item_change(
self, event: TodoItemChangeEvent, run_action: TriggerActionRunner
) -> None:
"""Listen for todo item changes."""
if event.items is None:
self._entity_completed_item_ids[event.entity_id] = None
return
old_item_ids = self._entity_completed_item_ids.get(event.entity_id)
current_item_ids = {
item.uid
for item in event.items
if item.uid is not None and item.status == TodoItemStatus.COMPLETED
}
self._entity_completed_item_ids[event.entity_id] = current_item_ids
if old_item_ids is None:
# Entity just became available, so no old items to compare against
return
new_completed_item_ids = current_item_ids - old_item_ids
if new_completed_item_ids:
_LOGGER.debug(
"Detected new completed items with ids %s for entity %s",
new_completed_item_ids,
event.entity_id,
)
payload = {
ATTR_ENTITY_ID: event.entity_id,
"item_ids": sorted(new_completed_item_ids),
}
run_action(payload, description="todo item completed trigger")
TRIGGERS: dict[str, type[Trigger]] = {
"item_added": ItemAddedTrigger,
"item_completed": ItemCompletedTrigger,
"item_removed": ItemRemovedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for todo platform."""
return TRIGGERS

View File

@@ -0,0 +1,8 @@
.trigger_common: &trigger_common
target:
entity:
domain: todo
item_added: *trigger_common
item_completed: *trigger_common
item_removed: *trigger_common

View File

@@ -1 +1,46 @@
"""The touchline component."""
from __future__ import annotations
import logging
from pytouchline_extended import PyTouchline
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .data import TouchlineConfigEntry, TouchlineData
PLATFORMS = [Platform.CLIMATE]
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: TouchlineConfigEntry) -> bool:
"""Set up touchline from a config entry."""
host = entry.data[CONF_HOST]
_LOGGER.debug(
"Touchline entry id: %s Unique id: %s", entry.entry_id, entry.unique_id
)
py_touchline = PyTouchline(url=host)
try:
number_of_devices = int(
await hass.async_add_executor_job(py_touchline.get_number_of_devices)
)
except (OSError, ConnectionError, TimeoutError) as err:
raise ConfigEntryNotReady(
f"Error while connecting to Touchline controller at {host}"
) from err
entry.runtime_data = TouchlineData(
touchline=py_touchline, number_of_devices=number_of_devices
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a touchline config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -13,12 +13,20 @@ from homeassistant.components.climate import (
ClimateEntityFeature,
HVACMode,
)
from homeassistant.config_entries import SOURCE_IMPORT
from homeassistant.const import ATTR_TEMPERATURE, CONF_HOST, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity_platform import AddEntitiesCallback
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
from homeassistant.helpers.entity_platform import (
AddConfigEntryEntitiesCallback,
AddEntitiesCallback,
)
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import DOMAIN
from .data import TouchlineConfigEntry
class PresetMode(NamedTuple):
"""Settings for preset mode."""
@@ -44,22 +52,73 @@ TOUCHLINE_HA_PRESETS = {
PLATFORM_SCHEMA = CLIMATE_PLATFORM_SCHEMA.extend({vol.Required(CONF_HOST): cv.string})
def setup_platform(
async def async_setup_entry(
hass: HomeAssistant,
config: ConfigType,
add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
entry: TouchlineConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Touchline devices."""
"""Set up Touchline devices from a config entry."""
host = entry.data[CONF_HOST]
host = config[CONF_HOST]
py_touchline = PyTouchline(url=host)
number_of_devices = int(py_touchline.get_number_of_devices())
devices = [
Touchline(PyTouchline(id=device_id, url=host))
for device_id in range(number_of_devices)
for device_id in range(entry.runtime_data.number_of_devices)
]
add_entities(devices, True)
async_add_entities(devices, True)
async def async_setup_platform(
hass: HomeAssistant,
config: ConfigType,
async_add_entities: AddEntitiesCallback,
discovery_info: DiscoveryInfoType | None = None,
) -> None:
"""Set up the Touchline devices from YAML.
Touchline now uses config entries. If an entry exists in configuration.yaml,
the import flow will attempt to import it and create a config entry.
"""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_IMPORT},
data={CONF_HOST: config[CONF_HOST]},
)
if (
result.get("type") is FlowResultType.ABORT
and result.get("reason") != "already_configured"
):
ir.async_create_issue(
hass,
DOMAIN,
f"deprecated_yaml_import_issue_{result.get('reason')}",
breaks_in_ha_version="2026.10.0",
is_fixable=False,
is_persistent=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key=f"deprecated_yaml_import_issue_{result.get('reason')}",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Roth Touchline",
},
)
return
ir.async_create_issue(
hass,
HOMEASSISTANT_DOMAIN,
f"deprecated_yaml_{DOMAIN}",
breaks_in_ha_version="2026.10.0",
is_fixable=False,
is_persistent=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_yaml",
translation_placeholders={
"domain": DOMAIN,
"integration_title": "Roth Touchline",
},
)
class Touchline(ClimateEntity):

View File

@@ -0,0 +1,110 @@
"""Config flow for Roth Touchline integration."""
from __future__ import annotations
import logging
from typing import Any
from pytouchline_extended import PyTouchline
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import config_validation as cv
from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
STEP_USER_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_HOST): cv.string,
}
)
def fetch_unique_id(host: str) -> str:
"""Fetch the unique id for the Touchline controller."""
client = PyTouchline(url=host)
client.get_number_of_devices()
client.update()
return str(client.get_controller_id())
async def _async_validate_input(hass: HomeAssistant, data: dict[str, Any]) -> str:
"""Validate the user input allows us to connect."""
host = data[CONF_HOST]
try:
return await hass.async_add_executor_job(fetch_unique_id, host)
except (OSError, ConnectionError, TimeoutError) as err:
_LOGGER.debug(
"Error while connecting to Touchline controller at %s", host, exc_info=True
)
raise CannotConnect from err
class CannotConnect(HomeAssistantError):
"""Error to indicate we cannot connect."""
class TouchlineConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Roth Touchline."""
VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
errors: dict[str, str] = {}
if user_input is not None:
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
try:
unique_id = await _async_validate_input(self.hass, user_input)
except CannotConnect:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
if not errors:
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_HOST], data=user_input
)
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_DATA_SCHEMA,
errors=errors,
)
async def async_step_import(self, user_input: dict[str, Any]) -> ConfigFlowResult:
"""Handle import from YAML."""
# Abort if an entry with the same host already exists, to avoid duplicates
self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]})
# Validate the user input allows us to connect
try:
unique_id = await _async_validate_input(self.hass, user_input)
except CannotConnect:
return self.async_abort(reason="cannot_connect")
except Exception: # noqa: BLE001
return self.async_abort(reason="unknown")
await self.async_set_unique_id(unique_id)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=user_input[CONF_HOST],
data=user_input,
)

View File

@@ -0,0 +1,3 @@
"""Constants for the Roth Touchline integration."""
DOMAIN = "touchline"

View File

@@ -0,0 +1,19 @@
"""Custom types for Touchline."""
from __future__ import annotations
from dataclasses import dataclass
from pytouchline_extended import PyTouchline
from homeassistant.config_entries import ConfigEntry
type TouchlineConfigEntry = ConfigEntry[TouchlineData]
@dataclass
class TouchlineData:
"""Runtime data for the Touchline integration."""
touchline: PyTouchline
number_of_devices: int

View File

@@ -2,9 +2,10 @@
"domain": "touchline",
"name": "Roth Touchline",
"codeowners": [],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/touchline",
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["pytouchline"],
"quality_scale": "legacy",
"loggers": ["pytouchline_extended"],
"requirements": ["pytouchline_extended==0.4.5"]
}

View File

@@ -0,0 +1,31 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The hostname or IP address of your Roth Touchline controller."
}
}
}
},
"issues": {
"deprecated_yaml_import_issue_cannot_connect": {
"description": "Home Assistant could not connect to the Roth Touchline controller while importing your YAML configuration. Remove the YAML configuration for Roth Touchline from configuration.yaml and set up the integration again from the Home Assistant UI.",
"title": "Roth Touchline YAML configuration import failed"
},
"deprecated_yaml_import_issue_unknown": {
"description": "An unknown error occurred while importing your Roth Touchline YAML configuration. Remove the YAML configuration for Roth Touchline from configuration.yaml and set up the integration again from the Home Assistant UI.",
"title": "Roth Touchline YAML configuration import issue"
}
}
}

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["tplink-omada-client==1.5.3"]
"requirements": ["tplink-omada-client==1.5.6"]
}

View File

@@ -10,7 +10,7 @@ from steamloop import (
from homeassistant.const import CONF_HOST, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr
from .const import CONF_SECRET_KEY, DOMAIN, MANUFACTURER
@@ -37,7 +37,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TraneConfigEntry) -> boo
) from err
except AuthenticationError as err:
await conn.disconnect()
raise ConfigEntryAuthFailed(
raise ConfigEntryError(
translation_domain=DOMAIN,
translation_key="authentication_failed",
) from err

View File

@@ -121,9 +121,7 @@ class TuyaAlarmEntity(TuyaEntity, AlarmControlPanelEntity):
state_wrapper: DeviceWrapper[TuyaAlarmControlPanelState],
) -> None:
"""Init Tuya Alarm."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
super().__init__(device, device_manager, description)
self._action_wrapper = action_wrapper
self._changed_by_wrapper = changed_by_wrapper
self._state_wrapper = state_wrapper

View File

@@ -449,9 +449,7 @@ class TuyaBinarySensorEntity(TuyaEntity, BinarySensorEntity):
dpcode_wrapper: DeviceWrapper[bool],
) -> None:
"""Init Tuya binary sensor."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
super().__init__(device, device_manager, description)
self._dpcode_wrapper = dpcode_wrapper
@property

View File

@@ -111,9 +111,7 @@ class TuyaButtonEntity(TuyaEntity, ButtonEntity):
dpcode_wrapper: DeviceWrapper[bool],
) -> None:
"""Init Tuya button."""
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
super().__init__(device, device_manager, description)
self._dpcode_wrapper = dpcode_wrapper
async def async_press(self) -> None:

View File

@@ -79,27 +79,27 @@ class TuyaClimateEntityDescription(ClimateEntityDescription):
CLIMATE_DESCRIPTIONS: dict[DeviceCategory, TuyaClimateEntityDescription] = {
DeviceCategory.DBL: TuyaClimateEntityDescription(
key="dbl",
key="",
switch_only_hvac_mode=HVACMode.HEAT,
),
DeviceCategory.KT: TuyaClimateEntityDescription(
key="kt",
key="",
switch_only_hvac_mode=HVACMode.COOL,
),
DeviceCategory.QN: TuyaClimateEntityDescription(
key="qn",
key="",
switch_only_hvac_mode=HVACMode.HEAT,
),
DeviceCategory.RS: TuyaClimateEntityDescription(
key="rs",
key="",
switch_only_hvac_mode=HVACMode.HEAT,
),
DeviceCategory.WK: TuyaClimateEntityDescription(
key="wk",
key="",
switch_only_hvac_mode=HVACMode.HEAT_COOL,
),
DeviceCategory.WKF: TuyaClimateEntityDescription(
key="wkf",
key="",
switch_only_hvac_mode=HVACMode.HEAT,
),
}
@@ -258,6 +258,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
entity_description: TuyaClimateEntityDescription
_attr_name = None
_attr_target_temperature_step = 1.0
def __init__(
self,
@@ -277,10 +278,7 @@ class TuyaClimateEntity(TuyaEntity, ClimateEntity):
temperature_unit: UnitOfTemperature,
) -> None:
"""Determine which values to use."""
self._attr_target_temperature_step = 1.0
self.entity_description = description
super().__init__(device, device_manager)
super().__init__(device, device_manager, description)
self._current_humidity_wrapper = current_humidity_wrapper
self._current_temperature = current_temperature_wrapper
self._fan_mode_wrapper = fan_mode_wrapper

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