Compare commits

..

40 Commits

Author SHA1 Message Date
Ludovic BOUÉ
f71ed51a1a Merge branch 'dev' into PIRUnoccupiedToOccupiedDelay 2026-03-23 17:28:48 +01:00
Ludovic BOUÉ
9cadf32e36 Update snapshot aliases from set to list for consistency in test_binary_sensor, test_button, and test_number 2026-03-19 06:57:04 +00:00
Ludovic BOUÉ
d44387e36b Merge branch 'dev' into PIRUnoccupiedToOccupiedDelay 2026-03-18 18:45:18 +01:00
Ludovic BOUÉ
1824ef12bb Merge branch 'dev' into PIRUnoccupiedToOccupiedDelay 2026-03-14 08:34:44 +01:00
Ludovic BOUÉ
c706e8a5b8 Merge branch 'dev' into PIRUnoccupiedToOccupiedDelay 2026-03-13 18:56:24 +01:00
Ludovic BOUÉ
5bd9742eb3 Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-03-13 18:47:52 +01:00
Ludovic BOUÉ
26f3eb5f6d Update snapshots 2026-03-13 17:41:22 +00:00
Ludovic BOUÉ
7a34d4f881 Merge branch 'dev' into PIRUnoccupiedToOccupiedDelay 2026-03-13 18:33:41 +01:00
Ludovic BOUÉ
e0a37a5eeb Merge branch 'dev' into PIRUnoccupiedToOccupiedDelay 2026-03-11 11:52:51 +01:00
Ludovic BOUÉ
ec3d1fd72c Merge branch 'dev' into PIRUnoccupiedToOccupiedDelay 2026-03-07 18:07:37 +01:00
Ludovic BOUÉ
4edea21cb7 Update unique_id in snapshots 2026-03-06 16:13:57 +00:00
Ludovic BOUÉ
7f065c1942 Add allow_multi attribute to occupancy sensing discovery schemas 2026-03-06 15:58:04 +00:00
Ludovic BOUÉ
46ce07a9a1 Update mock occupancy sensor fixture OccupancySensing revision attribute 2026-03-06 15:49:17 +00:00
Ludovic BOUÉ
5807db2c60 Add HoldTime and HoldTimeLimits attributes to mock occupancy sensor fixture for conformance 2026-03-06 15:46:12 +00:00
Ludovic BOUÉ
85732543b2 Update node_id in mock occupancy sensor fixture to match expected value 2026-03-06 15:33:35 +00:00
Ludovic BOUÉ
054c61d73f Merge branch 'dev' into PIRUnoccupiedToOccupiedDelay 2026-03-06 16:20:39 +01:00
Ludovic BOUÉ
be2c20c624 Add HoldTime attribute to occupancy sensing discovery schema 2026-03-06 15:19:28 +00:00
Ludovic BOUÉ
706127c9ea Add mock_occupancy_sensor_pir to common.py 2026-02-12 11:13:19 +01:00
Ludovic BOUÉ
b163829970 Merge branch 'dev' into PIRUnoccupiedToOccupiedDelay 2026-02-12 10:49:35 +01:00
Ludovic BOUÉ
7a93eb779c Merge branch 'dev' into PIRUnoccupiedToOccupiedDelay 2026-02-11 16:30:17 +01:00
Ludovic BOUÉ
7d673cd9c4 Update occupancy sensing PIR attributes for detection delay and threshold 2026-02-07 09:49:56 +00:00
Ludovic BOUÉ
44bc11580d Rename occupancy sensor attributes for clarity and update tests 2026-02-07 09:49:14 +00:00
Ludovic BOUÉ
c23795fe14 Rename occupancy sensing keys to include PIR prefix for clarity 2026-02-07 09:45:46 +00:00
Ludovic BOUÉ
bf6f9a011b Rename occupancy sensing translation keys and add new entries for detection delay and threshold 2026-02-07 09:44:30 +00:00
Ludovic BOUÉ
1cdbe596fe Update snapshots 2026-02-06 17:29:30 +00:00
Ludovic BOUÉ
a9d52bfbe7 Remove feature map attribute from occupancy sensing discovery schema 2026-02-06 17:24:51 +00:00
Ludovic BOUÉ
6eed1f9961 Update snapshots 2026-02-06 17:06:27 +00:00
Ludovic BOUÉ
149607ab17 Refactor strings.json: Remove duplicate unoccupied to occupied delay entries and standardize casing for threshold name 2026-02-06 17:04:42 +00:00
Ludovic BOUÉ
279b5be357 Add assertions for min, max, and unit_of_measurement in occupancy sensor tests 2026-02-06 17:02:19 +00:00
Ludovic BOUÉ
82b93e788b Update snapshots 2026-02-06 16:58:16 +00:00
Ludovic BOUÉ
555813f84f Move PIRUnoccupiedToOccupiedDelay before 2026-02-06 16:58:16 +00:00
Ludovic BOUÉ
ecf1b4e591 Fix occupancy sensor threshold test assertion to match updated mock data 2026-02-06 16:58:15 +00:00
Ludovic BOUÉ
e17a9f12a1 Rename occupancy sensor state and entity IDs for clarity in PIR tests 2026-02-06 16:58:15 +00:00
Ludovic BOUÉ
e8f05f5291 Update snapshots 2026-02-06 16:58:15 +00:00
Ludovic BOUÉ
a5a76e9268 Add mock occupancy sensor JSON fixture 2026-02-06 16:58:15 +00:00
Ludovic BOUÉ
edc3fb47b2 Réorganiser les chaînes pour le délai et le seuil de passage de l'état inoccupé à occupé 2026-02-06 16:58:15 +00:00
Ludovic BOUÉ
f1e514a70a Update homeassistant/components/matter/strings.json
Co-authored-by: Norbert Rittel <norbert@rittel.de>
2026-02-06 17:52:35 +01:00
Ludovic BOUÉ
5632baca5b Merge branch 'dev' into PIRUnoccupiedToOccupiedDelay 2026-02-06 17:08:24 +01:00
Ludovic BOUÉ
78f9bad706 PIRUnoccupiedToOccupiedDelay attribute 2026-02-06 16:00:18 +00:00
Ludovic BOUÉ
3fdaaecd0f PIRUnoccupiedToOccupied attributes 2026-02-04 13:01:13 +00:00
249 changed files with 1849 additions and 16917 deletions

View File

@@ -224,6 +224,7 @@ jobs:
matrix:
machine:
- generic-x86-64
- intel-nuc
- khadas-vim3
- odroid-c2
- odroid-c4
@@ -247,6 +248,10 @@ jobs:
- machine: qemux86-64
arch: amd64
runs-on: ubuntu-24.04
# TODO: remove, intel-nuc is a legacy name for x86-64, renamed in 2021
- machine: intel-nuc
arch: amd64
runs-on: ubuntu-24.04
steps:
- name: Checkout the repository
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2

8
CODEOWNERS generated
View File

@@ -214,8 +214,6 @@ build.json @home-assistant/supervisor
/tests/components/balboa/ @garbled1 @natekspencer
/homeassistant/components/bang_olufsen/ @mj23000
/tests/components/bang_olufsen/ @mj23000
/homeassistant/components/battery/ @home-assistant/core
/tests/components/battery/ @home-assistant/core
/homeassistant/components/bayesian/ @HarvsG
/tests/components/bayesian/ @HarvsG
/homeassistant/components/beewi_smartclim/ @alemuro
@@ -786,8 +784,6 @@ build.json @home-assistant/supervisor
/tests/components/igloohome/ @keithle888
/homeassistant/components/ign_sismologia/ @exxamalte
/tests/components/ign_sismologia/ @exxamalte
/homeassistant/components/illuminance/ @home-assistant/core
/tests/components/illuminance/ @home-assistant/core
/homeassistant/components/image/ @home-assistant/core
/tests/components/image/ @home-assistant/core
/homeassistant/components/image_processing/ @home-assistant/core
@@ -1313,8 +1309,6 @@ build.json @home-assistant/supervisor
/tests/components/poolsense/ @haemishkyd
/homeassistant/components/portainer/ @erwindouna
/tests/components/portainer/ @erwindouna
/homeassistant/components/power/ @home-assistant/core
/tests/components/power/ @home-assistant/core
/homeassistant/components/powerfox/ @klaasnicolaas
/tests/components/powerfox/ @klaasnicolaas
/homeassistant/components/powerfox_local/ @klaasnicolaas
@@ -1600,8 +1594,6 @@ build.json @home-assistant/supervisor
/homeassistant/components/solaredge_local/ @drobtravels @scheric
/homeassistant/components/solarlog/ @Ernst79 @dontinelli
/tests/components/solarlog/ @Ernst79 @dontinelli
/homeassistant/components/solarman/ @solarmanpv
/tests/components/solarman/ @solarmanpv
/homeassistant/components/solax/ @squishykid @Darsstar
/tests/components/solax/ @squishykid @Darsstar
/homeassistant/components/soma/ @ratsept

View File

@@ -241,16 +241,12 @@ DEFAULT_INTEGRATIONS = {
*BASE_PLATFORMS,
#
# Integrations providing triggers and conditions for base platforms:
"air_quality",
"battery",
"door",
"garage_door",
"gate",
"humidity",
"illuminance",
"motion",
"occupancy",
"power",
"temperature",
"window",
}

View File

@@ -3,103 +3,5 @@
"_": {
"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

@@ -1,626 +0,0 @@
{
"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

@@ -1,238 +0,0 @@
"""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,
make_entity_target_state_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."""
return make_entity_target_state_trigger(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_ON
)
def _make_cleared_trigger(
device_class: BinarySensorDeviceClass,
) -> type[EntityTargetStateTriggerBase]:
"""Create a cleared trigger for a binary sensor device class."""
return make_entity_target_state_trigger(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=device_class)}, STATE_OFF
)
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

@@ -1,692 +0,0 @@
.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

@@ -115,7 +115,6 @@ def async_setup(
) -> None:
"""Component to allow users to login."""
hass.http.register_view(WellKnownOAuthInfoView)
hass.http.register_view(WellKnownProtectedResourceView)
hass.http.register_view(AuthProvidersView)
hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow, store_result))
hass.http.register_view(LoginFlowResourceView(hass.auth.login_flow, store_result))
@@ -155,32 +154,6 @@ class WellKnownOAuthInfoView(HomeAssistantView):
return self.json(metadata)
class WellKnownProtectedResourceView(HomeAssistantView):
"""View to host the OAuth2 Protected Resource Metadata per RFC9728."""
requires_auth = False
url = "/.well-known/oauth-protected-resource"
name = "well-known/oauth-protected-resource"
async def get(self, request: web.Request) -> web.Response:
"""Return the protected resource metadata."""
hass = request.app[KEY_HASS]
try:
url_prefix = get_url(hass, require_current_request=True)
except NoURLAvailableError:
return self.json_message("No URL available", HTTPStatus.NOT_FOUND)
return self.json(
{
"resource": url_prefix,
"authorization_servers": [url_prefix],
"resource_documentation": (
"https://developers.home-assistant.io/docs/auth_api"
),
}
)
class AuthProvidersView(HomeAssistantView):
"""View to get available auth providers."""

View File

@@ -120,7 +120,6 @@ NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
_EXPERIMENTAL_CONDITION_PLATFORMS = {
"alarm_control_panel",
"assist_satellite",
"battery",
"climate",
"cover",
"device_tracker",
@@ -139,13 +138,11 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
"schedule",
"siren",
"switch",
"text",
"vacuum",
"window",
}
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
"air_quality",
"alarm_control_panel",
"assist_satellite",
"button",
@@ -153,13 +150,11 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"cover",
"device_tracker",
"door",
"event",
"fan",
"garage_door",
"gate",
"humidifier",
"humidity",
"illuminance",
"lawn_mower",
"light",
"lock",
@@ -167,7 +162,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"motion",
"occupancy",
"person",
"power",
"remote",
"scene",
"schedule",
@@ -178,7 +172,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"text",
"update",
"vacuum",
"water_heater",
"window",
}

View File

@@ -147,7 +147,7 @@ class S3BackupAgent(BackupAgent):
if backup.size < MULTIPART_MIN_PART_SIZE_BYTES:
await self._upload_simple(tar_filename, open_stream)
else:
await self._upload_multipart(tar_filename, open_stream, on_progress)
await self._upload_multipart(tar_filename, open_stream)
# Upload the metadata file
metadata_content = json.dumps(backup.as_dict())
@@ -188,13 +188,11 @@ class S3BackupAgent(BackupAgent):
self,
tar_filename: str,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
on_progress: OnProgressCallback,
) -> None:
):
"""Upload a large file using multipart upload.
:param tar_filename: The target filename for the backup.
:param open_stream: A function returning an async iterator that yields bytes.
:param on_progress: A callback to report the number of uploaded bytes.
"""
_LOGGER.debug("Starting multipart upload for %s", tar_filename)
multipart_upload = await self._client.create_multipart_upload(
@@ -207,7 +205,6 @@ class S3BackupAgent(BackupAgent):
part_number = 1
buffer = bytearray() # bytes buffer to store the data
offset = 0 # start index of unread data inside buffer
bytes_uploaded = 0
stream = await open_stream()
async for chunk in stream:
@@ -236,8 +233,6 @@ class S3BackupAgent(BackupAgent):
Body=part_data.tobytes(),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
bytes_uploaded += len(part_data)
on_progress(bytes_uploaded=bytes_uploaded)
part_number += 1
finally:
view.release()
@@ -266,8 +261,6 @@ class S3BackupAgent(BackupAgent):
Body=remaining_data.tobytes(),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
bytes_uploaded += len(remaining_data)
on_progress(bytes_uploaded=bytes_uploaded)
await cast(Any, self._client).complete_multipart_upload(
Bucket=self._bucket,

View File

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

View File

@@ -1,48 +0,0 @@
"""Provides conditions for batteries."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import PERCENTAGE, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
Condition,
make_entity_numerical_condition,
make_entity_state_condition,
)
BATTERY_DOMAIN_SPECS = {
BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.BATTERY)
}
BATTERY_CHARGING_DOMAIN_SPECS = {
BINARY_SENSOR_DOMAIN: DomainSpec(
device_class=BinarySensorDeviceClass.BATTERY_CHARGING
)
}
BATTERY_PERCENTAGE_DOMAIN_SPECS = {
SENSOR_DOMAIN: DomainSpec(device_class=SensorDeviceClass.BATTERY),
NUMBER_DOMAIN: DomainSpec(device_class=NumberDeviceClass.BATTERY),
}
CONDITIONS: dict[str, type[Condition]] = {
"is_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_ON),
"is_not_low": make_entity_state_condition(BATTERY_DOMAIN_SPECS, STATE_OFF),
"is_charging": make_entity_state_condition(BATTERY_CHARGING_DOMAIN_SPECS, STATE_ON),
"is_not_charging": make_entity_state_condition(
BATTERY_CHARGING_DOMAIN_SPECS, STATE_OFF
),
"is_level": make_entity_numerical_condition(
BATTERY_PERCENTAGE_DOMAIN_SPECS, PERCENTAGE
),
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the conditions for batteries."""
return CONDITIONS

View File

@@ -1,66 +0,0 @@
.condition_common: &condition_common
target: &target_battery_binary_sensor
entity:
- domain: binary_sensor
device_class: battery
fields:
behavior: &condition_behavior
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
.number_or_entity: &number_or_entity
required: false
selector:
choose:
choices:
number:
selector:
number:
unit_of_measurement: "%"
entity:
selector:
entity:
filter:
domain:
- input_number
- number
- sensor
translation_key: number_or_entity
is_low: *condition_common
is_not_low: *condition_common
is_charging:
target:
entity:
- domain: binary_sensor
device_class: battery_charging
fields:
behavior: *condition_behavior
is_not_charging:
target:
entity:
- domain: binary_sensor
device_class: battery_charging
fields:
behavior: *condition_behavior
is_level:
target:
entity:
- domain: sensor
device_class: battery
- domain: number
device_class: battery
fields:
behavior: *condition_behavior
above: *number_or_entity
below: *number_or_entity

View File

@@ -1,19 +0,0 @@
{
"conditions": {
"is_charging": {
"condition": "mdi:battery-charging"
},
"is_level": {
"condition": "mdi:battery-unknown"
},
"is_low": {
"condition": "mdi:battery-alert"
},
"is_not_charging": {
"condition": "mdi:battery"
},
"is_not_low": {
"condition": "mdi:battery"
}
}
}

View File

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

View File

@@ -1,81 +0,0 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted batteries.",
"condition_behavior_name": "Behavior"
},
"conditions": {
"is_charging": {
"description": "Tests if one or more batteries are charging.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
"name": "Battery is charging"
},
"is_level": {
"description": "Tests the battery level of one or more batteries.",
"fields": {
"above": {
"description": "Require the battery percentage to be above this value.",
"name": "Above"
},
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
},
"below": {
"description": "Require the battery percentage to be below this value.",
"name": "Below"
}
},
"name": "Battery level"
},
"is_low": {
"description": "Tests if one or more batteries are low.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
"name": "Battery is low"
},
"is_not_charging": {
"description": "Tests if one or more batteries are not charging.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
"name": "Battery is not charging"
},
"is_not_low": {
"description": "Tests if one or more batteries are not low.",
"fields": {
"behavior": {
"description": "[%key:component::battery::common::condition_behavior_description%]",
"name": "[%key:component::battery::common::condition_behavior_name%]"
}
},
"name": "Battery is not low"
}
},
"selector": {
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"number_or_entity": {
"choices": {
"entity": "Entity",
"number": "Number"
}
}
},
"title": "Battery"
}

View File

@@ -1,31 +1,10 @@
"""Provides conditions for climates."""
from homeassistant.const import ATTR_TEMPERATURE, UnitOfTemperature
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.condition import (
Condition,
EntityNumericalConditionWithUnitBase,
make_entity_numerical_condition,
make_entity_state_condition,
)
from homeassistant.util.unit_conversion import TemperatureConverter
from .const import ATTR_HUMIDITY, ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
class ClimateTargetTemperatureCondition(EntityNumericalConditionWithUnitBase):
"""Mixin for climate target temperature conditions with unit conversion."""
_base_unit = UnitOfTemperature.CELSIUS
_domain_specs = {DOMAIN: NumericalDomainSpec(value_source=ATTR_TEMPERATURE)}
_unit_converter = TemperatureConverter
def _get_entity_unit(self, entity_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
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import Condition, make_entity_state_condition
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONDITIONS: dict[str, type[Condition]] = {
"is_off": make_entity_state_condition(DOMAIN, HVACMode.OFF),
@@ -49,11 +28,6 @@ CONDITIONS: dict[str, type[Condition]] = {
"is_heating": make_entity_state_condition(
{DOMAIN: DomainSpec(value_source=ATTR_HVAC_ACTION)}, HVACAction.HEATING
),
"target_humidity": make_entity_numerical_condition(
{DOMAIN: NumericalDomainSpec(value_source=ATTR_HUMIDITY)},
valid_unit="%",
),
"target_temperature": ClimateTargetTemperatureCondition,
}

View File

@@ -1,9 +1,9 @@
.condition_common: &condition_common
target: &condition_climate_target
target:
entity:
domain: climate
fields:
behavior: &condition_behavior
behavior:
required: true
default: any
selector:
@@ -13,76 +13,8 @@
- all
- any
.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:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "°C"
- "°F"
- domain: sensor
device_class: temperature
- domain: number
device_class: temperature
translation_key: number_or_entity
.condition_unit_temperature: &condition_unit_temperature
required: false
selector:
select:
options:
- "°C"
- "°F"
is_off: *condition_common
is_on: *condition_common
is_cooling: *condition_common
is_drying: *condition_common
is_heating: *condition_common
target_humidity:
target: *condition_climate_target
fields:
behavior: *condition_behavior
above: *number_or_entity_humidity
below: *number_or_entity_humidity
target_temperature:
target: *condition_climate_target
fields:
behavior: *condition_behavior
above: *number_or_entity_temperature
below: *number_or_entity_temperature
unit: *condition_unit_temperature

View File

@@ -14,12 +14,6 @@
},
"is_on": {
"condition": "mdi:power-on"
},
"target_humidity": {
"condition": "mdi:water-percent"
},
"target_temperature": {
"condition": "mdi:thermometer"
}
},
"entity_component": {

View File

@@ -55,46 +55,6 @@
}
},
"name": "Climate-control device is on"
},
"target_humidity": {
"description": "Tests the humidity setpoint of one or more climate-control devices.",
"fields": {
"above": {
"description": "Require the target humidity to be above this value.",
"name": "Above"
},
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"below": {
"description": "Require the target humidity to be below this value.",
"name": "Below"
}
},
"name": "Climate-control device target humidity"
},
"target_temperature": {
"description": "Tests the temperature setpoint of one or more climate-control devices.",
"fields": {
"above": {
"description": "Require the target temperature to be above this value.",
"name": "Above"
},
"behavior": {
"description": "[%key:component::climate::common::condition_behavior_description%]",
"name": "[%key:component::climate::common::condition_behavior_name%]"
},
"below": {
"description": "Require the target temperature to be below this value.",
"name": "Below"
},
"unit": {
"description": "All values will be converted to this unit when evaluating the condition.",
"name": "Unit of measurement"
}
},
"name": "Climate-control device target temperature"
}
},
"device_automation": {

View File

@@ -138,7 +138,6 @@ class CloudBackupAgent(BackupAgent):
base64md5hash=base64md5hash,
metadata=metadata,
size=size,
on_progress=on_progress,
)
break
except CloudApiNonRetryableError as err:

View File

@@ -144,7 +144,7 @@ class R2BackupAgent(BackupAgent):
if backup.size < MULTIPART_MIN_PART_SIZE_BYTES:
await self._upload_simple(tar_filename, open_stream)
else:
await self._upload_multipart(tar_filename, open_stream, on_progress)
await self._upload_multipart(tar_filename, open_stream)
# Upload the metadata file
metadata_content = json.dumps(backup.as_dict())
@@ -185,13 +185,11 @@ class R2BackupAgent(BackupAgent):
self,
tar_filename: str,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
on_progress: OnProgressCallback,
) -> None:
):
"""Upload a large file using multipart upload.
:param tar_filename: The target filename for the backup.
:param open_stream: A function returning an async iterator that yields bytes.
:param on_progress: A callback to report the number of uploaded bytes.
"""
_LOGGER.debug("Starting multipart upload for %s", tar_filename)
key = self._with_prefix(tar_filename)
@@ -205,7 +203,6 @@ class R2BackupAgent(BackupAgent):
part_number = 1
buffer = bytearray() # bytes buffer to store the data
offset = 0 # start index of unread data inside buffer
bytes_uploaded = 0
stream = await open_stream()
async for chunk in stream:
@@ -234,8 +231,6 @@ class R2BackupAgent(BackupAgent):
Body=part_data.tobytes(),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
bytes_uploaded += len(part_data)
on_progress(bytes_uploaded=bytes_uploaded)
part_number += 1
finally:
view.release()
@@ -264,8 +259,6 @@ class R2BackupAgent(BackupAgent):
Body=remaining_data.tobytes(),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
bytes_uploaded += len(remaining_data)
on_progress(bytes_uploaded=bytes_uploaded)
await cast(Any, self._client).complete_multipart_upload(
Bucket=self._bucket,

View File

@@ -1,7 +1,7 @@
"""Provides conditions for covers."""
from homeassistant.const import STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, State
from homeassistant.core import HomeAssistant, State, split_entity_id
from homeassistant.helpers.condition import Condition, EntityConditionBase
from .const import ATTR_IS_CLOSED, DOMAIN, CoverDeviceClass
@@ -13,7 +13,7 @@ class CoverConditionBase(EntityConditionBase[CoverDomainSpec]):
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches the expected cover state."""
domain_spec = self._domain_specs[entity_state.domain]
domain_spec = self._domain_specs[split_entity_id(entity_state.entity_id)[0]]
if domain_spec.value_source is not None:
return (
entity_state.attributes.get(domain_spec.value_source)

View File

@@ -28,7 +28,6 @@ from homeassistant.config_entries import (
SOURCE_REAUTH,
SOURCE_RECONFIGURE,
ConfigEntry,
ConfigEntryState,
ConfigFlow,
ConfigFlowResult,
FlowType,
@@ -364,11 +363,6 @@ class EsphomeFlowHandler(ConfigFlow, domain=DOMAIN):
# Don't probe to verify the mac is correct since
# the host matches (and port matches if provided).
raise AbortFlow("already_configured")
# If the entry is loaded and the device is currently connected,
# don't update the host. This prevents transient mDNS announcements
# (e.g., during WiFi mesh roaming) from overwriting a working connection.
if entry.state is ConfigEntryState.LOADED and entry.runtime_data.available:
raise AbortFlow("already_configured")
configured_psk: str | None = entry.data.get(CONF_NOISE_PSK)
await self._fetch_device_info(host, port or configured_port, configured_psk)
updates: dict[str, Any] = {}

View File

@@ -1,12 +1,10 @@
"""HTTP view that converts audio from a URL to a preferred format."""
import asyncio
from collections import defaultdict, deque
import contextlib
from collections import defaultdict
from dataclasses import dataclass, field
from http import HTTPStatus
import logging
import re
import secrets
from typing import Final
@@ -24,12 +22,6 @@ from .const import DOMAIN
_LOGGER = logging.getLogger(__name__)
_MAX_CONVERSIONS_PER_DEVICE: Final[int] = 2
_MAX_STDERR_LINES: Final[int] = 64
_PROC_WAIT_TIMEOUT: Final[int] = 5
_STDERR_DRAIN_TIMEOUT: Final[int] = 1
_SENSITIVE_QUERY_PARAMS: Final[re.Pattern[str]] = re.compile(
r"(?<=[?&])(authSig|token|key|password|secret)=[^&\s]+", re.IGNORECASE
)
@callback
@@ -223,10 +215,8 @@ class FFmpegConvertResponse(web.StreamResponse):
assert proc.stdout is not None
assert proc.stderr is not None
stderr_lines: deque[str] = deque(maxlen=_MAX_STDERR_LINES)
stderr_task = self.hass.async_create_background_task(
self._collect_ffmpeg_stderr(proc, stderr_lines),
"ESPHome media proxy dump stderr",
self._dump_ffmpeg_stderr(proc), "ESPHome media proxy dump stderr"
)
try:
@@ -245,80 +235,33 @@ class FFmpegConvertResponse(web.StreamResponse):
if request.transport:
request.transport.abort()
raise # don't log error
except Exception:
except:
_LOGGER.exception("Unexpected error during ffmpeg conversion")
raise
finally:
# Allow conversion info to be removed
self.convert_info.is_finished = True
# Ensure subprocess and stderr cleanup run even if this task
# is cancelled (e.g., during shutdown)
try:
# Terminate hangs, so kill is used
if proc.returncode is None:
proc.kill()
# stop dumping ffmpeg stderr task
stderr_task.cancel()
# Wait for process to exit so returncode is set
await asyncio.wait_for(proc.wait(), timeout=_PROC_WAIT_TIMEOUT)
# Let stderr collector finish draining
if not stderr_task.done():
try:
await asyncio.wait_for(
stderr_task, timeout=_STDERR_DRAIN_TIMEOUT
)
except TimeoutError:
stderr_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await stderr_task
except TimeoutError:
_LOGGER.warning(
"Timed out waiting for ffmpeg process to exit for device %s",
self.device_id,
)
stderr_task.cancel()
with contextlib.suppress(asyncio.CancelledError):
await stderr_task
except asyncio.CancelledError:
# Kill the process if we were interrupted
if proc.returncode is None:
proc.kill()
stderr_task.cancel()
raise
if proc.returncode is not None and proc.returncode > 0:
_LOGGER.error(
"FFmpeg conversion failed for device %s (return code %s):\n%s",
self.device_id,
proc.returncode,
"\n".join(
_SENSITIVE_QUERY_PARAMS.sub(r"\1=REDACTED", line)
for line in stderr_lines
),
)
# Terminate hangs, so kill is used
if proc.returncode is None:
proc.kill()
# Close connection by writing EOF unless already closing
if request.transport and not request.transport.is_closing():
with contextlib.suppress(ConnectionResetError, RuntimeError, OSError):
await writer.write_eof()
await writer.write_eof()
async def _collect_ffmpeg_stderr(
async def _dump_ffmpeg_stderr(
self,
proc: asyncio.subprocess.Process,
stderr_lines: deque[str],
) -> None:
"""Collect stderr output from ffmpeg for error reporting."""
assert proc.stdout is not None
assert proc.stderr is not None
while self.hass.is_running and (chunk := await proc.stderr.readline()):
line = chunk.decode(errors="replace").rstrip()
stderr_lines.append(line)
_LOGGER.debug(
"ffmpeg[%s] output: %s",
proc.pid,
_SENSITIVE_QUERY_PARAMS.sub(r"\1=REDACTED", line),
)
_LOGGER.debug("ffmpeg[%s] output: %s", proc.pid, chunk.decode().rstrip())
class FFmpegProxyView(HomeAssistantView):

View File

@@ -12,10 +12,5 @@
"motion": {
"default": "mdi:motion-sensor"
}
},
"triggers": {
"received": {
"trigger": "mdi:eye-check"
}
}
}

View File

@@ -21,17 +21,5 @@
"name": "Motion"
}
},
"title": "Event",
"triggers": {
"received": {
"description": "Triggers after one or more event entities receive a matching event.",
"fields": {
"event_type": {
"description": "The event types to trigger on.",
"name": "Event type"
}
},
"name": "Event received"
}
}
"title": "Event"
}

View File

@@ -1,67 +0,0 @@
"""Provides triggers for events."""
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS, STATE_UNAVAILABLE, STATE_UNKNOWN
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
TriggerConfig,
)
from .const import ATTR_EVENT_TYPE, DOMAIN
CONF_EVENT_TYPE = "event_type"
EVENT_RECEIVED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(CONF_EVENT_TYPE): vol.All(
cv.ensure_list, vol.Length(min=1), [cv.string]
),
},
}
)
class EventReceivedTrigger(EntityTriggerBase):
"""Trigger for event entity when it receives a matching event."""
_domain_specs = {DOMAIN: DomainSpec()}
_schema = EVENT_RECEIVED_TRIGGER_SCHEMA
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the event received trigger."""
super().__init__(hass, config)
self._event_types = set(self._options[CONF_EVENT_TYPE])
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and different from the current state."""
# UNKNOWN is a valid from_state, otherwise the first time the event is received
# would not trigger
if from_state.state == STATE_UNAVAILABLE:
return False
return from_state.state != to_state.state
def is_valid_state(self, state: State) -> bool:
"""Check if the event type is valid and matches one of the configured types."""
return (
state.state not in (STATE_UNAVAILABLE, STATE_UNKNOWN)
and state.attributes.get(ATTR_EVENT_TYPE) in self._event_types
)
TRIGGERS: dict[str, type[Trigger]] = {
"received": EventReceivedTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for events."""
return TRIGGERS

View File

@@ -1,16 +0,0 @@
received:
target:
entity:
domain: event
fields:
event_type:
context:
filter_target: target
required: true
selector:
state:
attribute: event_type
hide_states:
- unavailable
- unknown
multiple: true

View File

@@ -36,10 +36,10 @@ DEFAULT_SCAN_INTERVAL = timedelta(minutes=5)
class FireflyCoordinatorData:
"""Data structure for Firefly III coordinator data."""
accounts: dict[str, Account]
accounts: list[Account]
categories: list[Category]
category_details: dict[str, Category]
budgets: dict[str, Budget]
category_details: list[Category]
budgets: list[Budget]
bills: list[Bill]
primary_currency: Currency
@@ -142,10 +142,10 @@ class FireflyDataUpdateCoordinator(DataUpdateCoordinator[FireflyCoordinatorData]
) from err
return FireflyCoordinatorData(
accounts={account.id: account for account in accounts},
accounts=accounts,
categories=categories,
category_details={category.id: category for category in category_details},
budgets={budget.id: budget for budget in budgets},
category_details=category_details,
budgets=budgets,
bills=bills,
primary_currency=primary_currency,
)

View File

@@ -44,7 +44,7 @@ class FireflyAccountBaseEntity(FireflyBaseEntity):
) -> None:
"""Initialize a Firefly account entity."""
super().__init__(coordinator)
self._account_id = account.id
self._account = account
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer=MANUFACTURER,
@@ -58,10 +58,6 @@ class FireflyAccountBaseEntity(FireflyBaseEntity):
f"{coordinator.config_entry.unique_id}_account_{account.id}_{key}"
)
@property
def _account(self) -> Account:
return self.coordinator.data.accounts[self._account_id]
class FireflyCategoryBaseEntity(FireflyBaseEntity):
"""Base class for Firefly III category entity."""
@@ -74,7 +70,7 @@ class FireflyCategoryBaseEntity(FireflyBaseEntity):
) -> None:
"""Initialize a Firefly category entity."""
super().__init__(coordinator)
self._category_id = category.id
self._category = category
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer=MANUFACTURER,
@@ -88,10 +84,6 @@ class FireflyCategoryBaseEntity(FireflyBaseEntity):
f"{coordinator.config_entry.unique_id}_category_{category.id}_{key}"
)
@property
def _category(self) -> Category:
return self.coordinator.data.category_details[self._category_id]
class FireflyBudgetBaseEntity(FireflyBaseEntity):
"""Base class for Firefly III budget entity."""
@@ -104,7 +96,7 @@ class FireflyBudgetBaseEntity(FireflyBaseEntity):
) -> None:
"""Initialize a Firefly budget entity."""
super().__init__(coordinator)
self._budget_id = budget.id
self._budget = budget
self._attr_device_info = DeviceInfo(
entry_type=DeviceEntryType.SERVICE,
manufacturer=MANUFACTURER,
@@ -117,7 +109,3 @@ class FireflyBudgetBaseEntity(FireflyBaseEntity):
self._attr_unique_id = (
f"{coordinator.config_entry.unique_id}_budget_{budget.id}_{key}"
)
@property
def _budget(self) -> Budget:
return self.coordinator.data.budgets[self._budget_id]

View File

@@ -51,7 +51,7 @@ async def async_setup_entry(
coordinator = entry.runtime_data
entities: list[SensorEntity] = []
for account in coordinator.data.accounts.values():
for account in coordinator.data.accounts:
entities.append(
FireflyAccountBalanceSensor(coordinator, account, ACCOUNT_BALANCE)
)
@@ -61,14 +61,14 @@ async def async_setup_entry(
entities.extend(
[
FireflyCategorySensor(coordinator, category, CATEGORY)
for category in coordinator.data.category_details.values()
for category in coordinator.data.category_details
]
)
entities.extend(
[
FireflyBudgetSensor(coordinator, budget, BUDGET)
for budget in coordinator.data.budgets.values()
for budget in coordinator.data.budgets
]
)
@@ -90,6 +90,7 @@ class FireflyAccountBalanceSensor(FireflyAccountBaseEntity, SensorEntity):
) -> None:
"""Initialize the account balance sensor."""
super().__init__(coordinator, account, key)
self._account = account
self._attr_native_unit_of_measurement = (
coordinator.data.primary_currency.attributes.code
)
@@ -107,6 +108,16 @@ class FireflyAccountRoleSensor(FireflyAccountBaseEntity, SensorEntity):
_attr_entity_category = EntityCategory.DIAGNOSTIC
_attr_entity_registry_enabled_default = True
def __init__(
self,
coordinator: FireflyDataUpdateCoordinator,
account: Account,
key: str,
) -> None:
"""Initialize the account role sensor."""
super().__init__(coordinator, account, key)
self._account = account
@property
def native_value(self) -> StateType:
"""Return account role."""
@@ -162,6 +173,7 @@ class FireflyCategorySensor(FireflyCategoryBaseEntity, SensorEntity):
) -> None:
"""Initialize the category sensor."""
super().__init__(coordinator, category, key)
self._category = category
self._attr_native_unit_of_measurement = (
coordinator.data.primary_currency.attributes.code
)
@@ -193,6 +205,7 @@ class FireflyBudgetSensor(FireflyBudgetBaseEntity, SensorEntity):
) -> None:
"""Initialize the budget sensor."""
super().__init__(coordinator, budget, key)
self._budget = budget
self._attr_native_unit_of_measurement = (
coordinator.data.primary_currency.attributes.code
)

View File

@@ -13,5 +13,5 @@
"iot_class": "local_polling",
"loggers": ["pyfronius"],
"quality_scale": "platinum",
"requirements": ["PyFronius==0.8.2"]
"requirements": ["PyFronius==0.8.0"]
}

View File

@@ -6,6 +6,6 @@
"documentation": "https://www.home-assistant.io/integrations/hassio",
"iot_class": "local_polling",
"quality_scale": "internal",
"requirements": ["aiohasupervisor==0.4.3"],
"requirements": ["aiohasupervisor==0.4.2"],
"single_config_entry": true
}

View File

@@ -211,10 +211,6 @@
"description": "System is currently unhealthy because Docker is configured incorrectly. For troubleshooting information, select Learn more.",
"title": "Unhealthy system - Docker misconfigured"
},
"unhealthy_docker_gateway_unprotected": {
"description": "System is currently unhealthy because Supervisor was not able to apply firewall protection for the Docker gateway IP. For troubleshooting information, select Learn more.",
"title": "Unhealthy system - Docker gateway unprotected"
},
"unhealthy_duplicate_os_installation": {
"description": "System is currently unhealthy because it has detected multiple Home Assistant OS installations. For troubleshooting information, select Learn more.",
"title": "Unhealthy system - Duplicate Home Assistant OS installation"

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,37 +0,0 @@
"""Diagnostics support for Huum."""
from __future__ import annotations
from typing import Any
from homeassistant.components.diagnostics import async_redact_data
from homeassistant.core import HomeAssistant
from .coordinator import HuumConfigEntry
TO_REDACT_DATA = {"sauna_name", "payment_end_date"}
async def async_get_config_entry_diagnostics(
hass: HomeAssistant, entry: HuumConfigEntry
) -> dict[str, Any]:
"""Return diagnostics for a config entry."""
coordinator = entry.runtime_data
result: dict[str, Any] = {
"entry": {
"version": entry.version,
},
"coordinator": {
"last_update_success": coordinator.last_update_success,
"last_exception": (
str(coordinator.last_exception) if coordinator.last_exception else None
),
},
}
if coordinator.data is None:
return result
result["data"] = async_redact_data(coordinator.data.to_dict(), TO_REDACT_DATA)
return result

View File

@@ -46,7 +46,7 @@ rules:
# Gold
devices: done
diagnostics: done
diagnostics: todo
discovery: todo
discovery-update-info: todo
docs-data-update: done

View File

@@ -142,7 +142,7 @@ class IDriveE2BackupAgent(BackupAgent):
if backup.size < MULTIPART_MIN_PART_SIZE_BYTES:
await self._upload_simple(tar_filename, open_stream)
else:
await self._upload_multipart(tar_filename, open_stream, on_progress)
await self._upload_multipart(tar_filename, open_stream)
# Upload the metadata file
metadata_content = json.dumps(backup.as_dict())
@@ -183,13 +183,11 @@ class IDriveE2BackupAgent(BackupAgent):
self,
tar_filename: str,
open_stream: Callable[[], Coroutine[Any, Any, AsyncIterator[bytes]]],
on_progress: OnProgressCallback,
) -> None:
"""Upload a large file using multipart upload.
:param tar_filename: The target filename for the backup.
:param open_stream: A function returning an async iterator that yields bytes.
:param on_progress: A callback to report the number of uploaded bytes.
"""
_LOGGER.debug("Starting multipart upload for %s", tar_filename)
multipart_upload = await cast(Any, self._client).create_multipart_upload(
@@ -202,7 +200,6 @@ class IDriveE2BackupAgent(BackupAgent):
part_number = 1
buffer = bytearray() # bytes buffer to store the data
offset = 0 # start index of unread data inside buffer
bytes_uploaded = 0
stream = await open_stream()
async for chunk in stream:
@@ -231,8 +228,6 @@ class IDriveE2BackupAgent(BackupAgent):
Body=part_data.tobytes(),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
bytes_uploaded += len(part_data)
on_progress(bytes_uploaded=bytes_uploaded)
part_number += 1
finally:
view.release()
@@ -261,8 +256,6 @@ class IDriveE2BackupAgent(BackupAgent):
Body=remaining_data.tobytes(),
)
parts.append({"PartNumber": part_number, "ETag": part["ETag"]})
bytes_uploaded += len(remaining_data)
on_progress(bytes_uploaded=bytes_uploaded)
await cast(Any, self._client).complete_multipart_upload(
Bucket=self._bucket,

View File

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

View File

@@ -1,16 +0,0 @@
{
"triggers": {
"changed": {
"trigger": "mdi:brightness-6"
},
"cleared": {
"trigger": "mdi:brightness-4"
},
"crossed_threshold": {
"trigger": "mdi:brightness-6"
},
"detected": {
"trigger": "mdi:brightness-7"
}
}
}

View File

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

View File

@@ -1,88 +0,0 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
"trigger_behavior_name": "Behavior"
},
"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": "Illuminance",
"triggers": {
"changed": {
"description": "Triggers after one or more illuminance values change.",
"fields": {
"above": {
"description": "Only trigger when illuminance is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when illuminance is below this value.",
"name": "Below"
}
},
"name": "Illuminance changed"
},
"cleared": {
"description": "Triggers after one or more light sensors stop detecting light.",
"fields": {
"behavior": {
"description": "[%key:component::illuminance::common::trigger_behavior_description%]",
"name": "[%key:component::illuminance::common::trigger_behavior_name%]"
}
},
"name": "Light cleared"
},
"crossed_threshold": {
"description": "Triggers after one or more illuminance values cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::illuminance::common::trigger_behavior_description%]",
"name": "[%key:component::illuminance::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "The lower limit of the threshold.",
"name": "Lower limit"
},
"threshold_type": {
"description": "The type of threshold to use.",
"name": "Threshold type"
},
"upper_limit": {
"description": "The upper limit of the threshold.",
"name": "Upper limit"
}
},
"name": "Illuminance crossed threshold"
},
"detected": {
"description": "Triggers after one or more light sensors start detecting light.",
"fields": {
"behavior": {
"description": "[%key:component::illuminance::common::trigger_behavior_description%]",
"name": "[%key:component::illuminance::common::trigger_behavior_name%]"
}
},
"name": "Light detected"
}
}
}

View File

@@ -1,46 +0,0 @@
"""Provides triggers for illuminance."""
from __future__ import annotations
from homeassistant.components.binary_sensor import (
DOMAIN as BINARY_SENSOR_DOMAIN,
BinarySensorDeviceClass,
)
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import LIGHT_LUX, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import DomainSpec, NumericalDomainSpec
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_trigger,
make_entity_numerical_state_crossed_threshold_trigger,
make_entity_target_state_trigger,
)
ILLUMINANCE_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.ILLUMINANCE),
SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.ILLUMINANCE),
}
TRIGGERS: dict[str, type[Trigger]] = {
"detected": make_entity_target_state_trigger(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.LIGHT)},
STATE_ON,
),
"cleared": make_entity_target_state_trigger(
{BINARY_SENSOR_DOMAIN: DomainSpec(device_class=BinarySensorDeviceClass.LIGHT)},
STATE_OFF,
),
"changed": make_entity_numerical_state_changed_trigger(
ILLUMINANCE_DOMAIN_SPECS, valid_unit=LIGHT_LUX
),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
ILLUMINANCE_DOMAIN_SPECS, valid_unit=LIGHT_LUX
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for illuminance."""
return TRIGGERS

View File

@@ -1,79 +0,0 @@
.trigger_common_fields: &trigger_common_fields
behavior: &trigger_behavior
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
.number_or_entity: &number_or_entity
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
unit_of_measurement: "lx"
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement: "lx"
- domain: sensor
device_class: illuminance
- domain: number
device_class: illuminance
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
default: above
selector:
select:
options:
- above
- below
- between
- outside
translation_key: trigger_threshold_type
.trigger_binary_target: &trigger_binary_target
entity:
- domain: binary_sensor
device_class: light
.trigger_numerical_target: &trigger_numerical_target
entity:
- domain: number
device_class: illuminance
- domain: sensor
device_class: illuminance
detected:
fields: *trigger_common_fields
target: *trigger_binary_target
cleared:
fields: *trigger_common_fields
target: *trigger_binary_target
changed:
target: *trigger_numerical_target
fields:
above: *number_or_entity
below: *number_or_entity
crossed_threshold:
target: *trigger_numerical_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity

View File

@@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/loqed",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["loqedAPI==2.1.11"],
"requirements": ["loqedAPI==2.1.10"],
"zeroconf": [
{
"name": "loqed*",

View File

@@ -399,6 +399,47 @@ DISCOVERY_SCHEMAS = [
),
entity_class=MatterNumber,
required_attributes=(clusters.OccupancySensing.Attributes.HoldTime,),
# HoldTime is shared by PIR-specific numbers as a required attribute.
# Keep discovery open so this generic schema does not block them.
allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(
key="OccupancySensingPIRUnoccupiedToOccupiedDelay",
entity_category=EntityCategory.CONFIG,
translation_key="detection_delay",
native_max_value=65534,
native_min_value=0,
native_unit_of_measurement=UnitOfTime.SECONDS,
mode=NumberMode.BOX,
),
entity_class=MatterNumber,
required_attributes=(
clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedDelay,
# This attribute is mandatory when the PIRUnoccupiedToOccupiedDelay is present
clusters.OccupancySensing.Attributes.HoldTime,
),
featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared,
allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,
entity_description=MatterNumberEntityDescription(
key="OccupancySensingPIRUnoccupiedToOccupiedThreshold",
entity_category=EntityCategory.CONFIG,
translation_key="detection_threshold",
native_max_value=254,
native_min_value=1,
mode=NumberMode.BOX,
),
entity_class=MatterNumber,
required_attributes=(
clusters.OccupancySensing.Attributes.PIRUnoccupiedToOccupiedThreshold,
clusters.OccupancySensing.Attributes.HoldTime,
),
featuremap_contains=clusters.OccupancySensing.Bitmaps.Feature.kPassiveInfrared,
allow_multi=True,
),
MatterDiscoverySchema(
platform=Platform.NUMBER,

View File

@@ -214,6 +214,12 @@
"cook_time": {
"name": "Cooking time"
},
"detection_delay": {
"name": "Detection delay"
},
"detection_threshold": {
"name": "Detection threshold"
},
"hold_time": {
"name": "Hold time"
},

View File

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

View File

@@ -14,14 +14,13 @@ import voluptuous as vol
from homeassistant.components import persistent_notification as pn
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_NAME, CONF_PLATFORM, STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.entity_component import EntityComponent
from homeassistant.helpers.restore_state import RestoreEntity
from homeassistant.helpers.typing import ConfigType
from homeassistant.util import dt as dt_util
from homeassistant.util.async_ import run_callback_threadsafe
from homeassistant.util.hass_dict import HassKey
from .const import ( # noqa: F401
@@ -156,20 +155,6 @@ class NotifyEntity(RestoreEntity):
if state is not None and state.state not in (STATE_UNAVAILABLE, None):
self.__set_state(state.state)
@final
def _record_notification(self) -> None:
run_callback_threadsafe(
self.hass.loop, self._async_record_notification
).result()
@final
@callback
def _async_record_notification(self) -> None:
"""Record last notification."""
self.__set_state(dt_util.utcnow().isoformat())
self.async_write_ha_state()
@final
async def _async_send_message(self, **kwargs: Any) -> None:
"""Send a notification message (from e.g., service call).
@@ -177,7 +162,8 @@ class NotifyEntity(RestoreEntity):
Should not be overridden, handle setting last notification timestamp.
"""
await self.async_send_message(**kwargs)
self._async_record_notification()
self.__set_state(dt_util.utcnow().isoformat())
self.async_write_ha_state()
def send_message(self, message: str, title: str | None = None) -> None:
"""Send a message."""

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 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}"
"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}"
},
"unsupported_utility": {
"fix_flow": {

View File

@@ -276,16 +276,10 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
) -> None:
"""Add new endpoints, remove non-existing endpoints."""
current_endpoints = {endpoint.id for endpoint in mapped_endpoints.values()}
self.known_endpoints &= current_endpoints
new_endpoints = current_endpoints - self.known_endpoints
if new_endpoints:
_LOGGER.debug("New endpoints found: %s", new_endpoints)
self.known_endpoints.update(new_endpoints)
new_endpoint_data = [
mapped_endpoints[endpoint_id] for endpoint_id in new_endpoints
]
for endpoint_callback in self.new_endpoints_callbacks:
endpoint_callback(new_endpoint_data)
# Surprise, we also handle containers here :)
current_containers = {
@@ -293,22 +287,10 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
for endpoint in mapped_endpoints.values()
for container_name in endpoint.containers
}
# Prune departed containers so a recreated container is detected as new
# and its entity is rebuilt with the fresh (ephemeral) Docker container ID.
self.known_containers &= current_containers
new_containers = current_containers - self.known_containers
if new_containers:
_LOGGER.debug("New containers found: %s", new_containers)
self.known_containers.update(new_containers)
new_container_data = [
(
mapped_endpoints[endpoint_id],
mapped_endpoints[endpoint_id].containers[name],
)
for endpoint_id, name in new_containers
]
for container_callback in self.new_containers_callbacks:
container_callback(new_container_data)
# Stack management
current_stacks = {
@@ -316,21 +298,10 @@ class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorD
for endpoint in mapped_endpoints.values()
for stack_name in endpoint.stacks
}
self.known_stacks &= current_stacks
new_stacks = current_stacks - self.known_stacks
if new_stacks:
_LOGGER.debug("New stacks found: %s", new_stacks)
self.known_stacks.update(new_stacks)
new_stack_data = [
(
mapped_endpoints[endpoint_id],
mapped_endpoints[endpoint_id].stacks[name],
)
for endpoint_id, name in new_stacks
]
for stack_callback in self.new_stacks_callbacks:
stack_callback(new_stack_data)
def _get_container_name(self, container_name: str) -> str:
"""Sanitize to get a proper container name."""

View File

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

View File

@@ -1,10 +0,0 @@
{
"triggers": {
"changed": {
"trigger": "mdi:flash"
},
"crossed_threshold": {
"trigger": "mdi:flash"
}
}
}

View File

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

View File

@@ -1,76 +0,0 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted entities to trigger on.",
"trigger_behavior_name": "Behavior"
},
"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": "Power",
"triggers": {
"changed": {
"description": "Triggers after one or more power values change.",
"fields": {
"above": {
"description": "Only trigger when power is above this value.",
"name": "Above"
},
"below": {
"description": "Only trigger when power 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": "Power changed"
},
"crossed_threshold": {
"description": "Triggers after one or more power values cross a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::power::common::trigger_behavior_description%]",
"name": "[%key:component::power::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "The lower limit of the threshold.",
"name": "Lower limit"
},
"threshold_type": {
"description": "The type of threshold to use.",
"name": "Threshold type"
},
"unit": {
"description": "[%key:component::power::triggers::changed::fields::unit::description%]",
"name": "[%key:component::power::triggers::changed::fields::unit::name%]"
},
"upper_limit": {
"description": "The upper limit of the threshold.",
"name": "Upper limit"
}
},
"name": "Power crossed threshold"
}
}
}

View File

@@ -1,35 +0,0 @@
"""Provides triggers for power."""
from __future__ import annotations
from homeassistant.components.number import DOMAIN as NUMBER_DOMAIN, NumberDeviceClass
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN, SensorDeviceClass
from homeassistant.const import UnitOfPower
from homeassistant.core import HomeAssistant
from homeassistant.helpers.automation import NumericalDomainSpec
from homeassistant.helpers.trigger import (
Trigger,
make_entity_numerical_state_changed_with_unit_trigger,
make_entity_numerical_state_crossed_threshold_with_unit_trigger,
)
from homeassistant.util.unit_conversion import PowerConverter
POWER_DOMAIN_SPECS: dict[str, NumericalDomainSpec] = {
NUMBER_DOMAIN: NumericalDomainSpec(device_class=NumberDeviceClass.POWER),
SENSOR_DOMAIN: NumericalDomainSpec(device_class=SensorDeviceClass.POWER),
}
TRIGGERS: dict[str, type[Trigger]] = {
"changed": make_entity_numerical_state_changed_with_unit_trigger(
POWER_DOMAIN_SPECS, UnitOfPower.WATT, PowerConverter
),
"crossed_threshold": make_entity_numerical_state_crossed_threshold_with_unit_trigger(
POWER_DOMAIN_SPECS, UnitOfPower.WATT, PowerConverter
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for power."""
return TRIGGERS

View File

@@ -1,87 +0,0 @@
.trigger_common_fields:
behavior: &trigger_behavior
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
.number_or_entity: &number_or_entity
required: false
selector:
choose:
choices:
number:
selector:
number:
mode: box
entity:
selector:
entity:
filter:
- domain: input_number
unit_of_measurement:
- "mW"
- "W"
- "kW"
- "MW"
- "GW"
- "TW"
- "BTU/h"
- domain: sensor
device_class: power
- domain: number
device_class: power
translation_key: number_or_entity
.trigger_threshold_type: &trigger_threshold_type
required: true
default: above
selector:
select:
options:
- above
- below
- between
- outside
translation_key: trigger_threshold_type
.trigger_unit: &trigger_unit
required: false
selector:
select:
options:
- "mW"
- "W"
- "kW"
- "MW"
- "GW"
- "TW"
- "BTU/h"
.trigger_target: &trigger_target
entity:
- domain: number
device_class: power
- domain: sensor
device_class: power
changed:
target: *trigger_target
fields:
above: *number_or_entity
below: *number_or_entity
unit: *trigger_unit
crossed_threshold:
target: *trigger_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
unit: *trigger_unit

View File

@@ -37,7 +37,7 @@ _LOGGER = logging.getLogger(__name__)
class ProxmoxNodeData:
"""All resources for a single Proxmox node."""
node: dict[str, Any] = field(default_factory=dict)
node: dict[str, str] = field(default_factory=dict)
vms: dict[int, dict[str, Any]] = field(default_factory=dict)
containers: dict[int, dict[str, Any]] = field(default_factory=dict)

View File

@@ -36,15 +36,6 @@
"container_memory": {
"default": "mdi:memory"
},
"container_memory_percentage": {
"default": "mdi:memory"
},
"container_netin": {
"default": "mdi:download-network"
},
"container_netout": {
"default": "mdi:upload-network"
},
"container_status": {
"default": "mdi:server"
},
@@ -66,9 +57,6 @@
"node_memory": {
"default": "mdi:memory"
},
"node_memory_percentage": {
"default": "mdi:memory"
},
"node_status": {
"default": "mdi:server"
},
@@ -90,15 +78,6 @@
"vm_memory": {
"default": "mdi:memory"
},
"vm_memory_percentage": {
"default": "mdi:memory"
},
"vm_netin": {
"default": "mdi:download-network"
},
"vm_netout": {
"default": "mdi:upload-network"
},
"vm_status": {
"default": "mdi:server"
}

View File

@@ -14,7 +14,7 @@ from homeassistant.components.sensor import (
SensorStateClass,
StateType,
)
from homeassistant.const import PERCENTAGE, UnitOfInformation, UnitOfTime
from homeassistant.const import PERCENTAGE, UnitOfInformation
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
@@ -104,25 +104,6 @@ NODE_SENSORS: tuple[ProxmoxNodeSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxNodeSensorEntityDescription(
key="node_memory_percentage",
translation_key="node_memory_percentage",
value_fn=lambda data: int(data.node["mem"]) / int(data.node["maxmem"]) * 100,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxNodeSensorEntityDescription(
key="node_uptime",
translation_key="node_uptime",
value_fn=lambda data: data.node["uptime"],
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxNodeSensorEntityDescription(
key="node_status",
translation_key="node_status",
@@ -169,25 +150,6 @@ VM_SENSORS: tuple[ProxmoxVMSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxVMSensorEntityDescription(
key="vm_memory_percentage",
translation_key="vm_memory_percentage",
value_fn=lambda data: int(data["mem"]) / int(data["maxmem"]) * 100,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxVMSensorEntityDescription(
key="vm_uptime",
translation_key="vm_uptime",
value_fn=lambda data: data["uptime"],
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxVMSensorEntityDescription(
key="vm_disk",
translation_key="vm_disk",
@@ -217,28 +179,6 @@ VM_SENSORS: tuple[ProxmoxVMSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["running", "stopped", "suspended"],
),
ProxmoxVMSensorEntityDescription(
key="vm_netin",
translation_key="vm_netin",
value_fn=lambda data: data["netin"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
),
ProxmoxVMSensorEntityDescription(
key="vm_netout",
translation_key="vm_netout",
value_fn=lambda data: data["netout"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
),
)
CONTAINER_SENSORS: tuple[ProxmoxContainerSensorEntityDescription, ...] = (
@@ -278,25 +218,6 @@ CONTAINER_SENSORS: tuple[ProxmoxContainerSensorEntityDescription, ...] = (
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxContainerSensorEntityDescription(
key="container_memory_percentage",
translation_key="container_memory_percentage",
value_fn=lambda data: int(data["mem"]) / int(data["maxmem"]) * 100,
native_unit_of_measurement=PERCENTAGE,
entity_category=EntityCategory.DIAGNOSTIC,
suggested_display_precision=2,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxContainerSensorEntityDescription(
key="container_uptime",
translation_key="container_uptime",
value_fn=lambda data: data["uptime"],
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.SECONDS,
suggested_unit_of_measurement=UnitOfTime.HOURS,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.MEASUREMENT,
),
ProxmoxContainerSensorEntityDescription(
key="container_disk",
translation_key="container_disk",
@@ -326,28 +247,6 @@ CONTAINER_SENSORS: tuple[ProxmoxContainerSensorEntityDescription, ...] = (
device_class=SensorDeviceClass.ENUM,
options=["running", "stopped", "suspended"],
),
ProxmoxContainerSensorEntityDescription(
key="container_netin",
translation_key="container_netin",
value_fn=lambda data: data["netin"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
),
ProxmoxContainerSensorEntityDescription(
key="container_netout",
translation_key="container_netout",
value_fn=lambda data: data["netout"],
device_class=SensorDeviceClass.DATA_SIZE,
native_unit_of_measurement=UnitOfInformation.BYTES,
suggested_unit_of_measurement=UnitOfInformation.GIBIBYTES,
suggested_display_precision=1,
entity_category=EntityCategory.DIAGNOSTIC,
state_class=SensorStateClass.TOTAL_INCREASING,
),
)

View File

@@ -113,15 +113,6 @@
"container_memory": {
"name": "Memory usage"
},
"container_memory_percentage": {
"name": "Memory usage percentage"
},
"container_netin": {
"name": "Network input"
},
"container_netout": {
"name": "Network output"
},
"container_status": {
"name": "Status",
"state": {
@@ -130,9 +121,6 @@
"suspended": "Suspended"
}
},
"container_uptime": {
"name": "Uptime"
},
"node_cpu": {
"name": "CPU usage"
},
@@ -151,9 +139,6 @@
"node_memory": {
"name": "Memory usage"
},
"node_memory_percentage": {
"name": "Memory usage percentage"
},
"node_status": {
"name": "Status",
"state": {
@@ -161,9 +146,6 @@
"online": "Online"
}
},
"node_uptime": {
"name": "Uptime"
},
"vm_cpu": {
"name": "CPU usage"
},
@@ -182,15 +164,6 @@
"vm_memory": {
"name": "Memory usage"
},
"vm_memory_percentage": {
"name": "Memory usage percentage"
},
"vm_netin": {
"name": "Network input"
},
"vm_netout": {
"name": "Network output"
},
"vm_status": {
"name": "Status",
"state": {
@@ -198,9 +171,6 @@
"stopped": "Stopped",
"suspended": "Suspended"
}
},
"vm_uptime": {
"name": "Uptime"
}
}
},

View File

@@ -18,7 +18,6 @@ from .coordinator import (
PLATFORMS: list[Platform] = [
Platform.BINARY_SENSOR,
Platform.BUTTON,
Platform.LIGHT,
Platform.SENSOR,
Platform.SWITCH,
Platform.UPDATE,

View File

@@ -3,19 +3,17 @@
from __future__ import annotations
from abc import abstractmethod
from collections.abc import Callable, Coroutine
from dataclasses import dataclass
from typing import Any
from pysmlight import Api2, Info, Sensors
from pysmlight.const import Settings, SettingsProp
from pysmlight.exceptions import SmlightAuthError, SmlightConnectionError
from pysmlight.models import AmbilightPayload, FirmwareList
from pysmlight.models import FirmwareList
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers import issue_registry as ir
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.issue_registry import IssueSeverity
@@ -123,24 +121,6 @@ class SmBaseDataUpdateCoordinator[_DataT](DataUpdateCoordinator[_DataT]):
async def _internal_update_data(self) -> _DataT:
"""Update coordinator data."""
async def async_execute_command(
self,
command: Callable[..., Coroutine[Any, Any, Any]],
*args: Any,
**kwargs: Any,
) -> Any:
"""Execute an API command and handle connection errors."""
try:
return await command(*args, **kwargs)
except SmlightAuthError as err:
raise ConfigEntryAuthFailed from err
except SmlightConnectionError as err:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect_device",
translation_placeholders={"error": str(err)},
) from err
class SmDataUpdateCoordinator(SmBaseDataUpdateCoordinator[SmData]):
"""Class to manage fetching SMLIGHT sensor data."""
@@ -153,14 +133,6 @@ class SmDataUpdateCoordinator(SmBaseDataUpdateCoordinator[SmData]):
self.async_set_updated_data(self.data)
def update_ambilight(self, changes: dict) -> None:
"""Update the ambilight state from event."""
for key in ("ultLedColor", "ultLedColor2"):
if isinstance(color := changes.get(key), int):
changes[key] = f"#{color:06x}"
self.data.sensors.ambilight = AmbilightPayload(**changes)
self.async_set_updated_data(self.data)
async def _internal_update_data(self) -> SmData:
"""Fetch sensor data from the SMLIGHT device."""
sensors = Sensors()

View File

@@ -1,153 +0,0 @@
"""Light platform for SLZB-Ultima Ambilight."""
from dataclasses import dataclass
import logging
from typing import Any
from pysmlight.const import AMBI_EFFECT_LIST, AmbiEffect, Pages
from pysmlight.models import AmbilightPayload
from homeassistant.components.light import (
ATTR_BRIGHTNESS,
ATTR_EFFECT,
ATTR_RGB_COLOR,
ColorMode,
LightEntity,
LightEntityDescription,
LightEntityFeature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .coordinator import SmConfigEntry, SmDataUpdateCoordinator
from .entity import SmEntity
PARALLEL_UPDATES = 1
_LOGGER = logging.getLogger(__name__)
@dataclass(kw_only=True, frozen=True)
class SmLightEntityDescription(LightEntityDescription):
"""Class describing Smlight light entities."""
effect_list: list[str]
AMBILIGHT = SmLightEntityDescription(
key="ambilight",
translation_key="ambilight",
icon="mdi:led-strip",
effect_list=AMBI_EFFECT_LIST,
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SmConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Initialize light for SLZB-Ultima device."""
coordinator = entry.runtime_data.data
if coordinator.data.info.has_peripherals:
async_add_entities([SmLightEntity(coordinator, AMBILIGHT)])
class SmLightEntity(SmEntity, LightEntity):
"""Representation of light entity for SLZB-Ultima Ambilight."""
coordinator: SmDataUpdateCoordinator
entity_description: SmLightEntityDescription
_attr_color_mode = ColorMode.RGB
_attr_supported_color_modes = {ColorMode.RGB}
_attr_supported_features = LightEntityFeature.EFFECT
def __init__(
self,
coordinator: SmDataUpdateCoordinator,
description: SmLightEntityDescription,
) -> None:
"""Initialize light entity."""
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.unique_id}-{description.key}"
self._attr_effect_list = description.effect_list
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
if ambi := self.coordinator.data.sensors.ambilight:
self._attr_is_on = ambi.ultLedMode not in (None, AmbiEffect.WSULT_OFF)
self._attr_brightness = ambi.ultLedBri
self._attr_effect = self._effect_from_mode(ambi.ultLedMode)
self._attr_rgb_color = self._parse_rgb_color(ambi.ultLedColor)
super()._handle_coordinator_update()
async def async_added_to_hass(self) -> None:
"""Register SSE page callback when entity is added to hass."""
await super().async_added_to_hass()
self.async_on_remove(
self.coordinator.client.sse.register_page_cb(
Pages.API2_PAGE_AMBILIGHT, self._handle_ambilight_changes
)
)
@callback
def _handle_ambilight_changes(self, changes: dict) -> None:
"""Handle ambilight SSE event."""
self.coordinator.update_ambilight(changes)
def _effect_from_mode(self, mode: AmbiEffect | None) -> str | None:
"""Return the effect name for a given AmbiEffect mode."""
if mode is None:
return None
try:
return self.entity_description.effect_list[int(mode)]
except IndexError, ValueError:
return None
def _parse_rgb_color(self, color: str | None) -> tuple[int, int, int] | None:
"""Parse a hex color string into an RGB tuple."""
try:
if color and color.startswith("#"):
return (int(color[1:3], 16), int(color[3:5], 16), int(color[5:7], 16))
except ValueError:
pass
return None
async def async_turn_on(self, **kwargs: Any) -> None:
"""Format kwargs into the specific schema for SLZB-OS and set."""
payload = AmbilightPayload()
if ATTR_EFFECT in kwargs:
effect_name: str = kwargs[ATTR_EFFECT]
try:
idx = self.entity_description.effect_list.index(effect_name)
except ValueError:
_LOGGER.warning("Unknown effect: %s", effect_name)
return
payload.ultLedMode = AmbiEffect(idx)
elif not self.is_on:
payload.ultLedMode = AmbiEffect.WSULT_SOLID
if ATTR_BRIGHTNESS in kwargs:
payload.ultLedBri = kwargs[ATTR_BRIGHTNESS]
if ATTR_RGB_COLOR in kwargs:
r, g, b = kwargs[ATTR_RGB_COLOR]
payload.ultLedColor = f"#{r:02x}{g:02x}{b:02x}"
if payload == AmbilightPayload():
return
await self.coordinator.async_execute_command(
self.coordinator.client.actions.ambilight, payload
)
async def async_turn_off(self, **kwargs: Any) -> None:
"""Turn the Ambilight off using effect OFF."""
await self.coordinator.async_execute_command(
self.coordinator.client.actions.ambilight,
AmbilightPayload(ultLedMode=AmbiEffect.WSULT_OFF),
)

View File

@@ -79,11 +79,6 @@
"name": "Zigbee restart"
}
},
"light": {
"ambilight": {
"name": "Ambilight"
}
},
"sensor": {
"core_temperature": {
"name": "Core chip temp"

View File

@@ -1,24 +0,0 @@
"""Home Assistant integration for SOLARMAN devices."""
from __future__ import annotations
from homeassistant.core import HomeAssistant
from .const import PLATFORMS
from .coordinator import SolarmanConfigEntry, SolarmanDeviceUpdateCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: SolarmanConfigEntry) -> bool:
"""Set up Solarman from a config entry."""
coordinator = SolarmanDeviceUpdateCoordinator(hass, entry)
await coordinator.async_config_entry_first_refresh()
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: SolarmanConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)

View File

@@ -1,152 +0,0 @@
"""Config flow for solarman integration."""
import logging
from typing import Any
from solarman_opendata.solarman import Solarman
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_DEVICE, CONF_HOST, CONF_MAC, CONF_MODEL, CONF_TYPE
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo
from .const import (
CONF_PRODUCT_TYPE,
CONF_SERIAL,
CONF_SN,
DEFAULT_PORT,
DOMAIN,
MODEL_NAME_MAP,
)
_LOGGER = logging.getLogger(__name__)
class SolarmanConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Solarman."""
VERSION = 1
host: str | None = None
model: str | None = None
device_sn: str | None = None
mac: str | None = None
client: Solarman | None = None
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step via user interface."""
errors = {}
if user_input is not None:
self.host = user_input[CONF_HOST]
self.client = Solarman(
async_get_clientsession(self.hass), self.host, DEFAULT_PORT
)
try:
config_data = await self.client.get_config()
except TimeoutError:
errors["base"] = "timeout"
except ConnectionError:
errors["base"] = "cannot_connect"
except Exception:
_LOGGER.exception("Unknown error occurred while verifying device")
errors["base"] = "unknown"
else:
device_info = config_data.get(CONF_DEVICE, config_data)
self.device_sn = device_info[CONF_SN]
self.model = device_info[CONF_TYPE]
self.mac = dr.format_mac(device_info[CONF_MAC])
await self.async_set_unique_id(self.device_sn)
self._abort_if_unique_id_configured()
if not errors:
return self.async_create_entry(
title=f"{MODEL_NAME_MAP[self.model]} ({self.host})",
data={
CONF_HOST: self.host,
CONF_SN: self.device_sn,
CONF_MODEL: self.model,
CONF_MAC: self.mac,
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_HOST): str,
}
),
errors=errors,
)
async def async_step_zeroconf(
self, discovery_info: ZeroconfServiceInfo
) -> ConfigFlowResult:
"""Handle zeroconf discovery."""
self.host = discovery_info.host
self.model = discovery_info.properties[CONF_PRODUCT_TYPE]
self.device_sn = discovery_info.properties[CONF_SERIAL]
self.client = Solarman(
async_get_clientsession(self.hass), self.host, DEFAULT_PORT
)
try:
config_data = await self.client.get_config()
except TimeoutError:
return self.async_abort(reason="timeout")
except ConnectionError:
return self.async_abort(reason="cannot_connect")
except Exception:
_LOGGER.exception("Unknown error occurred while verifying device")
return self.async_abort(reason="unknown")
device_info = config_data.get(CONF_DEVICE, config_data)
self.mac = dr.format_mac(device_info[CONF_MAC])
await self.async_set_unique_id(self.device_sn)
self._abort_if_unique_id_configured(updates={CONF_HOST: discovery_info.host})
return await self.async_step_discovery_confirm()
async def async_step_discovery_confirm(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm discovery."""
assert self.host
assert self.model
assert self.device_sn
if user_input is not None:
return self.async_create_entry(
title=f"{MODEL_NAME_MAP[self.model]} ({self.host})",
data={
CONF_HOST: self.host,
CONF_SN: self.device_sn,
CONF_MODEL: self.model,
CONF_MAC: self.mac,
},
)
self._set_confirm_only()
name = f"{self.model} ({self.device_sn})"
self.context["title_placeholders"] = {"name": name}
return self.async_show_form(
step_id="discovery_confirm",
description_placeholders={
CONF_MODEL: self.model,
CONF_SN: self.device_sn,
CONF_HOST: self.host,
CONF_MAC: self.mac or "",
},
)

View File

@@ -1,23 +0,0 @@
"""Constants for the solarman integration."""
from datetime import timedelta
from homeassistant.const import Platform
DOMAIN = "solarman"
DEFAULT_PORT = 8080
UPDATE_INTERVAL = timedelta(seconds=30)
PLATFORMS = [
Platform.SENSOR,
]
CONF_SERIAL = "serial"
CONF_SN = "sn"
CONF_FW = "fw"
CONF_PRODUCT_TYPE = "product_type"
MODEL_NAME_MAP = {
"SP-2W-EU": "Smart Plug",
"P1-2W": "P1 Meter Reader",
"gl meter": "Smart Meter",
}

View File

@@ -1,51 +0,0 @@
"""Coordinator for solarman integration."""
from __future__ import annotations
import logging
from typing import Any
from solarman_opendata.solarman import Solarman
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
from homeassistant.core import HomeAssistant
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DEFAULT_PORT, DOMAIN, UPDATE_INTERVAL
_LOGGER = logging.getLogger(__name__)
type SolarmanConfigEntry = ConfigEntry[SolarmanDeviceUpdateCoordinator]
class SolarmanDeviceUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
"""Coordinator for managing Solarman device data updates and control operations."""
config_entry: SolarmanConfigEntry
def __init__(self, hass: HomeAssistant, config_entry: SolarmanConfigEntry) -> None:
"""Initialize the Solarman device coordinator."""
super().__init__(
hass,
logger=_LOGGER,
config_entry=config_entry,
name=DOMAIN,
update_interval=UPDATE_INTERVAL,
)
self.api = Solarman(
async_get_clientsession(hass), config_entry.data[CONF_HOST], DEFAULT_PORT
)
async def _async_update_data(self) -> dict[str, Any]:
"""Fetch and update device data."""
try:
return await self.api.fetch_data()
except ConnectionError as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="update_failed",
) from e

View File

@@ -1,34 +0,0 @@
"""Base entity for the Solarman integration."""
from __future__ import annotations
from homeassistant.const import CONF_MAC, CONF_MODEL
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import CONF_SN, DOMAIN, MODEL_NAME_MAP
from .coordinator import SolarmanDeviceUpdateCoordinator
class SolarmanEntity(CoordinatorEntity[SolarmanDeviceUpdateCoordinator]):
"""Defines a Solarman entity."""
_attr_has_entity_name = True
def __init__(self, coordinator: SolarmanDeviceUpdateCoordinator) -> None:
"""Initialize the Solarman entity."""
super().__init__(coordinator)
entry = coordinator.config_entry
sn = entry.data[CONF_SN]
model_id = entry.data[CONF_MODEL]
self._attr_device_info = DeviceInfo(
connections={(CONNECTION_NETWORK_MAC, entry.data[CONF_MAC])},
identifiers={(DOMAIN, sn)},
manufacturer="SOLARMAN",
model=MODEL_NAME_MAP[model_id],
model_id=model_id,
serial_number=sn,
)

View File

@@ -1,12 +0,0 @@
{
"domain": "solarman",
"name": "Solarman",
"codeowners": ["@solarmanpv"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/solarman",
"integration_type": "device",
"iot_class": "local_polling",
"quality_scale": "bronze",
"requirements": ["solarman-opendata==0.0.3"],
"zeroconf": ["_solarman._tcp.local."]
}

View File

@@ -1,85 +0,0 @@
rules:
# Bronze
action-setup:
status: exempt
comment: The integration does not provide additional actions.
appropriate-polling: done
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: The integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup:
status: exempt
comment: |
Entities of this integration does not explicitly subscribe to events.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: The integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: |
This integration does not have an options flow.
docs-installation-parameters: todo
entity-unavailable: done
integration-owner: todo
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
No authentication required.
test-coverage: todo
# Gold
devices: done
diagnostics: todo
discovery-update-info: done
discovery: done
docs-data-update: todo
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: The integration connects to a single device.
entity-category: todo
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
No noisy entities.
entity-translations: done
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
No repairs/issues.
stale-devices:
status: exempt
comment: |
Device type integration.
# Platinum
async-dependency: done
inject-websession: done
strict-typing: todo

View File

@@ -1,281 +0,0 @@
"""Sensor platform for Solarman."""
from typing import Final
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import (
PERCENTAGE,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfFrequency,
UnitOfPower,
UnitOfVolume,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import SolarmanConfigEntry, SolarmanDeviceUpdateCoordinator
from .entity import SolarmanEntity
PARALLEL_UPDATES = 0
SENSORS: Final = (
SensorEntityDescription(
key="voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="electric_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="positive_active_energy",
translation_key="positive_active_energy",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="reverse_active_energy",
translation_key="reverse_active_energy",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="total_act_energy_LT",
translation_key="total_act_energy_lt",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="total_act_energy_NT",
translation_key="total_act_energy_nt",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="total_act_ret_energy_LT",
translation_key="total_act_ret_energy_lt",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="total_act_ret_energy_NT",
translation_key="total_act_ret_energy_nt",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="a_current",
translation_key="a_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="b_current",
translation_key="b_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="c_current",
translation_key="c_current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="a_voltage",
translation_key="a_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="b_voltage",
translation_key="b_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="c_voltage",
translation_key="c_voltage",
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
device_class=SensorDeviceClass.VOLTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="total_act_power",
translation_key="total_act_power",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="total_act_ret_power",
translation_key="total_act_ret_power",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="a_act_power",
translation_key="a_act_power",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="b_act_power",
translation_key="b_act_power",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="c_act_power",
translation_key="c_act_power",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="a_act_ret_power",
translation_key="a_act_ret_power",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="b_act_ret_power",
translation_key="b_act_ret_power",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="c_act_ret_power",
translation_key="c_act_ret_power",
native_unit_of_measurement=UnitOfPower.KILO_WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="total_gas",
translation_key="total_gas",
native_unit_of_measurement=UnitOfVolume.CUBIC_METERS,
device_class=SensorDeviceClass.GAS,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="current",
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
device_class=SensorDeviceClass.CURRENT,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="active power",
translation_key="active_power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="apparent power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.APPARENT_POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="reactive power",
native_unit_of_measurement=UnitOfPower.WATT,
device_class=SensorDeviceClass.REACTIVE_POWER,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="power factor",
translation_key="power_factor",
native_unit_of_measurement=PERCENTAGE,
device_class=SensorDeviceClass.POWER_FACTOR,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="frequency",
native_unit_of_measurement=UnitOfFrequency.HERTZ,
device_class=SensorDeviceClass.FREQUENCY,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="total_act_energy",
translation_key="total_act_energy",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
SensorEntityDescription(
key="total_act_ret_energy",
translation_key="total_act_ret_energy",
native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR,
device_class=SensorDeviceClass.ENERGY,
state_class=SensorStateClass.TOTAL_INCREASING,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: SolarmanConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors from SolarmanConfigEntry."""
async_add_entities(
SolarmanSensorEntity(entry.runtime_data, description)
for description in SENSORS
if description.key in entry.runtime_data.data
)
class SolarmanSensorEntity(SolarmanEntity, SensorEntity):
"""Representation of a Solarman sensor."""
def __init__(
self,
coordinator: SolarmanDeviceUpdateCoordinator,
description: SensorEntityDescription,
) -> None:
"""Initialize the sensor."""
assert coordinator.config_entry
super().__init__(coordinator)
self.entity_description = description
self._attr_unique_id = f"{coordinator.config_entry.unique_id}_{description.key}"
@property
def native_value(self) -> StateType:
"""Return value of sensor."""
return self.coordinator.data[self.entity_description.key]

View File

@@ -1,114 +0,0 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"another_device": "The configured device is not the same found on this IP address",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout": "Connection timed out while trying to connect to the device"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"timeout": "[%key:common::config_flow::error::timeout_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"step": {
"discovery_confirm": {
"description": "Do you want to set up {model} ({sn}) at {host}?"
},
"user": {
"data": {
"host": "[%key:common::config_flow::data::host%]"
},
"data_description": {
"host": "The IP address or hostname of the Solarman device."
},
"description": "Enter the IP address of Solarman device to integrate with Home Assistant.",
"title": "Configure Solarman Device"
}
}
},
"entity": {
"sensor": {
"a_act_power": {
"name": "Active power phase-A"
},
"a_act_ret_power": {
"name": "Active returned power phase-A"
},
"a_current": {
"name": "AC Phase-A current"
},
"a_voltage": {
"name": "AC Phase-A voltage"
},
"active_power": {
"name": "Active power"
},
"b_act_power": {
"name": "Active power phase-B"
},
"b_act_ret_power": {
"name": "Active returned power phase-B"
},
"b_current": {
"name": "AC Phase-B current"
},
"b_voltage": {
"name": "AC Phase-B voltage"
},
"c_act_power": {
"name": "Active power phase-C"
},
"c_act_ret_power": {
"name": "Active returned power phase-C"
},
"c_current": {
"name": "AC Phase-C current"
},
"c_voltage": {
"name": "AC Phase-C voltage"
},
"positive_active_energy": {
"name": "Positive active energy"
},
"power_factor": {
"name": "Power factor"
},
"reverse_active_energy": {
"name": "Reverse active energy"
},
"total_act_energy": {
"name": "Total actual energy"
},
"total_act_energy_lt": {
"name": "Total actual energy low tariff"
},
"total_act_energy_nt": {
"name": "Total actual energy normal tariff"
},
"total_act_power": {
"name": "Total actual power"
},
"total_act_ret_energy": {
"name": "Total actual returned energy"
},
"total_act_ret_energy_lt": {
"name": "Total actual returned energy low tariff"
},
"total_act_ret_energy_nt": {
"name": "Total actual returned energy normal tariff"
},
"total_act_ret_power": {
"name": "Total actual returned power"
},
"total_gas": {
"name": "Total gas consumption"
}
}
},
"exceptions": {
"update_failed": {
"message": "Unable to fetch data."
}
}
}

View File

@@ -30,8 +30,6 @@ from .const import (
CONNECTABLE_SUPPORTED_MODEL_TYPES,
DEFAULT_CURTAIN_SPEED,
DEFAULT_RETRY_COUNT,
DEPRECATED_SENSOR_TYPE_AIR_PURIFIER,
DEPRECATED_SENSOR_TYPE_AIR_PURIFIER_TABLE,
DOMAIN,
ENCRYPTED_MODELS,
HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL,
@@ -109,10 +107,8 @@ PLATFORMS_BY_TYPE = {
Platform.LOCK,
Platform.SENSOR,
],
SupportedModels.AIR_PURIFIER_JP.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.AIR_PURIFIER_US.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.AIR_PURIFIER_TABLE_JP.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.AIR_PURIFIER_TABLE_US.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.AIR_PURIFIER.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.AIR_PURIFIER_TABLE.value: [Platform.FAN, Platform.SENSOR],
SupportedModels.EVAPORATIVE_HUMIDIFIER: [Platform.HUMIDIFIER, Platform.SENSOR],
SupportedModels.FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR],
SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR],
@@ -166,10 +162,8 @@ CLASS_BY_DEVICE = {
SupportedModels.K20_VACUUM.value: switchbot.SwitchbotVacuum,
SupportedModels.LOCK_LITE.value: switchbot.SwitchbotLock,
SupportedModels.LOCK_ULTRA.value: switchbot.SwitchbotLock,
SupportedModels.AIR_PURIFIER_JP.value: switchbot.SwitchbotAirPurifier,
SupportedModels.AIR_PURIFIER_US.value: switchbot.SwitchbotAirPurifier,
SupportedModels.AIR_PURIFIER_TABLE_JP.value: switchbot.SwitchbotAirPurifier,
SupportedModels.AIR_PURIFIER_TABLE_US.value: switchbot.SwitchbotAirPurifier,
SupportedModels.AIR_PURIFIER.value: switchbot.SwitchbotAirPurifier,
SupportedModels.AIR_PURIFIER_TABLE.value: switchbot.SwitchbotAirPurifier,
SupportedModels.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier,
SupportedModels.FLOOR_LAMP.value: switchbot.SwitchbotStripLight3,
SupportedModels.STRIP_LIGHT_3.value: switchbot.SwitchbotStripLight3,
@@ -189,40 +183,6 @@ CLASS_BY_DEVICE = {
_LOGGER = logging.getLogger(__name__)
def _migrate_deprecated_air_purifier_type(
hass: HomeAssistant, entry: SwitchbotConfigEntry
) -> bool:
"""Migrate deprecated air purifier sensor types introduced before pySwitchbot 2.0.0.
The old library used a single AIR_PURIFIER/AIR_PURIFIER_TABLE type; the new
library distinguishes by region (JP/US). The correct type can be detected from
the BLE advertisement local name without connecting or using encryption keys.
Returns True if migration succeeded, False if device is not in range yet.
"""
address: str = entry.data[CONF_ADDRESS]
if service_info := bluetooth.async_last_service_info(
hass, address.upper(), connectable=True
):
parsed_adv = switchbot.parse_advertisement_data(
service_info.device, service_info.advertisement
)
if (
parsed_adv
and (adv_model := parsed_adv.data.get("modelName"))
in CONNECTABLE_SUPPORTED_MODEL_TYPES
):
hass.config_entries.async_update_entry(
entry,
data={
**entry.data,
CONF_SENSOR_TYPE: str(CONNECTABLE_SUPPORTED_MODEL_TYPES[adv_model]),
},
)
return True
return False
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Switchbot Devices component."""
async_setup_services(hass)
@@ -243,21 +203,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: SwitchbotConfigEntry) ->
data={**entry.data, CONF_ADDRESS: mac},
)
# Migrate deprecated air purifier sensor types introduced before pySwitchbot 2.0.0.
if entry.data.get(CONF_SENSOR_TYPE) in (
DEPRECATED_SENSOR_TYPE_AIR_PURIFIER,
DEPRECATED_SENSOR_TYPE_AIR_PURIFIER_TABLE,
) and not _migrate_deprecated_air_purifier_type(hass, entry):
# Device was not in range; retry when it starts advertising again.
raise ConfigEntryNotReady(
translation_domain=DOMAIN,
translation_key="device_not_found_error",
translation_placeholders={
"sensor_type": entry.data[CONF_SENSOR_TYPE],
"address": entry.data[CONF_ADDRESS],
},
)
sensor_type: str = entry.data[CONF_SENSOR_TYPE]
switchbot_model = HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL[sensor_type]
# connectable means we can make connections to the device

View File

@@ -47,10 +47,8 @@ class SupportedModels(StrEnum):
HUB3 = "hub3"
LOCK_LITE = "lock_lite"
LOCK_ULTRA = "lock_ultra"
AIR_PURIFIER_JP = "air_purifier_jp"
AIR_PURIFIER_US = "air_purifier_us"
AIR_PURIFIER_TABLE_JP = "air_purifier_table_jp"
AIR_PURIFIER_TABLE_US = "air_purifier_table_us"
AIR_PURIFIER = "air_purifier"
AIR_PURIFIER_TABLE = "air_purifier_table"
EVAPORATIVE_HUMIDIFIER = "evaporative_humidifier"
FLOOR_LAMP = "floor_lamp"
STRIP_LIGHT_3 = "strip_light_3"
@@ -92,10 +90,8 @@ CONNECTABLE_SUPPORTED_MODEL_TYPES = {
SwitchbotModel.K10_PRO_COMBO_VACUUM: SupportedModels.K10_PRO_COMBO_VACUUM,
SwitchbotModel.LOCK_LITE: SupportedModels.LOCK_LITE,
SwitchbotModel.LOCK_ULTRA: SupportedModels.LOCK_ULTRA,
SwitchbotModel.AIR_PURIFIER_JP: SupportedModels.AIR_PURIFIER_JP,
SwitchbotModel.AIR_PURIFIER_US: SupportedModels.AIR_PURIFIER_US,
SwitchbotModel.AIR_PURIFIER_TABLE_JP: SupportedModels.AIR_PURIFIER_TABLE_JP,
SwitchbotModel.AIR_PURIFIER_TABLE_US: SupportedModels.AIR_PURIFIER_TABLE_US,
SwitchbotModel.AIR_PURIFIER: SupportedModels.AIR_PURIFIER,
SwitchbotModel.AIR_PURIFIER_TABLE: SupportedModels.AIR_PURIFIER_TABLE,
SwitchbotModel.EVAPORATIVE_HUMIDIFIER: SupportedModels.EVAPORATIVE_HUMIDIFIER,
SwitchbotModel.FLOOR_LAMP: SupportedModels.FLOOR_LAMP,
SwitchbotModel.STRIP_LIGHT_3: SupportedModels.STRIP_LIGHT_3,
@@ -138,10 +134,8 @@ ENCRYPTED_MODELS = {
SwitchbotModel.LOCK_PRO,
SwitchbotModel.LOCK_LITE,
SwitchbotModel.LOCK_ULTRA,
SwitchbotModel.AIR_PURIFIER_JP,
SwitchbotModel.AIR_PURIFIER_US,
SwitchbotModel.AIR_PURIFIER_TABLE_JP,
SwitchbotModel.AIR_PURIFIER_TABLE_US,
SwitchbotModel.AIR_PURIFIER,
SwitchbotModel.AIR_PURIFIER_TABLE,
SwitchbotModel.EVAPORATIVE_HUMIDIFIER,
SwitchbotModel.FLOOR_LAMP,
SwitchbotModel.STRIP_LIGHT_3,
@@ -165,10 +159,8 @@ ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[
SwitchbotModel.RELAY_SWITCH_1: switchbot.SwitchbotRelaySwitch,
SwitchbotModel.LOCK_LITE: switchbot.SwitchbotLock,
SwitchbotModel.LOCK_ULTRA: switchbot.SwitchbotLock,
SwitchbotModel.AIR_PURIFIER_JP: switchbot.SwitchbotAirPurifier,
SwitchbotModel.AIR_PURIFIER_US: switchbot.SwitchbotAirPurifier,
SwitchbotModel.AIR_PURIFIER_TABLE_JP: switchbot.SwitchbotAirPurifier,
SwitchbotModel.AIR_PURIFIER_TABLE_US: switchbot.SwitchbotAirPurifier,
SwitchbotModel.AIR_PURIFIER: switchbot.SwitchbotAirPurifier,
SwitchbotModel.AIR_PURIFIER_TABLE: switchbot.SwitchbotAirPurifier,
SwitchbotModel.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier,
SwitchbotModel.FLOOR_LAMP: switchbot.SwitchbotStripLight3,
SwitchbotModel.STRIP_LIGHT_3: switchbot.SwitchbotStripLight3,
@@ -187,11 +179,6 @@ HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = {
str(v): k for k, v in SUPPORTED_MODEL_TYPES.items()
}
# Deprecated sensor type values used before pySwitchbot 2.0.0.
# AIR_PURIFIER and AIR_PURIFIER_TABLE were split into JP/US variants.
DEPRECATED_SENSOR_TYPE_AIR_PURIFIER = "air_purifier"
DEPRECATED_SENSOR_TYPE_AIR_PURIFIER_TABLE = "air_purifier_table"
# Config Defaults
DEFAULT_RETRY_COUNT = 3
DEFAULT_LOCK_NIGHTLATCH = False

View File

@@ -7,7 +7,8 @@ import logging
from typing import Any, Concatenate
import switchbot
from switchbot import Switchbot, SwitchbotDevice, SwitchbotOperationError
from switchbot import Switchbot, SwitchbotDevice
from switchbot.devices.device import SwitchbotOperationError
from homeassistant.components.bluetooth.passive_update_coordinator import (
PassiveBluetoothCoordinatorEntity,

View File

@@ -42,5 +42,5 @@
"iot_class": "local_push",
"loggers": ["switchbot"],
"quality_scale": "gold",
"requirements": ["PySwitchbot==2.0.0"]
"requirements": ["PySwitchbot==1.1.0"]
}

View File

@@ -6,7 +6,7 @@ from datetime import timedelta
import logging
import switchbot
from switchbot import SwitchbotOperationError
from switchbot.devices.device import SwitchbotOperationError
from homeassistant.components.select import SelectEntity
from homeassistant.const import EntityCategory

View File

@@ -7,7 +7,7 @@ from typing import Any
from aiohttp.hdrs import METH_POST
from aiohttp.web import Request, Response
from aiotedee.exceptions import TedeeDataUpdateException, TedeeWebhookException
from aiotedee.exception import TedeeDataUpdateException, TedeeWebhookException
from homeassistant.components.http import HomeAssistantView
from homeassistant.components.webhook import (

View File

@@ -4,7 +4,7 @@ from collections.abc import Callable
from dataclasses import dataclass
from aiotedee import TedeeLock
from aiotedee.models import TedeeDoorState, TedeeLockState
from aiotedee.lock import TedeeDoorState, TedeeLockState
from homeassistant.components.binary_sensor import (
BinarySensorDeviceClass,

View File

@@ -6,10 +6,10 @@ from typing import Any
from aiotedee import (
TedeeAuthException,
TedeeClient,
TedeeClientException,
TedeeDataUpdateException,
TedeeLocalAuthException,
TedeeLocalClient,
)
import voluptuous as vol
@@ -46,7 +46,7 @@ class TedeeConfigFlow(ConfigFlow, domain=DOMAIN):
else:
host = user_input[CONF_HOST]
local_access_token = user_input[CONF_LOCAL_ACCESS_TOKEN]
tedee_client = TedeeLocalClient(
tedee_client = TedeeClient(
local_token=local_access_token,
local_ip=host,
session=async_get_clientsession(self.hass),

View File

@@ -9,14 +9,14 @@ import time
from typing import Any
from aiotedee import (
TedeeClient,
TedeeClientException,
TedeeDataUpdateException,
TedeeLocalAuthException,
TedeeLocalClient,
TedeeLock,
TedeeWebhookException,
)
from aiotedee.models import TedeeBridge
from aiotedee.bridge import TedeeBridge
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_HOST
@@ -52,7 +52,7 @@ class TedeeApiCoordinator(DataUpdateCoordinator[dict[int, TedeeLock]]):
update_interval=SCAN_INTERVAL,
)
self.tedee_client = TedeeLocalClient(
self.tedee_client = TedeeClient(
local_token=self.config_entry.data[CONF_LOCAL_ACCESS_TOKEN],
local_ip=self.config_entry.data[CONF_HOST],
session=async_get_clientsession(hass),

View File

@@ -10,7 +10,7 @@ from homeassistant.core import HomeAssistant
from . import TedeeConfigEntry
TO_REDACT = {
"id",
"lock_id",
}

View File

@@ -1,6 +1,6 @@
"""Bases for Tedee entities."""
from aiotedee.models import TedeeLock
from aiotedee.lock import TedeeLock
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
@@ -25,21 +25,21 @@ class TedeeEntity(CoordinatorEntity[TedeeApiCoordinator]):
"""Initialize Tedee entity."""
super().__init__(coordinator)
self._lock = lock
self._attr_unique_id = f"{lock.id}-{key}"
self._attr_unique_id = f"{lock.lock_id}-{key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, str(lock.id))},
name=lock.name,
identifiers={(DOMAIN, str(lock.lock_id))},
name=lock.lock_name,
manufacturer="Tedee",
model=lock.type_name,
model_id=lock.type_name,
model=lock.lock_type,
model_id=lock.lock_type,
via_device=(DOMAIN, coordinator.bridge.serial),
)
@callback
def _handle_coordinator_update(self) -> None:
"""Handle updated data from the coordinator."""
self._lock = self.coordinator.data.get(self._lock.id, self._lock)
self._lock = self.coordinator.data.get(self._lock.lock_id, self._lock)
super()._handle_coordinator_update()

View File

@@ -84,7 +84,7 @@ class TedeeLockEntity(TedeeEntity, LockEntity):
@property
def is_jammed(self) -> bool:
"""Return true if lock is jammed."""
return self._lock.is_jammed
return self._lock.is_state_jammed
@property
def available(self) -> bool:
@@ -101,13 +101,13 @@ class TedeeLockEntity(TedeeEntity, LockEntity):
self._lock.state = TedeeLockState.UNLOCKING
self.async_write_ha_state()
await self.coordinator.tedee_client.unlock(self._lock.id)
await self.coordinator.tedee_client.unlock(self._lock.lock_id)
await self.coordinator.async_request_refresh()
except (TedeeClientException, Exception) as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="unlock_failed",
translation_placeholders={"lock_id": str(self._lock.id)},
translation_placeholders={"lock_id": str(self._lock.lock_id)},
) from ex
async def async_lock(self, **kwargs: Any) -> None:
@@ -116,13 +116,13 @@ class TedeeLockEntity(TedeeEntity, LockEntity):
self._lock.state = TedeeLockState.LOCKING
self.async_write_ha_state()
await self.coordinator.tedee_client.lock(self._lock.id)
await self.coordinator.tedee_client.lock(self._lock.lock_id)
await self.coordinator.async_request_refresh()
except (TedeeClientException, Exception) as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="lock_failed",
translation_placeholders={"lock_id": str(self._lock.id)},
translation_placeholders={"lock_id": str(self._lock.lock_id)},
) from ex
@@ -140,11 +140,11 @@ class TedeeLockWithLatchEntity(TedeeLockEntity):
self._lock.state = TedeeLockState.UNLOCKING
self.async_write_ha_state()
await self.coordinator.tedee_client.open(self._lock.id)
await self.coordinator.tedee_client.open(self._lock.lock_id)
await self.coordinator.async_request_refresh()
except (TedeeClientException, Exception) as ex:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="open_failed",
translation_placeholders={"lock_id": str(self._lock.id)},
translation_placeholders={"lock_id": str(self._lock.lock_id)},
) from ex

View File

@@ -9,5 +9,5 @@
"iot_class": "local_push",
"loggers": ["aiotedee"],
"quality_scale": "platinum",
"requirements": ["aiotedee==0.3.0"]
"requirements": ["aiotedee==0.2.27"]
}

View File

@@ -2,6 +2,7 @@
from typing import Final
from aiohttp.client_exceptions import ClientResponseError
import jwt
from tesla_fleet_api import TeslaFleetApi, is_valid_region
from tesla_fleet_api.const import Scope
@@ -18,12 +19,7 @@ from tesla_fleet_api.tesla import VehicleFleet
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_TOKEN, Platform
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import (
ConfigEntryAuthFailed,
ConfigEntryNotReady,
OAuth2TokenRequestError,
OAuth2TokenRequestReauthError,
)
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from homeassistant.helpers.config_entry_oauth2_flow import (
@@ -62,48 +58,6 @@ type TeslaFleetConfigEntry = ConfigEntry[TeslaFleetData]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def _async_get_products(tesla: TeslaFleetApi) -> list[dict]:
"""Get products from Tesla Fleet API with region fallback handling."""
try:
return (await tesla.products())["response"]
except InvalidRegion:
LOGGER.warning("Region is invalid, trying to find the correct region")
except (
InvalidToken,
OAuthExpired,
LoginRequired,
OAuth2TokenRequestReauthError,
) as e:
raise ConfigEntryAuthFailed from e
except (TeslaFleetError, OAuth2TokenRequestError) as e:
raise ConfigEntryNotReady from e
try:
await tesla.find_server()
except (
InvalidToken,
OAuthExpired,
LoginRequired,
LibraryError,
OAuth2TokenRequestReauthError,
) as e:
raise ConfigEntryAuthFailed from e
except (TeslaFleetError, OAuth2TokenRequestError) as e:
raise ConfigEntryNotReady from e
try:
return (await tesla.products())["response"]
except (
InvalidToken,
OAuthExpired,
LoginRequired,
OAuth2TokenRequestReauthError,
) as e:
raise ConfigEntryAuthFailed from e
except (TeslaFleetError, OAuth2TokenRequestError) as e:
raise ConfigEntryNotReady from e
async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -> bool:
"""Set up TeslaFleet config."""
@@ -132,7 +86,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
oauth_session = OAuth2Session(hass, entry, implementation)
async def _get_access_token() -> str:
await oauth_session.async_ensure_token_valid()
try:
await oauth_session.async_ensure_token_valid()
except ClientResponseError as e:
if e.status == 401:
raise ConfigEntryAuthFailed from e
raise ConfigEntryNotReady from e
token: str = oauth_session.token[CONF_ACCESS_TOKEN]
return token
@@ -146,7 +105,22 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslaFleetConfigEntry) -
energy_scope=Scope.ENERGY_DEVICE_DATA in scopes,
vehicle_scope=Scope.VEHICLE_DEVICE_DATA in scopes,
)
products = await _async_get_products(tesla)
try:
products = (await tesla.products())["response"]
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
except InvalidRegion:
try:
LOGGER.warning("Region is invalid, trying to find the correct region")
await tesla.find_server()
try:
products = (await tesla.products())["response"]
except TeslaFleetError as e:
raise ConfigEntryNotReady from e
except LibraryError as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise ConfigEntryNotReady from e
device_registry = dr.async_get(hass)

View File

@@ -18,7 +18,6 @@ from tesla_fleet_api.exceptions import (
)
from tesla_fleet_api.tesla import EnergySite, VehicleFleet
from homeassistant.const import CONF_TOKEN
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
@@ -48,28 +47,6 @@ ENDPOINTS = [
]
def _invalidate_access_token(
hass: HomeAssistant, config_entry: TeslaFleetConfigEntry
) -> None:
"""Invalidate the cached access token to force a refresh."""
if (
not (token_data := config_entry.data.get(CONF_TOKEN))
or token_data.get("expires_at") == 0
):
return
hass.config_entries.async_update_entry(
config_entry,
data={
**config_entry.data,
CONF_TOKEN: {
**token_data,
"expires_at": 0,
},
},
)
def flatten(data: dict[str, Any], parent: str | None = None) -> dict[str, Any]:
"""Flatten the data structure."""
result = {}
@@ -141,10 +118,7 @@ class TeslaFleetVehicleDataCoordinator(DataUpdateCoordinator[dict[str, Any]]):
self.name,
)
return self.data
except (InvalidToken, OAuthExpired) as e:
_invalidate_access_token(self.hass, self.config_entry)
raise UpdateFailed(e.message) from e
except LoginRequired as e:
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
@@ -216,10 +190,7 @@ class TeslaFleetEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]])
else:
LOGGER.warning("%s rate limited, will skip refresh", self.name)
return self.data
except (InvalidToken, OAuthExpired) as e:
_invalidate_access_token(self.hass, self.config_entry)
raise UpdateFailed(e.message) from e
except LoginRequired as e:
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
@@ -296,10 +267,7 @@ class TeslaFleetEnergySiteHistoryCoordinator(DataUpdateCoordinator[dict[str, Any
else:
LOGGER.warning("%s rate limited, will skip refresh", self.name)
return self.data
except (InvalidToken, OAuthExpired) as e:
_invalidate_access_token(self.hass, self.config_entry)
raise UpdateFailed(e.message) from e
except LoginRequired as e:
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e
@@ -376,10 +344,7 @@ class TeslaFleetEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]])
else:
LOGGER.warning("%s rate limited, will skip refresh", self.name)
return self.data
except (InvalidToken, OAuthExpired) as e:
_invalidate_access_token(self.hass, self.config_entry)
raise UpdateFailed(e.message) from e
except LoginRequired as e:
except (InvalidToken, OAuthExpired, LoginRequired) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(e.message) from e

View File

@@ -7,7 +7,7 @@ from http import HTTPStatus
import logging
from typing import TYPE_CHECKING, Any
from aiohttp import ClientError, ClientResponseError
from aiohttp import ClientResponseError
from tesla_fleet_api.const import TeslaEnergyPeriod
from tesla_fleet_api.exceptions import InvalidToken, MissingToken, TeslaFleetError
from tesla_fleet_api.tessie import EnergySite
@@ -83,15 +83,7 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except ClientResponseError as e:
if e.status == HTTPStatus.UNAUTHORIZED:
raise ConfigEntryAuthFailed from e
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from e
except ClientError as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from e
raise
return flatten(vehicle)
@@ -131,10 +123,7 @@ class TessieEnergySiteLiveCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except (InvalidToken, MissingToken) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from e
raise UpdateFailed(e.message) from e
# Convert Wall Connectors from array to dict
data["wall_connectors"] = {
@@ -170,10 +159,7 @@ class TessieEnergySiteInfoCoordinator(DataUpdateCoordinator[dict[str, Any]]):
except (InvalidToken, MissingToken) as e:
raise ConfigEntryAuthFailed from e
except TeslaFleetError as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from e
raise UpdateFailed(e.message) from e
return flatten(data)
@@ -211,10 +197,7 @@ class TessieEnergyHistoryCoordinator(DataUpdateCoordinator[dict[str, Any]]):
translation_key="auth_failed",
) from e
except TeslaFleetError as e:
raise UpdateFailed(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from e
raise UpdateFailed(e.message) from e
if (
not data

View File

@@ -4,7 +4,7 @@ from abc import abstractmethod
from collections.abc import Awaitable, Callable
from typing import Any
from aiohttp import ClientError
from aiohttp import ClientResponseError
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.device_registry import DeviceInfo
@@ -103,11 +103,8 @@ class TessieEntity(TessieBaseEntity):
api_key=self._api_key,
**kargs,
)
except ClientError as e:
raise HomeAssistantError(
translation_domain=DOMAIN,
translation_key="cannot_connect",
) from e
except ClientResponseError as e:
raise HomeAssistantError from e
if response["result"] is False:
name: str = getattr(self, "name", self.entity_id)
reason: str = response.get("reason", "unknown")

View File

@@ -72,7 +72,13 @@ rules:
entity-device-class: done
entity-disabled-by-default: done
entity-translations: done
exception-translations: done
exception-translations:
status: todo
comment: |
Most user-facing exceptions have translations (HomeAssistantError and
ServiceValidationError use translation keys from strings.json). Remaining:
entity.py raises bare HomeAssistantError for ClientResponseError, and
coordinators raise UpdateFailed with untranslated messages.
icon-translations: done
reconfiguration-flow: todo
repair-issues: todo

View File

@@ -631,9 +631,6 @@
"cable_connected": {
"message": "Charge cable is connected."
},
"cannot_connect": {
"message": "[%key:common::config_flow::error::cannot_connect%]"
},
"command_failed": {
"message": "Command failed, {message}"
},

View File

@@ -1,66 +0,0 @@
"""Provides conditions for texts."""
from typing import TYPE_CHECKING
import voluptuous as vol
from homeassistant.components.input_text import DOMAIN as INPUT_TEXT_DOMAIN
from homeassistant.const import CONF_OPTIONS, CONF_TARGET
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.automation import DomainSpec
from homeassistant.helpers.condition import (
ATTR_BEHAVIOR,
BEHAVIOR_ALL,
BEHAVIOR_ANY,
Condition,
ConditionConfig,
EntityConditionBase,
)
from .const import DOMAIN
CONF_VALUE = "value"
_TEXT_CONDITION_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS): {
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_ANY, BEHAVIOR_ALL]
),
vol.Required(CONF_VALUE): cv.string,
},
}
)
class TextIsEqualToCondition(EntityConditionBase):
"""Condition for text entity value matching."""
_domain_specs = {
DOMAIN: DomainSpec(),
INPUT_TEXT_DOMAIN: DomainSpec(),
}
_schema = _TEXT_CONDITION_SCHEMA
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options
self._value: str = config.options[CONF_VALUE]
def is_valid_state(self, entity_state: State) -> bool:
"""Check if the state matches the expected value."""
return entity_state.state == self._value
CONDITIONS: dict[str, type[Condition]] = {
"is_equal_to": TextIsEqualToCondition,
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the text conditions."""
return CONDITIONS

View File

@@ -1,19 +0,0 @@
is_equal_to:
target:
entity:
- domain: text
- domain: input_text
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
value:
required: true
selector:
text:

View File

@@ -1,9 +1,4 @@
{
"conditions": {
"is_equal_to": {
"condition": "mdi:form-textbox"
}
},
"entity_component": {
"_": {
"default": "mdi:form-textbox"

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