Compare commits

..

5 Commits

Author SHA1 Message Date
Erik
562fcd0e34 Update translations 2026-03-23 20:45:56 +01:00
Erik
8c39011ca4 Update snapshots 2026-03-23 20:44:27 +01:00
Erik
fc0117775d Adjust translations 2026-03-23 19:05:09 +01:00
Erik
3cfac4ce54 Add support to trigger on number entities 2026-03-23 16:28:45 +01:00
Erik
bf7f9621bf Add power triggers 2026-03-23 12:50:40 +01:00
172 changed files with 2445 additions and 7196 deletions

View File

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

View File

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

2
CODEOWNERS generated
View File

@@ -1309,6 +1309,8 @@ 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

View File

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

View File

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

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

View File

@@ -143,7 +143,6 @@ _EXPERIMENTAL_CONDITION_PLATFORMS = {
}
_EXPERIMENTAL_TRIGGER_PLATFORMS = {
"air_quality",
"alarm_control_panel",
"assist_satellite",
"button",
@@ -151,7 +150,6 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"cover",
"device_tracker",
"door",
"event",
"fan",
"garage_door",
"gate",
@@ -164,6 +162,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"motion",
"occupancy",
"person",
"power",
"remote",
"scene",
"schedule",

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -126,8 +126,8 @@
},
"issues": {
"return_to_grid_migration": {
"description": "We found negative values in your existing consumption statistics, likely because you have solar. We split those 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

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

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

View File

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

View File

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

@@ -0,0 +1,52 @@
"""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 (
EntityNumericalStateChangedTriggerWithUnitBase,
EntityNumericalStateCrossedThresholdTriggerWithUnitBase,
EntityNumericalStateTriggerWithUnitBase,
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),
}
class _PowerTriggerMixin(EntityNumericalStateTriggerWithUnitBase):
"""Mixin for power triggers providing entity filtering, value extraction, and unit conversion."""
_base_unit = UnitOfPower.WATT
_domain_specs = POWER_DOMAIN_SPECS
_unit_converter = PowerConverter
class PowerChangedTrigger(
_PowerTriggerMixin, EntityNumericalStateChangedTriggerWithUnitBase
):
"""Trigger for power value changes."""
class PowerCrossedThresholdTrigger(
_PowerTriggerMixin, EntityNumericalStateCrossedThresholdTriggerWithUnitBase
):
"""Trigger for power value crossing a threshold."""
TRIGGERS: dict[str, type[Trigger]] = {
"changed": PowerChangedTrigger,
"crossed_threshold": PowerCrossedThresholdTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for power."""
return TRIGGERS

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -240,7 +240,9 @@ class TuyaCoverEntity(TuyaEntity, CoverEntity):
tilt_position: DeviceWrapper[int] | None,
) -> None:
"""Init Tuya Cover."""
super().__init__(device, device_manager, description)
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._attr_supported_features = CoverEntityFeature(0)
self._current_position = current_position or set_position

View File

@@ -9,7 +9,7 @@ from tuya_sharing import CustomerDevice, Manager
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity, EntityDescription
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, LOGGER, TUYA_HA_SIGNAL_UPDATE_ENTITY
@@ -20,21 +20,13 @@ class TuyaEntity(Entity):
_attr_has_entity_name = True
_attr_should_poll = False
def __init__(
self,
device: CustomerDevice,
device_manager: Manager,
description: EntityDescription | None = None,
) -> None:
def __init__(self, device: CustomerDevice, device_manager: Manager) -> None:
"""Init TuyaHaEntity."""
self._attr_unique_id = f"tuya.{device.id}"
# TuyaEntity initialize mq can subscribe
device.set_up = True
self.device = device
self.device_manager = device_manager
if description:
self._attr_unique_id = f"tuya.{device.id}{description.key}"
self.entity_description = description
@property
def device_info(self) -> DeviceInfo:

View File

@@ -161,7 +161,9 @@ class TuyaEventEntity(TuyaEntity, EventEntity):
dpcode_wrapper: DeviceWrapper[tuple[str, dict[str, Any] | None]],
) -> None:
"""Init Tuya event entity."""
super().__init__(device, device_manager, description)
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
self._attr_event_types = dpcode_wrapper.options

View File

@@ -137,7 +137,9 @@ class TuyaHumidifierEntity(TuyaEntity, HumidifierEntity):
target_humidity_wrapper: DeviceWrapper[int] | None = None,
) -> None:
"""Init Tuya (de)humidifier."""
super().__init__(device, device_manager, description)
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._current_humidity_wrapper = current_humidity_wrapper
self._mode_wrapper = mode_wrapper

View File

@@ -529,7 +529,9 @@ class TuyaLightEntity(TuyaEntity, LightEntity):
switch_wrapper: DeviceWrapper[bool],
) -> None:
"""Init TuyaHaLight."""
super().__init__(device, device_manager, description)
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._brightness_wrapper = brightness_wrapper
self._color_data_wrapper = color_data_wrapper
self._color_mode_wrapper = color_mode_wrapper

View File

@@ -492,7 +492,9 @@ class TuyaNumberEntity(TuyaEntity, NumberEntity):
dpcode_wrapper: DeviceWrapper[float],
) -> None:
"""Init Tuya sensor."""
super().__init__(device, device_manager, description)
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
self._attr_native_max_value = dpcode_wrapper.max_value

View File

@@ -397,7 +397,9 @@ class TuyaSelectEntity(TuyaEntity, SelectEntity):
dpcode_wrapper: DeviceWrapper[str],
) -> None:
"""Init Tuya sensor."""
super().__init__(device, device_manager, description)
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
self._attr_options = dpcode_wrapper.options

View File

@@ -1684,7 +1684,9 @@ class TuyaSensorEntity(TuyaEntity, SensorEntity):
dpcode_wrapper: DeviceWrapper[StateType],
) -> None:
"""Init Tuya sensor."""
super().__init__(device, device_manager, description)
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
if description.native_unit_of_measurement is None:

View File

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

View File

@@ -950,7 +950,9 @@ class TuyaSwitchEntity(TuyaEntity, SwitchEntity):
dpcode_wrapper: DeviceWrapper[bool],
) -> None:
"""Init TuyaHaSwitch."""
super().__init__(device, device_manager, description)
super().__init__(device, device_manager)
self.entity_description = description
self._attr_unique_id = f"{super().unique_id}{description.key}"
self._dpcode_wrapper = dpcode_wrapper
@property

View File

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

View File

@@ -212,6 +212,9 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
async def _handle_insights_add(self, msg: WebsocketMessage) -> None:
"""Handle access insights events (entry/exit)."""
insights = cast(InsightsAdd, msg)
door = insights.data.metadata.door
if not door.id:
return
event_type = (
"access_granted" if insights.data.result == "ACCESS" else "access_denied"
)
@@ -222,9 +225,7 @@ class UnifiAccessCoordinator(DataUpdateCoordinator[UnifiAccessData]):
attrs["authentication"] = insights.data.metadata.authentication.display_name
if insights.data.result:
attrs["result"] = insights.data.result
for door in insights.data.metadata.door:
if door.id:
self._dispatch_door_event(door.id, "access", event_type, attrs)
self._dispatch_door_event(door.id, "access", event_type, attrs)
@callback
def _dispatch_door_event(

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["unifi_access_api"],
"quality_scale": "bronze",
"requirements": ["py-unifi-access==1.1.3"]
"requirements": ["py-unifi-access==1.1.0"]
}

View File

@@ -8,7 +8,9 @@ import logging
import pyvera as veraApi
from requests.exceptions import RequestException
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
CONF_EXCLUDE,
@@ -19,6 +21,7 @@ from homeassistant.const import (
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.typing import ConfigType
from .common import (
ControllerData,
@@ -32,7 +35,41 @@ from .const import CONF_CONTROLLER, DOMAIN
_LOGGER = logging.getLogger(__name__)
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
VERA_ID_LIST_SCHEMA = vol.Schema([int])
CONFIG_SCHEMA = vol.Schema(
vol.All(
cv.deprecated(DOMAIN),
{
DOMAIN: vol.Schema(
{
vol.Required(CONF_CONTROLLER): cv.url,
vol.Optional(CONF_EXCLUDE, default=[]): VERA_ID_LIST_SCHEMA,
vol.Optional(CONF_LIGHTS, default=[]): VERA_ID_LIST_SCHEMA,
}
)
},
),
extra=vol.ALLOW_EXTRA,
)
async def async_setup(hass: HomeAssistant, base_config: ConfigType) -> bool:
"""Set up for Vera controllers."""
hass.data[DOMAIN] = {}
if not (config := base_config.get(DOMAIN)):
return True
hass.async_create_task(
hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_IMPORT},
data=config,
)
)
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:

View File

@@ -46,7 +46,7 @@ def set_controller_data(
hass: HomeAssistant, config_entry: ConfigEntry, data: ControllerData
) -> None:
"""Set controller data in hass data."""
hass.data.setdefault(DOMAIN, {})[config_entry.entry_id] = data
hass.data[DOMAIN][config_entry.entry_id] = data
class SubscriptionRegistry(pv.AbstractSubscriptionRegistry):

View File

@@ -12,6 +12,7 @@ from requests.exceptions import RequestException
import voluptuous as vol
from homeassistant.config_entries import (
SOURCE_IMPORT,
SOURCE_USER,
ConfigEntry,
ConfigFlow,
@@ -20,6 +21,7 @@ from homeassistant.config_entries import (
)
from homeassistant.const import CONF_EXCLUDE, CONF_LIGHTS, CONF_SOURCE
from homeassistant.core import callback
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.typing import VolDictType
from .const import CONF_CONTROLLER, CONF_LEGACY_UNIQUE_ID, DOMAIN
@@ -129,6 +131,31 @@ class VeraFlowHandler(ConfigFlow, domain=DOMAIN):
},
)
async def async_step_import(self, import_data: dict[str, Any]) -> ConfigFlowResult:
"""Handle a flow initialized by import."""
# If there are entities with the legacy unique_id, then this imported config
# should also use the legacy unique_id for entity creation.
entity_registry = er.async_get(self.hass)
use_legacy_unique_id = (
len(
[
entry
for entry in entity_registry.entities.values()
if entry.platform == DOMAIN and entry.unique_id.isdigit()
]
)
> 0
)
return await self.async_step_finish(
{
**import_data,
CONF_SOURCE: SOURCE_IMPORT,
CONF_LEGACY_UNIQUE_ID: use_legacy_unique_id,
}
)
async def async_step_finish(self, config: dict[str, Any]) -> ConfigFlowResult:
"""Validate and create config entry."""
base_url = config[CONF_CONTROLLER] = config[CONF_CONTROLLER].rstrip("/")

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