Compare commits

...

61 Commits

Author SHA1 Message Date
Erik
d851649b03 Add additional media_player triggers 2025-11-20 11:55:50 +01:00
Erik Montnemery
e8de91d4c7 Move condition and trigger behavior translations to common section (#156925) 2025-11-20 11:54:26 +01:00
Erik Montnemery
8969f714cd Add cover entity triggers xxx_opened (#156923) 2025-11-20 11:36:48 +01:00
Erik Montnemery
b144eae69b Add trigger alarm_control_panel.armed (#156913) 2025-11-20 11:09:34 +01:00
Erik Montnemery
97441f00d8 Simplify cover trigger tests (#156916) 2025-11-20 10:22:41 +01:00
Erik Montnemery
86891f9578 Refactor test helper set_or_remove_state (#156910) 2025-11-20 10:00:57 +01:00
Erik Montnemery
707a2a2360 Rename parametrize_trigger_states parameter with additional attributes (#156918) 2025-11-20 09:55:32 +01:00
Erik Montnemery
1987995edd Minor improvement of entity trigger tests (#156897) 2025-11-20 08:01:25 +01:00
Erik Montnemery
17281e89c0 Minor improvement of cover entity trigger tests (#156895) 2025-11-19 20:04:25 +01:00
Erik Montnemery
a7a5e2c43f Minor improvement of cover entity trigger tests (#156888) 2025-11-19 18:49:51 +01:00
Erik Montnemery
45b2e41c8a Add triggers alarm_control_panel.armed_xxx (#156854) 2025-11-19 17:32:22 +01:00
Erik Montnemery
8dae522043 Move ENTITY_STATE_TRIGGER_SCHEMA close to EntityTriggerBase (#156857) 2025-11-19 16:26:22 +01:00
Erik Montnemery
6dc62073e5 Teach test helper parametrize_trigger_states about state attributes (#156868) 2025-11-19 15:41:22 +01:00
Erik Montnemery
f9fd6826ce Add vacuum triggers (#156870) 2025-11-19 13:33:27 +01:00
Erik Montnemery
f71653f4d7 Add lawn_mower triggers (#156869)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-19 13:33:10 +01:00
Erik Montnemery
3f4334f4a1 Refactor parametrize_trigger_states test helper (#156861) 2025-11-19 10:34:47 +01:00
Erik Montnemery
6ee8724a8a Add test helper set_or_remove_state (#156856) 2025-11-19 08:50:22 +01:00
Erik Montnemery
2e2c8d1b12 Add fan triggers (#156831)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-19 08:31:59 +01:00
Erik
06decf9760 Merge remote-tracking branch 'upstream/dev' into dev_target_triggers_conditions 2025-11-19 08:04:14 +01:00
Erik Montnemery
8337f1575a Add assist_satellite triggers (#156819)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-18 21:15:28 +01:00
Raj Laud
4b69543515 Add support for Victron bluetooth low energy devices (#148043)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-11-18 21:12:48 +01:00
Erik Montnemery
456d55a0e0 Add triggers alarm_control_panel.disarmed and triggered (#156805) 2025-11-18 21:01:54 +01:00
Thomas55555
97ef4a35b9 Bump aioautomower to 2.7.1 (#156826) 2025-11-18 20:32:47 +01:00
Dan Raper
f782c78650 Bump ohmepy and remove advanced_settings_coordinator (#156764) 2025-11-18 19:52:17 +01:00
Abílio Costa
139ed34c74 Properly mock integrations' file_path (#156813) 2025-11-18 18:42:35 +01:00
Andre Lengwenus
7f14d013ac Strict typing for lcn integration (#156800) 2025-11-18 18:26:24 +01:00
Erik Montnemery
3ce6442472 Deduplicate light trigger definitions (#156810) 2025-11-18 16:50:44 +01:00
Erik Montnemery
bf46bfb554 Fix cover trigger target filter (#156809) 2025-11-18 16:50:33 +01:00
Erik Montnemery
6041894b41 Add shared test helper for arming triggers (#156801) 2025-11-18 13:49:48 +01:00
Erik Montnemery
bd07f74cf8 Add trigger media_player.stopped_playing (#156789)
Co-authored-by: Martin Hjelmare <marhje52@gmail.com>
2025-11-18 12:45:37 +01:00
Erik Montnemery
128ff4004c Simplify entity trigger (#156783) 2025-11-18 08:59:16 +01:00
Erik Montnemery
50afba3958 Minor adjustment of cover trigger tests (#156737) 2025-11-17 12:21:21 +01:00
Erik Montnemery
14088a67f2 Small cleanup of entity trigger base classes (#156725) 2025-11-17 09:02:59 +01:00
Erik Montnemery
212c8f2688 Use set union operator in target helper (#156724) 2025-11-17 09:01:31 +01:00
Erik Montnemery
e29b9026ab Deduplicate description_configured in trigger translations (#156723) 2025-11-17 09:00:41 +01:00
Erik Montnemery
0c8dda1956 Add trigger cover.garage_opened (#156545) 2025-11-14 08:17:55 +01:00
Erik Montnemery
edf82db057 Small improvement of entity trigger tests (#156498) 2025-11-13 11:00:11 +01:00
Erik Montnemery
37644511f6 Simplify entity state trigger (#156495) 2025-11-13 09:19:40 +01:00
Erik Montnemery
3685d0f7c2 Add trigger climate.started_heating (#156473) 2025-11-13 07:44:02 +01:00
Erik Montnemery
3dabfeb329 Add more test helpers for trigger tests (#156464) 2025-11-12 19:33:59 +01:00
Erik Montnemery
8e7d2d7108 Add test helper for trigger tests (#156462) 2025-11-12 17:12:32 +01:00
Erik Montnemery
2fe4a1164b Add climate turned off trigger (#156453) 2025-11-12 16:48:10 +01:00
Erik Montnemery
05175294f6 Check from state in light triggers (#156443) 2025-11-12 13:15:30 +01:00
Erik Montnemery
e2ddfb8782 Add light trigger tests where initial state is none, unknown or unavailable (#156430) 2025-11-12 11:54:15 +01:00
Erik Montnemery
f1cc133ff6 Improve light trigger tests (#156424) 2025-11-12 09:12:41 +01:00
Erik Montnemery
0cf97cf577 Improve light trigger tests (#156378) 2025-11-11 21:15:26 +01:00
Franck Nijhof
38cea2e5f0 Clean up CONF_OPTIONS constant from light triggers (#156375) 2025-11-11 17:49:45 +01:00
Erik Montnemery
71876d5b34 Fix light trigger descriptions (#156380) 2025-11-11 17:47:48 +01:00
Abílio Costa
0f780254e1 Add light state condition (#149830)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-11-11 13:46:43 +01:00
Abílio Costa
9e40972b11 Split light state trigger (#156272)
Co-authored-by: Erik <erik@montnemery.com>
2025-11-11 13:42:40 +01:00
abmantis
07ef61dd8d Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-11-10 14:19:18 +00:00
abmantis
1bf6771a54 Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-11-06 19:57:40 +00:00
abmantis
e7a7cb829e Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-11-04 12:28:39 +00:00
abmantis
6f6b2f1ad3 Merge branch 'dev_target_triggers_conditions' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-10-15 17:03:28 +01:00
abmantis
1cc4890f75 Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-10-15 17:03:18 +01:00
Bram Kragten
d3dd9b26c9 Fixes for triggers.yaml descriptions (#153841)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-10-09 18:00:56 +01:00
Abílio Costa
a64d61df05 Fix light trigger with new Trigger class changes (#154087) 2025-10-09 18:14:55 +02:00
abmantis
e7c6c5311d Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-10-09 15:55:39 +01:00
abmantis
72a524c868 Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-09-29 16:56:23 +01:00
abmantis
b437113f31 Merge branch 'dev' of github.com:home-assistant/core into dev_target_triggers_conditions 2025-09-29 11:18:39 +01:00
Abílio Costa
e0e263d3b5 Add state trigger to light component (#148416)
Co-authored-by: Artur Pragacz <49985303+arturpragacz@users.noreply.github.com>
2025-09-18 19:53:26 +01:00
94 changed files with 8074 additions and 177 deletions

2
CODEOWNERS generated
View File

@@ -1736,6 +1736,8 @@ build.json @home-assistant/supervisor
/tests/components/vesync/ @markperdue @webdjoe @thegardenmonkey @cdnninja @iprak @sapuseven
/homeassistant/components/vicare/ @CFenner
/tests/components/vicare/ @CFenner
/homeassistant/components/victron_ble/ @rajlaud
/tests/components/victron_ble/ @rajlaud
/homeassistant/components/victron_remote_monitoring/ @AndyTempel
/tests/components/victron_remote_monitoring/ @AndyTempel
/homeassistant/components/vilfo/ @ManneW

View File

@@ -0,0 +1,5 @@
{
"domain": "victron",
"name": "Victron",
"integrations": ["victron_ble", "victron_remote_monitoring"]
}

View File

@@ -36,5 +36,28 @@
"alarm_trigger": {
"service": "mdi:bell-ring"
}
},
"triggers": {
"armed": {
"trigger": "mdi:shield"
},
"armed_away": {
"trigger": "mdi:shield-lock"
},
"armed_home": {
"trigger": "mdi:shield-home"
},
"armed_night": {
"trigger": "mdi:shield-moon"
},
"armed_vacation": {
"trigger": "mdi:shield-airplane"
},
"disarmed": {
"trigger": "mdi:shield-off"
},
"triggered": {
"trigger": "mdi:bell-ring"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted alarms to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"arm_away": "Arm {entity_name} away",
@@ -71,6 +75,15 @@
"message": "Arming requires a code but none was given for {entity_id}."
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"alarm_arm_away": {
"description": "Arms the alarm in the away mode.",
@@ -143,5 +156,84 @@
"name": "Trigger"
}
},
"title": "Alarm control panel"
"title": "Alarm control panel",
"triggers": {
"armed": {
"description": "Triggers when an alarm is armed.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed"
},
"armed_away": {
"description": "Triggers when an alarm is armed away.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_away::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed away"
},
"armed_home": {
"description": "Triggers when an alarm is armed home.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_home::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed home"
},
"armed_night": {
"description": "Triggers when an alarm is armed night.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_night::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed night"
},
"armed_vacation": {
"description": "Triggers when an alarm is armed vacation.",
"description_configured": "[%key:component::alarm_control_panel::triggers::armed_vacation::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is armed vacation"
},
"disarmed": {
"description": "Triggers when an alarm is disarmed.",
"description_configured": "[%key:component::alarm_control_panel::triggers::disarmed::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is disarmed"
},
"triggered": {
"description": "Triggers when an alarm is triggered.",
"description_configured": "[%key:component::alarm_control_panel::triggers::triggered::description%]",
"fields": {
"behavior": {
"description": "[%key:component::alarm_control_panel::common::trigger_behavior_description%]",
"name": "[%key:component::alarm_control_panel::common::trigger_behavior_name%]"
}
},
"name": "When an alarm is triggered"
}
}
}

View File

@@ -0,0 +1,99 @@
"""Provides triggers for alarm control panels."""
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_supported_features
from homeassistant.helpers.trigger import (
EntityStateTriggerBase,
Trigger,
make_conditional_entity_state_trigger,
make_entity_state_trigger,
)
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return bool(get_supported_features(hass, entity_id) & features)
except HomeAssistantError:
return False
class EntityStateTriggerRequiredFeatures(EntityStateTriggerBase):
"""Trigger for entity state changes."""
_required_features: int
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if supports_feature(self._hass, entity_id, self._required_features)
}
def make_entity_state_trigger_required_features(
domain: str, to_state: str, required_features: int
) -> type[EntityStateTriggerBase]:
"""Create an entity state trigger class."""
class CustomTrigger(EntityStateTriggerRequiredFeatures):
"""Trigger for entity state changes."""
_domain = domain
_to_state = to_state
_required_features = required_features
return CustomTrigger
TRIGGERS: dict[str, type[Trigger]] = {
"armed": make_conditional_entity_state_trigger(
DOMAIN,
from_states={
AlarmControlPanelState.ARMING,
AlarmControlPanelState.DISARMED,
AlarmControlPanelState.DISARMING,
AlarmControlPanelState.PENDING,
AlarmControlPanelState.TRIGGERED,
},
to_states={
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
},
),
"armed_away": make_entity_state_trigger_required_features(
DOMAIN,
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelEntityFeature.ARM_AWAY,
),
"armed_home": make_entity_state_trigger_required_features(
DOMAIN,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelEntityFeature.ARM_HOME,
),
"armed_night": make_entity_state_trigger_required_features(
DOMAIN,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelEntityFeature.ARM_NIGHT,
),
"armed_vacation": make_entity_state_trigger_required_features(
DOMAIN,
AlarmControlPanelState.ARMED_VACATION,
AlarmControlPanelEntityFeature.ARM_VACATION,
),
"disarmed": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.DISARMED),
"triggered": make_entity_state_trigger(DOMAIN, AlarmControlPanelState.TRIGGERED),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for alarm control panels."""
return TRIGGERS

View File

@@ -0,0 +1,53 @@
.trigger_common: &trigger_common
target:
entity:
domain: alarm_control_panel
fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
armed: *trigger_common
armed_away:
fields: *trigger_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
armed_home:
fields: *trigger_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
armed_night:
fields: *trigger_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
armed_vacation:
fields: *trigger_common_fields
target:
entity:
domain: alarm_control_panel
supported_features:
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
disarmed: *trigger_common
triggered: *trigger_common

View File

@@ -14,5 +14,19 @@
"start_conversation": {
"service": "mdi:forum"
}
},
"triggers": {
"idle": {
"trigger": "mdi:chat-sleep"
},
"listening": {
"trigger": "mdi:chat-question"
},
"processing": {
"trigger": "mdi:chat-processing"
},
"responding": {
"trigger": "mdi:chat-alert"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted assist satellites to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "Assist satellite",
@@ -16,6 +20,13 @@
"id": "Answer ID",
"sentences": "Sentences"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
@@ -98,5 +109,51 @@
"name": "Start conversation"
}
},
"title": "Assist satellite"
"title": "Assist satellite",
"triggers": {
"idle": {
"description": "Triggers when an assist satellite becomes idle.",
"description_configured": "[%key:component::assist_satellite::triggers::idle::description%]",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
"name": "When an assist satellite becomes idle"
},
"listening": {
"description": "Triggers when an assist satellite starts listening.",
"description_configured": "[%key:component::assist_satellite::triggers::listening::description%]",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
"name": "When an assist satellite starts listening"
},
"processing": {
"description": "Triggers when an assist satellite is processing.",
"description_configured": "[%key:component::assist_satellite::triggers::processing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
"name": "When an assist satellite is processing"
},
"responding": {
"description": "Triggers when an assist satellite is responding.",
"description_configured": "[%key:component::assist_satellite::triggers::responding::description%]",
"fields": {
"behavior": {
"description": "[%key:component::assist_satellite::common::trigger_behavior_description%]",
"name": "[%key:component::assist_satellite::common::trigger_behavior_name%]"
}
},
"name": "When an assist satellite is responding"
}
}
}

View File

@@ -0,0 +1,19 @@
"""Provides triggers for assist satellites."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from .const import DOMAIN
from .entity import AssistSatelliteState
TRIGGERS: dict[str, type[Trigger]] = {
"idle": make_entity_state_trigger(DOMAIN, AssistSatelliteState.IDLE),
"listening": make_entity_state_trigger(DOMAIN, AssistSatelliteState.LISTENING),
"processing": make_entity_state_trigger(DOMAIN, AssistSatelliteState.PROCESSING),
"responding": make_entity_state_trigger(DOMAIN, AssistSatelliteState.RESPONDING),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for assist satellites."""
return TRIGGERS

View File

@@ -0,0 +1,20 @@
.trigger_common: &trigger_common
target:
entity:
domain: assist_satellite
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
idle: *trigger_common
listening: *trigger_common
processing: *trigger_common
responding: *trigger_common

View File

@@ -96,5 +96,13 @@
"turn_on": {
"service": "mdi:power-on"
}
},
"triggers": {
"started_heating": {
"trigger": "mdi:fire"
},
"turned_off": {
"trigger": "mdi:power-off"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted climates to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"set_hvac_mode": "Change HVAC mode on {entity_name}",
@@ -187,6 +191,13 @@
"heat_cool": "Heat/cool",
"off": "[%key:common::state::off%]"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
@@ -285,5 +296,29 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Climate"
"title": "Climate",
"triggers": {
"started_heating": {
"description": "Triggers when a climate starts to heat.",
"description_configured": "[%key:component::climate::triggers::started_heating::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate starts to heat"
},
"turned_off": {
"description": "Triggers when a climate is turned off.",
"description_configured": "[%key:component::climate::triggers::turned_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
}
},
"name": "When a climate is turned off"
}
}
}

View File

@@ -0,0 +1,23 @@
"""Provides triggers for climates."""
from homeassistant.const import STATE_OFF
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_state_attribute_trigger,
make_entity_state_trigger,
)
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction
TRIGGERS: dict[str, type[Trigger]] = {
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
"started_heating": make_entity_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.HEATING
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for climates."""
return TRIGGERS

View File

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

View File

@@ -108,5 +108,34 @@
"toggle_cover_tilt": {
"service": "mdi:arrow-top-right-bottom-left"
}
},
"triggers": {
"awning_opened": {
"trigger": "mdi:awning-outline"
},
"blind_opened": {
"trigger": "mdi:blinds-horizontal"
},
"curtain_opened": {
"trigger": "mdi:curtains"
},
"door_opened": {
"trigger": "mdi:door-open"
},
"garage_opened": {
"trigger": "mdi:garage-open"
},
"gate_opened": {
"trigger": "mdi:gate-open"
},
"shade_opened": {
"trigger": "mdi:roller-shade"
},
"shutter_opened": {
"trigger": "mdi:window-shutter-open"
},
"window_opened": {
"trigger": "mdi:window-open"
}
}
}

View File

@@ -1,4 +1,16 @@
{
"common": {
"trigger_behavior_description_awning": "The behavior of the targeted awnings to trigger on.",
"trigger_behavior_description_blind": "The behavior of the targeted blinds to trigger on.",
"trigger_behavior_description_curtain": "The behavior of the targeted curtains to trigger on.",
"trigger_behavior_description_door": "The behavior of the targeted doors to trigger on.",
"trigger_behavior_description_garage": "The behavior of the targeted garage doors to trigger on.",
"trigger_behavior_description_gate": "The behavior of the targeted gates to trigger on.",
"trigger_behavior_description_shade": "The behavior of the targeted shades to trigger on.",
"trigger_behavior_description_shutter": "The behavior of the targeted shutters to trigger on.",
"trigger_behavior_description_window": "The behavior of the targeted windows to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"close": "Close {entity_name}",
@@ -82,6 +94,15 @@
"name": "Window"
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"close_cover": {
"description": "Closes a cover.",
@@ -136,5 +157,142 @@
"name": "Toggle tilt"
}
},
"title": "Cover"
"title": "Cover",
"triggers": {
"awning_opened": {
"description": "Triggers when an awning opens.",
"description_configured": "[%key:component::cover::triggers::awning_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_awning%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the awnings to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When an awning opens"
},
"blind_opened": {
"description": "Triggers when a blind opens.",
"description_configured": "[%key:component::cover::triggers::blind_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_blind%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the blinds to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a blind opens"
},
"curtain_opened": {
"description": "Triggers when a curtain opens.",
"description_configured": "[%key:component::cover::triggers::curtain_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_curtain%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the curtains to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a curtain opens"
},
"door_opened": {
"description": "Triggers when a door opens.",
"description_configured": "[%key:component::cover::triggers::door_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_door%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the doors to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a door opens"
},
"garage_opened": {
"description": "Triggers when a garage door opens.",
"description_configured": "[%key:component::cover::triggers::garage_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_garage%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the garage doors to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a garage door opens"
},
"gate_opened": {
"description": "Triggers when a gate opens.",
"description_configured": "[%key:component::cover::triggers::gate_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_gate%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the gates to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a gate opens"
},
"shade_opened": {
"description": "Triggers when a shade opens.",
"description_configured": "[%key:component::cover::triggers::shade_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_shade%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the shades to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a shade opens"
},
"shutter_opened": {
"description": "Triggers when a shutter opens.",
"description_configured": "[%key:component::cover::triggers::shutter_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_shutter%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the shutters to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a shutter opens"
},
"window_opened": {
"description": "Triggers when a window opens.",
"description_configured": "[%key:component::cover::triggers::window_opened::description%]",
"fields": {
"behavior": {
"description": "[%key:component::cover::common::trigger_behavior_description_window%]",
"name": "[%key:component::cover::common::trigger_behavior_name%]"
},
"fully_opened": {
"description": "Require the windows to be fully opened before triggering.",
"name": "Fully opened"
}
},
"name": "When a window opens"
}
}
}

View File

@@ -0,0 +1,116 @@
"""Provides triggers for covers."""
from typing import Final
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS
from homeassistant.core import HomeAssistant, State
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers.entity import get_device_class
from homeassistant.helpers.trigger import (
ENTITY_STATE_TRIGGER_SCHEMA,
EntityTriggerBase,
Trigger,
TriggerConfig,
)
from homeassistant.helpers.typing import UNDEFINED, UndefinedType
from . import ATTR_CURRENT_POSITION, CoverDeviceClass, CoverState
from .const import DOMAIN
ATTR_FULLY_OPENED: Final = "fully_opened"
COVER_OPENED_TRIGGER_SCHEMA = ENTITY_STATE_TRIGGER_SCHEMA.extend(
{
vol.Required(CONF_OPTIONS): {
vol.Required(ATTR_FULLY_OPENED, default=False): bool,
},
}
)
def get_device_class_or_undefined(
hass: HomeAssistant, entity_id: str
) -> str | None | UndefinedType:
"""Get the device class of an entity or UNDEFINED if not found."""
try:
return get_device_class(hass, entity_id)
except HomeAssistantError:
return UNDEFINED
class CoverOpenedClosedTrigger(EntityTriggerBase):
"""Class for cover opened and closed triggers."""
_attribute: str = ATTR_CURRENT_POSITION
_attribute_value: int | None = None
_device_class: CoverDeviceClass | None
_domain: str = DOMAIN
_to_states: set[str]
def is_to_state(self, state: State) -> bool:
"""Check if the state matches the target state."""
if state.state not in self._to_states:
return False
if (
self._attribute_value is not None
and (value := state.attributes.get(self._attribute)) is not None
and value != self._attribute_value
):
return False
return True
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
entities = super().entity_filter(entities)
return {
entity_id
for entity_id in entities
if get_device_class_or_undefined(self._hass, entity_id)
== self._device_class
}
class CoverOpenedTrigger(CoverOpenedClosedTrigger):
"""Class for cover opened triggers."""
_schema = COVER_OPENED_TRIGGER_SCHEMA
_to_states = {CoverState.OPEN, CoverState.OPENING}
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
if self._options.get(ATTR_FULLY_OPENED):
self._attribute_value = 100
def make_cover_opened_trigger(
device_class: CoverDeviceClass | None,
) -> type[CoverOpenedTrigger]:
"""Create an entity state attribute trigger class."""
class CustomTrigger(CoverOpenedTrigger):
"""Trigger for entity state changes."""
_device_class = device_class
return CustomTrigger
TRIGGERS: dict[str, type[Trigger]] = {
"awning_opened": make_cover_opened_trigger(CoverDeviceClass.AWNING),
"blind_opened": make_cover_opened_trigger(CoverDeviceClass.BLIND),
"curtain_opened": make_cover_opened_trigger(CoverDeviceClass.CURTAIN),
"door_opened": make_cover_opened_trigger(CoverDeviceClass.DOOR),
"garage_opened": make_cover_opened_trigger(CoverDeviceClass.GARAGE),
"gate_opened": make_cover_opened_trigger(CoverDeviceClass.GATE),
"shade_opened": make_cover_opened_trigger(CoverDeviceClass.SHADE),
"shutter_opened": make_cover_opened_trigger(CoverDeviceClass.SHUTTER),
"window_opened": make_cover_opened_trigger(CoverDeviceClass.WINDOW),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for covers."""
return TRIGGERS

View File

@@ -0,0 +1,79 @@
.trigger_common_fields: &trigger_common_fields
behavior:
required: true
default: any
selector:
select:
translation_key: trigger_behavior
options:
- first
- last
- any
fully_opened:
required: true
default: false
selector:
boolean:
awning_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: awning
blind_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: blind
curtain_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: curtain
door_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: door
garage_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: garage
gate_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: gate
shade_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: shade
shutter_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: shutter
window_opened:
fields: *trigger_common_fields
target:
entity:
domain: cover
device_class: window

View File

@@ -47,5 +47,13 @@
"turn_on": {
"service": "mdi:fan"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:fan-off"
},
"turned_on": {
"trigger": "mdi:fan"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted fans to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"toggle": "[%key:common::device_automation::action_type::toggle%]",
@@ -66,6 +70,13 @@
"forward": "Forward",
"reverse": "Reverse"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
@@ -152,5 +163,29 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Fan"
"title": "Fan",
"triggers": {
"turned_off": {
"description": "Triggers when a fan is turned off.",
"description_configured": "[%key:component::fan::triggers::turned_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::trigger_behavior_description%]",
"name": "[%key:component::fan::common::trigger_behavior_name%]"
}
},
"name": "When a fan is turned off"
},
"turned_on": {
"description": "Triggers when a fan is turned on.",
"description_configured": "[%key:component::fan::triggers::turned_on::description%]",
"fields": {
"behavior": {
"description": "[%key:component::fan::common::trigger_behavior_description%]",
"name": "[%key:component::fan::common::trigger_behavior_name%]"
}
},
"name": "When a fan is turned on"
}
}
}

View File

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

View File

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

View File

@@ -778,7 +778,7 @@ class ManifestJSONView(HomeAssistantView):
{
"type": "frontend/get_icons",
vol.Required("category"): vol.In(
{"entity", "entity_component", "services", "triggers", "conditions"}
{"conditions", "entity", "entity_component", "services", "triggers"}
),
vol.Optional("integration"): vol.All(cv.ensure_list, [str]),
}

View File

@@ -9,5 +9,5 @@
"iot_class": "cloud_push",
"loggers": ["aioautomower"],
"quality_scale": "silver",
"requirements": ["aioautomower==2.7.0"]
"requirements": ["aioautomower==2.7.1"]
}

View File

@@ -14,5 +14,19 @@
"start_mowing": {
"service": "mdi:play"
}
},
"triggers": {
"docked": {
"trigger": "mdi:home-import-outline"
},
"errored": {
"trigger": "mdi:alert-circle-outline"
},
"paused_mowing": {
"trigger": "mdi:pause"
},
"started_mowing": {
"trigger": "mdi:play"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted lawn mowers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::lawn_mower::title%]",
@@ -11,6 +15,15 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"dock": {
"description": "Stops the mowing task and returns to the dock.",
@@ -25,5 +38,51 @@
"name": "Start mowing"
}
},
"title": "Lawn mower"
"title": "Lawn mower",
"triggers": {
"docked": {
"description": "Triggers when a lawn mower has docked.",
"description_configured": "[%key:component::lawn_mower::triggers::docked::description%]",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
}
},
"name": "When a lawn mower has docked"
},
"errored": {
"description": "Triggers when a lawn mower has errored.",
"description_configured": "[%key:component::lawn_mower::triggers::errored::description%]",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
}
},
"name": "When a lawn mower has errored"
},
"paused_mowing": {
"description": "Triggers when a lawn mower has paused mowing.",
"description_configured": "[%key:component::lawn_mower::triggers::paused_mowing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
}
},
"name": "When a lawn mower has paused mowing"
},
"started_mowing": {
"description": "Triggers when a lawn mower has started mowing.",
"description_configured": "[%key:component::lawn_mower::triggers::started_mowing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::lawn_mower::common::trigger_behavior_description%]",
"name": "[%key:component::lawn_mower::common::trigger_behavior_name%]"
}
},
"name": "When a lawn mower has started mowing"
}
}
}

View File

@@ -0,0 +1,18 @@
"""Provides triggers for lawn mowers."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from .const import DOMAIN, LawnMowerActivity
TRIGGERS: dict[str, type[Trigger]] = {
"docked": make_entity_state_trigger(DOMAIN, LawnMowerActivity.DOCKED),
"errored": make_entity_state_trigger(DOMAIN, LawnMowerActivity.ERROR),
"paused_mowing": make_entity_state_trigger(DOMAIN, LawnMowerActivity.PAUSED),
"started_mowing": make_entity_state_trigger(DOMAIN, LawnMowerActivity.MOWING),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for lawn mowers."""
return TRIGGERS

View File

@@ -0,0 +1,20 @@
.trigger_common: &trigger_common
target:
entity:
domain: lawn_mower
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
docked: *trigger_common
errored: *trigger_common
paused_mowing: *trigger_common
started_mowing: *trigger_common

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
from functools import partial
import logging
from typing import cast
import pypck
from pypck.connection import (
@@ -48,7 +49,6 @@ from .const import (
)
from .helpers import (
AddressType,
InputType,
LcnConfigEntry,
LcnRuntimeData,
async_update_config_entry,
@@ -285,7 +285,7 @@ def _async_fire_access_control_event(
hass: HomeAssistant,
device: dr.DeviceEntry | None,
address: AddressType,
inp: InputType,
inp: pypck.inputs.ModStatusAccessControl,
) -> None:
"""Fire access control event (transponder, transmitter, fingerprint, codelock)."""
event_data = {
@@ -299,7 +299,11 @@ def _async_fire_access_control_event(
if inp.periphery == pypck.lcn_defs.AccessControlPeriphery.TRANSMITTER:
event_data.update(
{"level": inp.level, "key": inp.key, "action": inp.action.value}
{
"level": inp.level,
"key": inp.key,
"action": cast(pypck.lcn_defs.KeyAction, inp.action).value,
}
)
event_name = f"lcn_{inp.periphery.value.lower()}"
@@ -310,7 +314,7 @@ def _async_fire_send_keys_event(
hass: HomeAssistant,
device: dr.DeviceEntry | None,
address: AddressType,
inp: InputType,
inp: pypck.inputs.ModSendKeysHost,
) -> None:
"""Fire send_keys event."""
for table, action in enumerate(inp.actions):

View File

@@ -100,8 +100,6 @@ class LcnClimate(LcnEntity, ClimateEntity):
self._max_temp = config[CONF_DOMAIN_DATA][CONF_MAX_TEMP]
self._min_temp = config[CONF_DOMAIN_DATA][CONF_MIN_TEMP]
self._current_temperature = None
self._target_temperature = None
self._is_on = True
self._attr_hvac_modes = [HVACMode.HEAT]
@@ -121,16 +119,6 @@ class LcnClimate(LcnEntity, ClimateEntity):
return UnitOfTemperature.FAHRENHEIT
return UnitOfTemperature.CELSIUS
@property
def current_temperature(self) -> float | None:
"""Return the current temperature."""
return self._current_temperature
@property
def target_temperature(self) -> float | None:
"""Return the temperature we try to reach."""
return self._target_temperature
@property
def hvac_mode(self) -> HVACMode:
"""Return hvac operation ie. heat, cool mode.
@@ -166,7 +154,7 @@ class LcnClimate(LcnEntity, ClimateEntity):
):
return
self._is_on = False
self._target_temperature = None
self._attr_target_temperature = None
self.async_write_ha_state()
async def async_set_temperature(self, **kwargs: Any) -> None:
@@ -178,7 +166,7 @@ class LcnClimate(LcnEntity, ClimateEntity):
self.setpoint, temperature, self.unit
):
return
self._target_temperature = temperature
self._attr_target_temperature = temperature
self.async_write_ha_state()
async def async_update(self) -> None:
@@ -198,10 +186,14 @@ class LcnClimate(LcnEntity, ClimateEntity):
return
if input_obj.get_var() == self.variable:
self._current_temperature = input_obj.get_value().to_var_unit(self.unit)
self._attr_current_temperature = float(
input_obj.get_value().to_var_unit(self.unit)
)
elif input_obj.get_var() == self.setpoint:
self._is_on = not input_obj.get_value().is_locked_regulator()
if self._is_on:
self._target_temperature = input_obj.get_value().to_var_unit(self.unit)
self._attr_target_temperature = float(
input_obj.get_value().to_var_unit(self.unit)
)
self.async_write_ha_state()

View File

@@ -120,7 +120,7 @@ class LcnFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
errors={CONF_BASE: error},
)
data: dict = {
data: dict[str, Any] = {
**user_input,
CONF_DEVICES: [],
CONF_ENTITIES: [],

View File

@@ -1,7 +1,7 @@
"""Support for LCN covers."""
import asyncio
from collections.abc import Iterable
from collections.abc import Coroutine, Iterable
from datetime import timedelta
from functools import partial
from typing import Any
@@ -81,6 +81,8 @@ class LcnOutputsCover(LcnEntity, CoverEntity):
_attr_is_opening = False
_attr_assumed_state = True
reverse_time: pypck.lcn_defs.MotorReverseTime | None
def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None:
"""Initialize the LCN cover."""
super().__init__(config, config_entry)
@@ -255,7 +257,15 @@ class LcnRelayCover(LcnEntity, CoverEntity):
async def async_update(self) -> None:
"""Update the state of the entity."""
coros = [self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)]
coros: list[
Coroutine[
Any,
Any,
pypck.inputs.ModStatusRelays
| pypck.inputs.ModStatusMotorPositionBS4
| None,
]
] = [self.device_connection.request_status_relays(SCAN_INTERVAL.seconds)]
if self.positioning_mode == pypck.lcn_defs.MotorPositioningMode.BS4:
coros.append(
self.device_connection.request_status_motor_position(
@@ -283,7 +293,7 @@ class LcnRelayCover(LcnEntity, CoverEntity):
)
and input_obj.motor == self.motor.value
):
self._attr_current_cover_position = input_obj.position
self._attr_current_cover_position = int(input_obj.position)
if self._attr_current_cover_position in [0, 100]:
self._attr_is_opening = False
self._attr_is_closing = False

View File

@@ -35,7 +35,7 @@ class LcnEntity(Entity):
self.config = config
self.config_entry = config_entry
self.address: AddressType = config[CONF_ADDRESS]
self._unregister_for_inputs: Callable | None = None
self._unregister_for_inputs: Callable[[], None] | None = None
self._name: str = config[CONF_NAME]
self._attr_device_info = DeviceInfo(
identifiers={

View File

@@ -61,7 +61,7 @@ type LcnConfigEntry = ConfigEntry[LcnRuntimeData]
type AddressType = tuple[int, int, bool]
type InputType = type[pypck.inputs.Input]
type InputType = pypck.inputs.Input
# Regex for address validation
PATTERN_ADDRESS = re.compile(
@@ -269,10 +269,10 @@ async def async_update_device_config(
if device_config[CONF_NAME] != "":
return
device_name = ""
device_name: str | None = None
if not is_group:
device_name = await device_connection.request_name()
if is_group or device_name == "":
if is_group or device_name is None:
module_type = "Group" if is_group else "Module"
device_name = (
f"{module_type} "

View File

@@ -9,5 +9,5 @@
"iot_class": "local_polling",
"loggers": ["pypck"],
"quality_scale": "bronze",
"requirements": ["pypck==0.9.3", "lcn-frontend==0.2.7"]
"requirements": ["pypck==0.9.4", "lcn-frontend==0.2.7"]
}

View File

@@ -74,4 +74,4 @@ rules:
status: exempt
comment: |
Integration is not making any HTTP requests.
strict-typing: todo
strict-typing: done

View File

@@ -156,6 +156,8 @@ class LcnVariableSensor(LcnEntity, SensorEntity):
class LcnLedLogicSensor(LcnEntity, SensorEntity):
"""Representation of a LCN sensor for leds and logicops."""
source: pypck.lcn_defs.LedPort | pypck.lcn_defs.LogicOpPort
def __init__(self, config: ConfigType, config_entry: LcnConfigEntry) -> None:
"""Initialize the LCN sensor."""
super().__init__(config, config_entry)

View File

@@ -104,7 +104,9 @@ def get_config_entry(
@wraps(func)
async def get_entry(
hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict[str, Any],
) -> None:
"""Get config_entry."""
if not (config_entry := hass.config_entries.async_get_entry(msg["entry_id"])):
@@ -124,7 +126,7 @@ def get_config_entry(
async def websocket_get_device_configs(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Get device configs."""
@@ -144,7 +146,7 @@ async def websocket_get_device_configs(
async def websocket_get_entity_configs(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Get entities configs."""
@@ -175,7 +177,7 @@ async def websocket_get_entity_configs(
async def websocket_scan_devices(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Scan for new devices."""
@@ -207,7 +209,7 @@ async def websocket_scan_devices(
async def websocket_add_device(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Add a device."""
@@ -253,7 +255,7 @@ async def websocket_add_device(
async def websocket_delete_device(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Delete a device."""
@@ -315,7 +317,7 @@ async def websocket_delete_device(
async def websocket_add_entity(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Add an entity."""
@@ -381,7 +383,7 @@ async def websocket_add_entity(
async def websocket_delete_entity(
hass: HomeAssistant,
connection: websocket_api.ActiveConnection,
msg: dict,
msg: dict[str, Any],
config_entry: LcnConfigEntry,
) -> None:
"""Delete an entity."""
@@ -451,7 +453,7 @@ async def async_create_or_update_device_in_config_entry(
def get_entity_entry(
hass: HomeAssistant, entity_config: dict, config_entry: LcnConfigEntry
hass: HomeAssistant, entity_config: dict[str, Any], config_entry: LcnConfigEntry
) -> er.RegistryEntry | None:
"""Get entity RegistryEntry from entity_config."""
entity_registry = er.async_get(hass)

View File

@@ -0,0 +1,131 @@
"""Provides conditions for lights."""
from collections.abc import Callable
from typing import TYPE_CHECKING, Any, Final, override
import voluptuous as vol
from homeassistant.const import CONF_OPTIONS, CONF_TARGET, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, split_entity_id
from homeassistant.helpers import config_validation as cv, target
from homeassistant.helpers.condition import (
Condition,
ConditionCheckerType,
ConditionConfig,
trace_condition_function,
)
from homeassistant.helpers.typing import ConfigType, TemplateVarsType
from .const import DOMAIN
ATTR_BEHAVIOR: Final = "behavior"
BEHAVIOR_ANY: Final = "any"
BEHAVIOR_ALL: Final = "all"
STATE_CONDITION_VALID_STATES: Final = [STATE_ON, STATE_OFF]
STATE_CONDITION_OPTIONS_SCHEMA: dict[vol.Marker, Any] = {
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_ANY, BEHAVIOR_ALL]
),
}
STATE_CONDITION_SCHEMA = vol.Schema(
{
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
vol.Required(CONF_OPTIONS): STATE_CONDITION_OPTIONS_SCHEMA,
}
)
class StateConditionBase(Condition):
"""State condition."""
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return STATE_CONDITION_SCHEMA(config) # type: ignore[no-any-return]
def __init__(
self, hass: HomeAssistant, config: ConditionConfig, state: str
) -> None:
"""Initialize condition."""
self._hass = hass
if TYPE_CHECKING:
assert config.target
assert config.options
self._target = config.target
self._behavior = config.options[ATTR_BEHAVIOR]
self._state = state
@override
async def async_get_checker(self) -> ConditionCheckerType:
"""Get the condition checker."""
def check_any_match_state(states: list[str]) -> bool:
"""Test if any entity match the state."""
return any(state == self._state for state in states)
def check_all_match_state(states: list[str]) -> bool:
"""Test if all entities match the state."""
return all(state == self._state for state in states)
matcher: Callable[[list[str]], bool]
if self._behavior == BEHAVIOR_ANY:
matcher = check_any_match_state
elif self._behavior == BEHAVIOR_ALL:
matcher = check_all_match_state
@trace_condition_function
def test_state(hass: HomeAssistant, variables: TemplateVarsType = None) -> bool:
"""Test state condition."""
selector_data = target.TargetSelectorData(self._target)
targeted_entities = target.async_extract_referenced_entity_ids(
hass, selector_data, expand_group=False
)
referenced_entity_ids = targeted_entities.referenced.union(
targeted_entities.indirectly_referenced
)
light_entity_ids = {
entity_id
for entity_id in referenced_entity_ids
if split_entity_id(entity_id)[0] == DOMAIN
}
light_entity_states = [
state.state
for entity_id in light_entity_ids
if (state := hass.states.get(entity_id))
and state.state in STATE_CONDITION_VALID_STATES
]
return matcher(light_entity_states)
return test_state
class IsOnCondition(StateConditionBase):
"""Is on condition."""
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config, STATE_ON)
class IsOffCondition(StateConditionBase):
"""Is off condition."""
def __init__(self, hass: HomeAssistant, config: ConditionConfig) -> None:
"""Initialize condition."""
super().__init__(hass, config, STATE_OFF)
CONDITIONS: dict[str, type[Condition]] = {
"is_off": IsOffCondition,
"is_on": IsOnCondition,
}
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
"""Return the light conditions."""
return CONDITIONS

View File

@@ -0,0 +1,28 @@
is_off:
target:
entity:
domain: light
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any
is_on:
target:
entity:
domain: light
fields:
behavior:
required: true
default: any
selector:
select:
translation_key: condition_behavior
options:
- all
- any

View File

@@ -1,4 +1,12 @@
{
"conditions": {
"is_off": {
"condition": "mdi:lightbulb-off"
},
"is_on": {
"condition": "mdi:lightbulb-on"
}
},
"entity_component": {
"_": {
"default": "mdi:lightbulb",
@@ -25,5 +33,13 @@
"turn_on": {
"service": "mdi:lightbulb-on"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:lightbulb-off"
},
"turned_on": {
"trigger": "mdi:lightbulb-on"
}
}
}

View File

@@ -1,5 +1,7 @@
{
"common": {
"condition_behavior_description": "How the state should match on the targeted lights.",
"condition_behavior_name": "Behavior",
"field_brightness_description": "Number indicating brightness, where 0 turns the light off, 1 is the minimum brightness, and 255 is the maximum brightness.",
"field_brightness_name": "Brightness value",
"field_brightness_pct_description": "Number indicating the percentage of full brightness, where 0 turns the light off, 1 is the minimum brightness, and 100 is the maximum brightness.",
@@ -34,7 +36,33 @@
"field_white_name": "White",
"field_xy_color_description": "Color in XY-format. A list of two decimal numbers between 0 and 1.",
"field_xy_color_name": "XY-color",
"section_advanced_fields_name": "Advanced options"
"section_advanced_fields_name": "Advanced options",
"trigger_behavior_description": "The behavior of the targeted lights to trigger on.",
"trigger_behavior_name": "Behavior"
},
"conditions": {
"is_off": {
"description": "Test if a light is off.",
"description_configured": "[%key:component::light::conditions::is_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",
"name": "[%key:component::light::common::condition_behavior_name%]"
}
},
"name": "If a light is off"
},
"is_on": {
"description": "Test if a light is on.",
"description_configured": "[%key:component::light::conditions::is_on::description%]",
"fields": {
"behavior": {
"description": "[%key:component::light::common::condition_behavior_description%]",
"name": "[%key:component::light::common::condition_behavior_name%]"
}
},
"name": "If a light is on"
}
},
"device_automation": {
"action_type": {
@@ -284,11 +312,30 @@
"yellowgreen": "Yellow green"
}
},
"condition_behavior": {
"options": {
"all": "All",
"any": "Any"
}
},
"flash": {
"options": {
"long": "Long",
"short": "Short"
}
},
"state": {
"options": {
"off": "[%key:common::state::off%]",
"on": "[%key:common::state::on%]"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
@@ -462,5 +509,29 @@
}
}
},
"title": "Light"
"title": "Light",
"triggers": {
"turned_off": {
"description": "Triggers when a light is turned off.",
"description_configured": "[%key:component::light::triggers::turned_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::light::common::trigger_behavior_description%]",
"name": "[%key:component::light::common::trigger_behavior_name%]"
}
},
"name": "When a light is turned off"
},
"turned_on": {
"description": "Triggers when a light is turned on.",
"description_configured": "[%key:component::light::triggers::turned_on::description%]",
"fields": {
"behavior": {
"description": "[%key:component::light::common::trigger_behavior_description%]",
"name": "[%key:component::light::common::trigger_behavior_name%]"
}
},
"name": "When a light is turned on"
}
}
}

View File

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

View File

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

View File

@@ -104,5 +104,22 @@
"volume_up": {
"service": "mdi:volume-plus"
}
},
"triggers": {
"paused_playing": {
"trigger": "mdi:pause"
},
"started_playing": {
"trigger": "mdi:play"
},
"stopped_playing": {
"trigger": "mdi:stop"
},
"turned_off": {
"trigger": "mdi:power"
},
"turned_on": {
"trigger": "mdi:power"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted media players to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"condition_type": {
"is_buffering": "{entity_name} is buffering",
@@ -177,6 +181,13 @@
"off": "[%key:common::state::off%]",
"one": "Repeat one"
}
},
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
@@ -367,5 +378,62 @@
"name": "Turn up volume"
}
},
"title": "Media player"
"title": "Media player",
"triggers": {
"paused_playing": {
"description": "Triggers when a media player paused playing.",
"description_configured": "[%key:component::media_player::triggers::paused_playing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::media_player::common::trigger_behavior_description%]",
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
}
},
"name": "When a media player paused playing"
},
"started_playing": {
"description": "Triggers when a media player started playing.",
"description_configured": "[%key:component::media_player::triggers::started_playing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::media_player::common::trigger_behavior_description%]",
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
}
},
"name": "When a media player started playing"
},
"stopped_playing": {
"description": "Triggers when a media player stops playing.",
"description_configured": "[%key:component::media_player::triggers::stopped_playing::description%]",
"fields": {
"behavior": {
"description": "[%key:component::media_player::common::trigger_behavior_description%]",
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
}
},
"name": "When a media player stops playing"
},
"turned_off": {
"description": "Triggers when a media player turned off.",
"description_configured": "[%key:component::media_player::triggers::turned_off::description%]",
"fields": {
"behavior": {
"description": "[%key:component::media_player::common::trigger_behavior_description%]",
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
}
},
"name": "When a media player turned off"
},
"turned_on": {
"description": "Triggers when a media player turned on.",
"description_configured": "[%key:component::media_player::triggers::turned_on::description%]",
"fields": {
"behavior": {
"description": "[%key:component::media_player::common::trigger_behavior_description%]",
"name": "[%key:component::media_player::common::trigger_behavior_name%]"
}
},
"name": "When a media player turned on"
}
}
}

View File

@@ -0,0 +1,75 @@
"""Provides triggers for media players."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_conditional_entity_state_trigger
from . import MediaPlayerState
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"paused_playing": make_conditional_entity_state_trigger(
DOMAIN,
from_states={
MediaPlayerState.PLAYING,
},
to_states={
MediaPlayerState.PAUSED,
},
),
"started_playing": make_conditional_entity_state_trigger(
DOMAIN,
from_states={
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
},
to_states={
MediaPlayerState.PLAYING,
},
),
"stopped_playing": make_conditional_entity_state_trigger(
DOMAIN,
from_states={
MediaPlayerState.BUFFERING,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
},
to_states={
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
},
),
"turned_off": make_conditional_entity_state_trigger(
DOMAIN,
from_states={
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
},
to_states={
MediaPlayerState.OFF,
},
),
"turned_on": make_conditional_entity_state_trigger(
DOMAIN,
from_states={
MediaPlayerState.OFF,
},
to_states={
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
},
),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for media players."""
return TRIGGERS

View File

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

View File

@@ -11,7 +11,6 @@ from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, PLATFORMS
from .coordinator import (
OhmeAdvancedSettingsCoordinator,
OhmeChargeSessionCoordinator,
OhmeConfigEntry,
OhmeDeviceInfoCoordinator,
@@ -56,7 +55,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: OhmeConfigEntry) -> bool
coordinators = (
OhmeChargeSessionCoordinator(hass, entry, client),
OhmeAdvancedSettingsCoordinator(hass, entry, client),
OhmeDeviceInfoCoordinator(hass, entry, client),
)

View File

@@ -10,7 +10,7 @@ import logging
from ohme import ApiException, OhmeApiClient
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@@ -23,7 +23,6 @@ class OhmeRuntimeData:
"""Dataclass to hold ohme coordinators."""
charge_session_coordinator: OhmeChargeSessionCoordinator
advanced_settings_coordinator: OhmeAdvancedSettingsCoordinator
device_info_coordinator: OhmeDeviceInfoCoordinator
@@ -78,31 +77,6 @@ class OhmeChargeSessionCoordinator(OhmeBaseCoordinator):
await self.client.async_get_charge_session()
class OhmeAdvancedSettingsCoordinator(OhmeBaseCoordinator):
"""Coordinator to pull settings and charger state from the API."""
coordinator_name = "Advanced Settings"
def __init__(
self, hass: HomeAssistant, config_entry: OhmeConfigEntry, client: OhmeApiClient
) -> None:
"""Initialise coordinator."""
super().__init__(hass, config_entry, client)
@callback
def _dummy_listener() -> None:
pass
# This coordinator is used by the API library to determine whether the
# charger is online and available. It is therefore required even if no
# entities are using it.
self.async_add_listener(_dummy_listener)
async def _internal_update_data(self) -> None:
"""Fetch data from API endpoint."""
await self.client.async_get_advanced_settings()
class OhmeDeviceInfoCoordinator(OhmeBaseCoordinator):
"""Coordinator to pull device info and charger settings from the API."""

View File

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

View File

@@ -37,7 +37,7 @@ class OhmeSensorDescription(OhmeEntityDescription, SensorEntityDescription):
value_fn: Callable[[OhmeApiClient], str | int | float | None]
SENSOR_CHARGE_SESSION = [
SENSORS = [
OhmeSensorDescription(
key="status",
translation_key="status",
@@ -91,18 +91,6 @@ SENSOR_CHARGE_SESSION = [
),
]
SENSOR_ADVANCED_SETTINGS = [
OhmeSensorDescription(
key="ct_current",
translation_key="ct_current",
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
value_fn=lambda client: client.power.ct_amps,
is_supported_fn=lambda client: client.ct_connected,
entity_registry_enabled_default=False,
),
]
async def async_setup_entry(
hass: HomeAssistant,
@@ -110,16 +98,11 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up sensors."""
coordinators = config_entry.runtime_data
coordinator_map = [
(SENSOR_CHARGE_SESSION, coordinators.charge_session_coordinator),
(SENSOR_ADVANCED_SETTINGS, coordinators.advanced_settings_coordinator),
]
coordinator = config_entry.runtime_data.charge_session_coordinator
async_add_entities(
OhmeSensor(coordinator, description)
for entities, coordinator in coordinator_map
for description in entities
for description in SENSORS
if description.is_supported_fn(coordinator.client)
)

View File

@@ -41,5 +41,19 @@
"turn_on": {
"service": "mdi:play"
}
},
"triggers": {
"docked": {
"trigger": "mdi:home-import-outline"
},
"errored": {
"trigger": "mdi:alert-circle-outline"
},
"paused_cleaning": {
"trigger": "mdi:pause"
},
"started_cleaning": {
"trigger": "mdi:play"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted vacuum cleaners to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"clean": "Let {entity_name} clean",
@@ -31,6 +35,15 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"clean_spot": {
"description": "Tells the vacuum cleaner to do a spot clean-up.",
@@ -97,5 +110,51 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Vacuum"
"title": "Vacuum",
"triggers": {
"docked": {
"description": "Triggers when a vacuum cleaner has docked.",
"description_configured": "[%key:component::vacuum::triggers::docked::description%]",
"fields": {
"behavior": {
"description": "[%key:component::vacuum::common::trigger_behavior_description%]",
"name": "[%key:component::vacuum::common::trigger_behavior_name%]"
}
},
"name": "When a vacuum cleaner has docked"
},
"errored": {
"description": "Triggers when a vacuum cleaner has errored.",
"description_configured": "[%key:component::vacuum::triggers::errored::description%]",
"fields": {
"behavior": {
"description": "[%key:component::vacuum::common::trigger_behavior_description%]",
"name": "[%key:component::vacuum::common::trigger_behavior_name%]"
}
},
"name": "When a vacuum cleaner has errored"
},
"paused_cleaning": {
"description": "Triggers when a vacuum cleaner has paused cleaning.",
"description_configured": "[%key:component::vacuum::triggers::paused_cleaning::description%]",
"fields": {
"behavior": {
"description": "[%key:component::vacuum::common::trigger_behavior_description%]",
"name": "[%key:component::vacuum::common::trigger_behavior_name%]"
}
},
"name": "When a vacuum cleaner has paused cleaning"
},
"started_cleaning": {
"description": "Triggers when a vacuum cleaner has started cleaning.",
"description_configured": "[%key:component::vacuum::triggers::started_cleaning::description%]",
"fields": {
"behavior": {
"description": "[%key:component::vacuum::common::trigger_behavior_description%]",
"name": "[%key:component::vacuum::common::trigger_behavior_name%]"
}
},
"name": "When a vacuum cleaner has started cleaning"
}
}
}

View File

@@ -0,0 +1,18 @@
"""Provides triggers for vacuum cleaners."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_state_trigger
from .const import DOMAIN, VacuumActivity
TRIGGERS: dict[str, type[Trigger]] = {
"docked": make_entity_state_trigger(DOMAIN, VacuumActivity.DOCKED),
"errored": make_entity_state_trigger(DOMAIN, VacuumActivity.ERROR),
"paused_cleaning": make_entity_state_trigger(DOMAIN, VacuumActivity.PAUSED),
"started_cleaning": make_entity_state_trigger(DOMAIN, VacuumActivity.CLEANING),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for vacuum cleaners."""
return TRIGGERS

View File

@@ -0,0 +1,20 @@
.trigger_common: &trigger_common
target:
entity:
domain: vacuum
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
docked: *trigger_common
errored: *trigger_common
paused_cleaning: *trigger_common
started_cleaning: *trigger_common

View File

@@ -0,0 +1,54 @@
"""The Victron Bluetooth Low Energy integration."""
from __future__ import annotations
import logging
from victron_ble_ha_parser import VictronBluetoothDeviceData
from homeassistant.components.bluetooth import (
BluetoothScanningMode,
async_rediscover_address,
)
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothProcessorCoordinator,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
from homeassistant.core import HomeAssistant
_LOGGER = logging.getLogger(__name__)
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Victron BLE device from a config entry."""
address = entry.unique_id
assert address is not None
key = entry.data[CONF_ACCESS_TOKEN]
data = VictronBluetoothDeviceData(key)
coordinator = PassiveBluetoothProcessorCoordinator(
hass,
_LOGGER,
address=address,
mode=BluetoothScanningMode.ACTIVE,
update_method=data.update,
)
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, [Platform.SENSOR])
entry.async_on_unload(coordinator.async_start())
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload a config entry."""
unload_ok = await hass.config_entries.async_unload_platforms(
entry, [Platform.SENSOR]
)
if unload_ok:
async_rediscover_address(hass, entry.entry_id)
return unload_ok

View File

@@ -0,0 +1,123 @@
"""Config flow for Victron Bluetooth Low Energy integration."""
from __future__ import annotations
import logging
from typing import Any
from victron_ble_ha_parser import VictronBluetoothDeviceData
import voluptuous as vol
from homeassistant.components.bluetooth import (
BluetoothServiceInfoBleak,
async_discovered_service_info,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS
from .const import DOMAIN, VICTRON_IDENTIFIER
_LOGGER = logging.getLogger(__name__)
STEP_ACCESS_TOKEN_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_ACCESS_TOKEN): str,
}
)
class VictronBLEConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Victron Bluetooth Low Energy."""
VERSION = 1
def __init__(self) -> None:
"""Initialize the config flow."""
self._discovered_device: str | None = None
self._discovered_devices: dict[str, str] = {}
self._discovered_devices_info: dict[str, BluetoothServiceInfoBleak] = {}
async def async_step_bluetooth(
self, discovery_info: BluetoothServiceInfoBleak
) -> ConfigFlowResult:
"""Handle the bluetooth discovery step."""
_LOGGER.debug("async_step_bluetooth: %s", discovery_info.address)
await self.async_set_unique_id(discovery_info.address)
self._abort_if_unique_id_configured()
device = VictronBluetoothDeviceData()
if not device.supported(discovery_info):
_LOGGER.debug("device %s not supported", discovery_info.address)
return self.async_abort(reason="not_supported")
self._discovered_device = discovery_info.address
self._discovered_devices_info[discovery_info.address] = discovery_info
self._discovered_devices[discovery_info.address] = discovery_info.name
self.context["title_placeholders"] = {"title": discovery_info.name}
return await self.async_step_access_token()
async def async_step_access_token(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle advertisement key input."""
# should only be called if there are discovered devices
assert self._discovered_device is not None
discovery_info = self._discovered_devices_info[self._discovered_device]
title = discovery_info.name
if user_input is not None:
# see if we can create a device with the access token
device = VictronBluetoothDeviceData(user_input[CONF_ACCESS_TOKEN])
if device.validate_advertisement_key(
discovery_info.manufacturer_data[VICTRON_IDENTIFIER]
):
return self.async_create_entry(
title=title,
data=user_input,
)
return self.async_abort(reason="invalid_access_token")
return self.async_show_form(
step_id="access_token",
data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA,
description_placeholders={"title": title},
)
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle select a device to set up."""
if user_input is not None:
address = user_input[CONF_ADDRESS]
await self.async_set_unique_id(address, raise_on_progress=False)
self._abort_if_unique_id_configured()
self._discovered_device = address
title = self._discovered_devices_info[address].name
return self.async_show_form(
step_id="access_token",
data_schema=STEP_ACCESS_TOKEN_DATA_SCHEMA,
description_placeholders={"title": title},
)
current_addresses = self._async_current_ids()
for discovery_info in async_discovered_service_info(self.hass, False):
address = discovery_info.address
if address in current_addresses or address in self._discovered_devices:
continue
device = VictronBluetoothDeviceData()
if device.supported(discovery_info):
self._discovered_devices_info[address] = discovery_info
self._discovered_devices[address] = discovery_info.name
if len(self._discovered_devices) < 1:
return self.async_abort(reason="no_devices_found")
_LOGGER.debug("Discovered %s devices", len(self._discovered_devices))
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{vol.Required(CONF_ADDRESS): vol.In(self._discovered_devices)}
),
)

View File

@@ -0,0 +1,4 @@
"""Constants for the Victron Bluetooth Low Energy integration."""
DOMAIN = "victron_ble"
VICTRON_IDENTIFIER = 0x02E1

View File

@@ -0,0 +1,19 @@
{
"domain": "victron_ble",
"name": "Victron BLE",
"bluetooth": [
{
"connectable": false,
"manufacturer_data_start": [16],
"manufacturer_id": 737
}
],
"codeowners": ["@rajlaud"],
"config_flow": true,
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/victron_ble",
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "bronze",
"requirements": ["victron-ble-ha-parser==0.4.9"]
}

View File

@@ -0,0 +1,85 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup:
status: exempt
comment: |
There is nothing to test, the integration just passively receives BLE advertisements.
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: This integration does not provide additional actions.
config-entry-unloading: done
docs-configuration-parameters:
status: exempt
comment: No options to configure
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: todo
parallel-updates:
status: done
reauthentication-flow:
status: todo
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not use IP addresses. Bluetooth MAC addresses do not change.
discovery: done
docs-data-update: done
docs-examples: todo
docs-known-limitations: todo
docs-supported-devices: todo
docs-supported-functions: todo
docs-troubleshooting: todo
docs-use-cases: todo
dynamic-devices:
status: exempt
comment: |
This integration has a fixed single device per instance, and each device needs a user-supplied encryption key to set up.
entity-category: done
entity-device-class: done
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices:
status: exempt
comment: |
This integration has a fixed single device.
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo

View File

@@ -0,0 +1,474 @@
"""Sensor platform for Victron BLE."""
from collections.abc import Callable
from dataclasses import dataclass
import logging
from typing import Any
from sensor_state_data import DeviceKey
from victron_ble_ha_parser import Keys, Units
from homeassistant.components.bluetooth.passive_update_processor import (
PassiveBluetoothDataProcessor,
PassiveBluetoothDataUpdate,
PassiveBluetoothEntityKey,
PassiveBluetoothProcessorEntity,
)
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import (
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfElectricCurrent,
UnitOfElectricPotential,
UnitOfEnergy,
UnitOfPower,
UnitOfTemperature,
UnitOfTime,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.sensor import sensor_device_info_to_hass_device_info
LOGGER = logging.getLogger(__name__)
AC_IN_OPTIONS = [
"ac_in_1",
"ac_in_2",
"not_connected",
]
ALARM_OPTIONS = [
"low_voltage",
"high_voltage",
"low_soc",
"low_starter_voltage",
"high_starter_voltage",
"low_temperature",
"high_temperature",
"mid_voltage",
"overload",
"dc_ripple",
"low_v_ac_out",
"high_v_ac_out",
"short_circuit",
"bms_lockout",
]
CHARGER_ERROR_OPTIONS = [
"no_error",
"temperature_battery_high",
"voltage_high",
"remote_temperature_auto_reset",
"remote_temperature_not_auto_reset",
"remote_battery",
"high_ripple",
"temperature_battery_low",
"temperature_charger",
"over_current",
"bulk_time",
"current_sensor",
"internal_temperature",
"fan",
"overheated",
"short_circuit",
"converter_issue",
"over_charge",
"input_voltage",
"input_current",
"input_power",
"input_shutdown_voltage",
"input_shutdown_current",
"input_shutdown_failure",
"inverter_shutdown_pv_isolation",
"inverter_shutdown_ground_fault",
"inverter_overload",
"inverter_temperature",
"inverter_peak_current",
"inverter_output_voltage",
"inverter_self_test",
"inverter_ac",
"communication",
"synchronisation",
"bms",
"network",
"pv_input_shutdown",
"cpu_temperature",
"calibration_lost",
"firmware",
"settings",
"tester_fail",
"internal_dc_voltage",
"self_test",
"internal_supply",
]
def error_to_state(value: float | str | None) -> str | None:
"""Convert error code to state string."""
value_map: dict[Any, str] = {
"internal_supply_a": "internal_supply",
"internal_supply_b": "internal_supply",
"internal_supply_c": "internal_supply",
"internal_supply_d": "internal_supply",
"inverter_shutdown_41": "inverter_shutdown_pv_isolation",
"inverter_shutdown_42": "inverter_shutdown_pv_isolation",
"inverter_shutdown_43": "inverter_shutdown_ground_fault",
"internal_temperature_a": "internal_temperature",
"internal_temperature_b": "internal_temperature",
"inverter_output_voltage_a": "inverter_output_voltage",
"inverter_output_voltage_b": "inverter_output_voltage",
"internal_dc_voltage_a": "internal_dc_voltage",
"internal_dc_voltage_b": "internal_dc_voltage",
"remote_temperature_a": "remote_temperature_auto_reset",
"remote_temperature_b": "remote_temperature_auto_reset",
"remote_temperature_c": "remote_temperature_not_auto_reset",
"remote_battery_a": "remote_battery",
"remote_battery_b": "remote_battery",
"remote_battery_c": "remote_battery",
"pv_input_shutdown_80": "pv_input_shutdown",
"pv_input_shutdown_81": "pv_input_shutdown",
"pv_input_shutdown_82": "pv_input_shutdown",
"pv_input_shutdown_83": "pv_input_shutdown",
"pv_input_shutdown_84": "pv_input_shutdown",
"pv_input_shutdown_85": "pv_input_shutdown",
"pv_input_shutdown_86": "pv_input_shutdown",
"pv_input_shutdown_87": "pv_input_shutdown",
"inverter_self_test_a": "inverter_self_test",
"inverter_self_test_b": "inverter_self_test",
"inverter_self_test_c": "inverter_self_test",
"network_a": "network",
"network_b": "network",
"network_c": "network",
"network_d": "network",
}
return value_map.get(value)
DEVICE_STATE_OPTIONS = [
"off",
"low_power",
"fault",
"bulk",
"absorption",
"float",
"storage",
"equalize_manual",
"inverting",
"power_supply",
"starting_up",
"repeated_absorption",
"recondition",
"battery_safe",
"active",
"external_control",
"not_available",
]
# Coordinator is used to centralize the data updates
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class VictronBLESensorEntityDescription(SensorEntityDescription):
"""Describes Victron BLE sensor entity."""
value_fn: Callable[[float | int | str | None], float | int | str | None] = (
lambda x: x
)
SENSOR_DESCRIPTIONS = {
Keys.AC_IN_POWER: VictronBLESensorEntityDescription(
key=Keys.AC_IN_POWER,
translation_key=Keys.AC_IN_POWER,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.AC_IN_STATE: VictronBLESensorEntityDescription(
key=Keys.AC_IN_STATE,
device_class=SensorDeviceClass.ENUM,
translation_key="ac_in_state",
options=AC_IN_OPTIONS,
),
Keys.AC_OUT_POWER: VictronBLESensorEntityDescription(
key=Keys.AC_OUT_POWER,
translation_key=Keys.AC_OUT_POWER,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.AC_OUT_STATE: VictronBLESensorEntityDescription(
key=Keys.AC_OUT_STATE,
device_class=SensorDeviceClass.ENUM,
translation_key="device_state",
options=DEVICE_STATE_OPTIONS,
),
Keys.ALARM: VictronBLESensorEntityDescription(
key=Keys.ALARM,
device_class=SensorDeviceClass.ENUM,
translation_key="alarm",
options=ALARM_OPTIONS,
),
Keys.BALANCER_STATUS: VictronBLESensorEntityDescription(
key=Keys.BALANCER_STATUS,
device_class=SensorDeviceClass.ENUM,
translation_key="balancer_status",
options=["balanced", "balancing", "imbalance"],
),
Keys.BATTERY_CURRENT: VictronBLESensorEntityDescription(
key=Keys.BATTERY_CURRENT,
translation_key=Keys.BATTERY_CURRENT,
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.BATTERY_TEMPERATURE: VictronBLESensorEntityDescription(
key=Keys.BATTERY_TEMPERATURE,
translation_key=Keys.BATTERY_TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.BATTERY_VOLTAGE: VictronBLESensorEntityDescription(
key=Keys.BATTERY_VOLTAGE,
translation_key=Keys.BATTERY_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.CHARGER_ERROR: VictronBLESensorEntityDescription(
key=Keys.CHARGER_ERROR,
device_class=SensorDeviceClass.ENUM,
translation_key="charger_error",
options=CHARGER_ERROR_OPTIONS,
value_fn=error_to_state,
),
Keys.CONSUMED_AMPERE_HOURS: VictronBLESensorEntityDescription(
key=Keys.CONSUMED_AMPERE_HOURS,
translation_key=Keys.CONSUMED_AMPERE_HOURS,
native_unit_of_measurement=Units.ELECTRIC_CURRENT_FLOW_AMPERE_HOUR,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.CURRENT: VictronBLESensorEntityDescription(
key=Keys.CURRENT,
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.DEVICE_STATE: VictronBLESensorEntityDescription(
key=Keys.DEVICE_STATE,
device_class=SensorDeviceClass.ENUM,
translation_key="device_state",
options=DEVICE_STATE_OPTIONS,
),
Keys.ERROR_CODE: VictronBLESensorEntityDescription(
key=Keys.ERROR_CODE,
device_class=SensorDeviceClass.ENUM,
translation_key="charger_error",
options=CHARGER_ERROR_OPTIONS,
),
Keys.EXTERNAL_DEVICE_LOAD: VictronBLESensorEntityDescription(
key=Keys.EXTERNAL_DEVICE_LOAD,
translation_key=Keys.EXTERNAL_DEVICE_LOAD,
device_class=SensorDeviceClass.CURRENT,
native_unit_of_measurement=UnitOfElectricCurrent.AMPERE,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.INPUT_VOLTAGE: VictronBLESensorEntityDescription(
key=Keys.INPUT_VOLTAGE,
translation_key=Keys.INPUT_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.METER_TYPE: VictronBLESensorEntityDescription(
key=Keys.METER_TYPE,
device_class=SensorDeviceClass.ENUM,
translation_key="meter_type",
options=[
"solar_charger",
"wind_charger",
"shaft_generator",
"alternator",
"fuel_cell",
"water_generator",
"dc_dc_charger",
"ac_charger",
"generic_source",
"generic_load",
"electric_drive",
"fridge",
"water_pump",
"bilge_pump",
"dc_system",
"inverter",
"water_heater",
],
),
Keys.MIDPOINT_VOLTAGE: VictronBLESensorEntityDescription(
key=Keys.MIDPOINT_VOLTAGE,
translation_key=Keys.MIDPOINT_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.OFF_REASON: VictronBLESensorEntityDescription(
key=Keys.OFF_REASON,
device_class=SensorDeviceClass.ENUM,
translation_key="off_reason",
options=[
"no_reason",
"no_input_power",
"switched_off_switch",
"switched_off_register",
"remote_input",
"protection_active",
"pay_as_you_go_out_of_credit",
"bms",
"engine_shutdown",
"analysing_input_voltage",
],
),
Keys.OUTPUT_VOLTAGE: VictronBLESensorEntityDescription(
key=Keys.OUTPUT_VOLTAGE,
translation_key=Keys.OUTPUT_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.REMAINING_MINUTES: VictronBLESensorEntityDescription(
key=Keys.REMAINING_MINUTES,
translation_key=Keys.REMAINING_MINUTES,
device_class=SensorDeviceClass.DURATION,
native_unit_of_measurement=UnitOfTime.MINUTES,
state_class=SensorStateClass.MEASUREMENT,
),
SensorDeviceClass.SIGNAL_STRENGTH: VictronBLESensorEntityDescription(
key=SensorDeviceClass.SIGNAL_STRENGTH.value,
device_class=SensorDeviceClass.SIGNAL_STRENGTH,
native_unit_of_measurement=SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.SOLAR_POWER: VictronBLESensorEntityDescription(
key=Keys.SOLAR_POWER,
translation_key=Keys.SOLAR_POWER,
device_class=SensorDeviceClass.POWER,
native_unit_of_measurement=UnitOfPower.WATT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.STARTER_VOLTAGE: VictronBLESensorEntityDescription(
key=Keys.STARTER_VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.STATE_OF_CHARGE: VictronBLESensorEntityDescription(
key=Keys.STATE_OF_CHARGE,
device_class=SensorDeviceClass.BATTERY,
native_unit_of_measurement=PERCENTAGE,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.TEMPERATURE: VictronBLESensorEntityDescription(
key=Keys.TEMPERATURE,
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.CELSIUS,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.VOLTAGE: VictronBLESensorEntityDescription(
key=Keys.VOLTAGE,
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
),
Keys.WARNING: VictronBLESensorEntityDescription(
key=Keys.WARNING,
device_class=SensorDeviceClass.ENUM,
translation_key="alarm",
options=ALARM_OPTIONS,
),
Keys.YIELD_TODAY: VictronBLESensorEntityDescription(
key=Keys.YIELD_TODAY,
translation_key=Keys.YIELD_TODAY,
device_class=SensorDeviceClass.ENERGY,
native_unit_of_measurement=UnitOfEnergy.WATT_HOUR,
state_class=SensorStateClass.TOTAL_INCREASING,
),
}
for i in range(1, 8):
cell_key = getattr(Keys, f"CELL_{i}_VOLTAGE")
SENSOR_DESCRIPTIONS[cell_key] = VictronBLESensorEntityDescription(
key=cell_key,
translation_key="cell_voltage",
device_class=SensorDeviceClass.VOLTAGE,
native_unit_of_measurement=UnitOfElectricPotential.VOLT,
state_class=SensorStateClass.MEASUREMENT,
)
def _device_key_to_bluetooth_entity_key(
device_key: DeviceKey,
) -> PassiveBluetoothEntityKey:
"""Convert a device key to an entity key."""
return PassiveBluetoothEntityKey(device_key.key, device_key.device_id)
def sensor_update_to_bluetooth_data_update(
sensor_update,
) -> PassiveBluetoothDataUpdate:
"""Convert a sensor update to a bluetooth data update."""
return PassiveBluetoothDataUpdate(
devices={
device_id: sensor_device_info_to_hass_device_info(device_info)
for device_id, device_info in sensor_update.devices.items()
},
entity_descriptions={
_device_key_to_bluetooth_entity_key(device_key): SENSOR_DESCRIPTIONS[
device_key.key
]
for device_key in sensor_update.entity_descriptions
if device_key.key in SENSOR_DESCRIPTIONS
},
entity_data={
_device_key_to_bluetooth_entity_key(device_key): sensor_values.native_value
for device_key, sensor_values in sensor_update.entity_values.items()
if device_key.key in SENSOR_DESCRIPTIONS
},
entity_names={},
)
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up the Victron BLE sensor."""
coordinator = entry.runtime_data
processor = PassiveBluetoothDataProcessor(sensor_update_to_bluetooth_data_update)
entry.async_on_unload(
processor.async_add_entities_listener(
VictronBLESensorEntity, async_add_entities
)
)
entry.async_on_unload(coordinator.async_register_processor(processor))
class VictronBLESensorEntity(PassiveBluetoothProcessorEntity, SensorEntity):
"""Representation of Victron BLE sensor."""
entity_description: VictronBLESensorEntityDescription
@property
def native_value(self) -> float | int | str | None:
"""Return the state of the sensor."""
value = self.processor.entity_data.get(self.entity_key)
return self.entity_description.value_fn(value)

View File

@@ -0,0 +1,234 @@
{
"common": {
"high_voltage": "High voltage",
"low_voltage": "Low voltage",
"midpoint_voltage": "Midpoint voltage",
"starter_voltage": "Starter voltage"
},
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"invalid_access_token": "Invalid encryption key for instant readout",
"no_devices_found": "[%key:common::config_flow::abort::no_devices_found%]"
},
"flow_title": "{title}",
"step": {
"access_token": {
"data": {
"access_token": "The encryption key for instant readout of the Victron device."
},
"data_description": {
"access_token": "The encryption key for instant readout may be found in the VictronConnect app under Settings > Product info > Instant readout details > Encryption data > Encryption Key."
},
"title": "{title}"
},
"user": {
"data": {
"address": "The Bluetooth address of the Victron device."
},
"data_description": {
"address": "This Bluetooth address is automatically discovered. You may view a device's Bluetooth address in the VictronConnect app under Settings > Product info > Instant readout details > Encryption data > MAC Address."
}
}
}
},
"entity": {
"sensor": {
"ac_in_power": {
"name": "AC-in power"
},
"ac_in_state": {
"name": "AC-in state",
"state": {
"ac_in_1": "AC-in 1",
"ac_in_2": "AC-in 2",
"not_connected": "Not connected"
}
},
"ac_out_power": {
"name": "AC-out power"
},
"alarm": {
"name": "Alarm",
"state": {
"bms_lockout": "Battery management system lockout",
"dc_ripple": "DC ripple",
"high_starter_voltage": "High starter voltage",
"high_temperature": "High temperature",
"high_v_ac_out": "AC-out overvoltage",
"high_voltage": "Overvoltage",
"low_soc": "Low state of charge",
"low_starter_voltage": "Low starter voltage",
"low_temperature": "Low temperature",
"low_v_ac_out": "AC-out undervoltage",
"low_voltage": "Undervoltage",
"mid_voltage": "[%key:component::victron_ble::common::midpoint_voltage%]",
"overload": "Overload",
"short_circuit": "Short circuit"
}
},
"balancer_status": {
"name": "Balancer status",
"state": {
"balanced": "Balanced",
"balancing": "Balancing",
"imbalance": "Imbalance"
}
},
"battery_current": {
"name": "Battery current"
},
"battery_temperature": {
"name": "Battery temperature"
},
"battery_voltage": {
"name": "Battery voltage"
},
"cell_voltage": {
"name": "Cell {cell} voltage"
},
"charger_error": {
"name": "Charger error",
"state": {
"bms": "BMS connection lost",
"bulk_time": "Bulk time limit exceeded",
"calibration_lost": "Factory calibration data lost",
"communication": "Communication warning",
"converter_issue": "Converter issue",
"cpu_temperature": "CPU temperature too high",
"current_sensor": "Current sensor issue",
"fan": "Fan failure",
"firmware": "Invalid or incompatible firmware",
"high_ripple": "Battery high ripple voltage",
"input_current": "Input overcurrent",
"input_power": "Input overpower",
"input_shutdown_current": "Input shutdown (current flow during off mode)",
"input_shutdown_failure": "PV input failed to shutdown",
"input_shutdown_voltage": "Input shutdown (battery overvoltage)",
"input_voltage": "Input overvoltage",
"internal_dc_voltage": "Internal DC voltage error",
"internal_supply": "Internal supply voltage error",
"internal_temperature": "Internal temperature sensor failure",
"inverter_ac": "Inverter AC voltage on output",
"inverter_output_voltage": "Inverter output voltage",
"inverter_overload": "Inverter overload",
"inverter_peak_current": "Inverter peak current",
"inverter_self_test": "Inverter self-test failed",
"inverter_shutdown_ground_fault": "Inverter shutdown (Ground fault)",
"inverter_shutdown_pv_isolation": "Inverter shutdown (PV isolation)",
"inverter_temperature": "Inverter temperature too high",
"network": "Network misconfigured",
"no_error": "No error",
"over_charge": "Overcharge protection",
"over_current": "Charger overcurrent",
"overheated": "Terminals overheated",
"pv_input_shutdown": "PV input shutdown",
"remote_battery": "Remote battery voltage sense failure",
"remote_temperature_auto_reset": "Remote temperature sensor failure (auto-reset)",
"remote_temperature_not_auto_reset": "Remote temperature sensor failure (not auto-reset)",
"self_test": "PV residual current sensor self-test failure",
"settings": "Settings data lost",
"short_circuit": "Charger short circuit",
"synchronisation": "Synchronized charging device configuration issue",
"temperature_battery_high": "Battery temperature too high",
"temperature_battery_low": "Battery temperature too low",
"temperature_charger": "Charger temperature too high",
"tester_fail": "Tester fail",
"voltage_high": "Battery overvoltage"
}
},
"consumed_ampere_hours": {
"name": "Consumed ampere hours"
},
"device_state": {
"name": "Device state",
"state": {
"absorption": "Absorption",
"active": "Active",
"battery_safe": "Battery safe",
"bulk": "Bulk",
"equalize_manual": "Equalize (manual)",
"external_control": "External control",
"fault": "Fault",
"float": "Float",
"inverting": "Inverting",
"low_power": "Low power",
"not_available": "Not available",
"off": "[%key:common::state::off%]",
"power_supply": "Power supply",
"recondition": "Recondition",
"repeated_absorption": "Repeated absorption",
"starting_up": "Starting up",
"storage": "Storage"
}
},
"error_code": {
"name": "Error code"
},
"external_device_load": {
"name": "External device load"
},
"input_voltage": {
"name": "Input voltage"
},
"meter_type": {
"name": "Meter type",
"state": {
"ac_charger": "AC charger",
"alternator": "Alternator",
"bilge_pump": "Bilge pump",
"dc_dc_charger": "DC-DC charger",
"dc_system": "DC system",
"electric_drive": "Electric drive",
"fridge": "Fridge",
"fuel_cell": "Fuel cell",
"generic_load": "Generic load",
"generic_source": "Generic source",
"inverter": "Inverter",
"shaft_generator": "Shaft generator",
"solar_charger": "Solar charger",
"water_generator": "Water generator",
"water_heater": "Water heater",
"water_pump": "Water pump",
"wind_charger": "Wind charger"
}
},
"midpoint_voltage": {
"name": "[%key:component::victron_ble::common::midpoint_voltage%]"
},
"off_reason": {
"name": "Off reason",
"state": {
"analysing_input_voltage": "Analyzing input voltage",
"bms": "Battery management system",
"engine_shutdown": "Engine shutdown",
"no_input_power": "No input power",
"no_reason": "No reason",
"pay_as_you_go_out_of_credit": "Pay-as-you-go out of credit",
"protection_active": "Protection active",
"remote_input": "Remote input",
"switched_off_register": "Switched off by register",
"switched_off_switch": "Switched off by switch"
}
},
"output_voltage": {
"name": "Output voltage"
},
"remaining_minutes": {
"name": "Remaining minutes"
},
"solar_power": {
"name": "Solar power"
},
"starter_voltage": {
"name": "[%key:component::victron_ble::common::starter_voltage%]"
},
"warning": {
"name": "Warning"
},
"yield_today": {
"name": "Yield today"
}
}
}
}

View File

@@ -849,6 +849,14 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"manufacturer_id": 34714,
"service_uuid": "0000cee0-0000-1000-8000-00805f9b34fb",
},
{
"connectable": False,
"domain": "victron_ble",
"manufacturer_data_start": [
16,
],
"manufacturer_id": 737,
},
{
"connectable": False,
"domain": "xiaomi_ble",

View File

@@ -723,6 +723,7 @@ FLOWS = {
"version",
"vesync",
"vicare",
"victron_ble",
"victron_remote_monitoring",
"vilfo",
"vizio",

View File

@@ -7282,11 +7282,22 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"victron_remote_monitoring": {
"name": "Victron Remote Monitoring",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
"victron": {
"name": "Victron",
"integrations": {
"victron_ble": {
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push",
"name": "Victron BLE"
},
"victron_remote_monitoring": {
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling",
"name": "Victron Remote Monitoring"
}
}
},
"vilfo": {
"name": "Vilfo Router",

View File

@@ -289,7 +289,7 @@ class TargetStateChangeTracker:
)
tracked_entities = self._entity_filter(
selected.referenced.union(selected.indirectly_referenced)
selected.referenced | selected.indirectly_referenced
)
@callback

View File

@@ -10,11 +10,12 @@ from dataclasses import dataclass, field
import functools
import inspect
import logging
from typing import TYPE_CHECKING, Any, Protocol, TypedDict, cast
from typing import TYPE_CHECKING, Any, Final, Protocol, TypedDict, cast, override
import voluptuous as vol
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_ALIAS,
CONF_ENABLED,
CONF_ID,
@@ -23,6 +24,8 @@ from homeassistant.const import (
CONF_SELECTOR,
CONF_TARGET,
CONF_VARIABLES,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import (
CALLBACK_TYPE,
@@ -30,9 +33,11 @@ from homeassistant.core import (
HassJob,
HassJobType,
HomeAssistant,
State,
callback,
get_hassjob_callable_job_type,
is_callback,
split_entity_id,
)
from homeassistant.exceptions import HomeAssistantError, TemplateError
from homeassistant.loader import (
@@ -49,6 +54,10 @@ from . import config_validation as cv, selector
from .automation import get_absolute_description_key, get_relative_description_key
from .integration_platform import async_process_integration_platforms
from .selector import TargetSelector
from .target import (
TargetStateChangedData,
async_track_target_selector_state_change_event,
)
from .template import Template
from .typing import ConfigType, TemplateVarsType
@@ -245,6 +254,217 @@ class Trigger(abc.ABC):
"""Attach the trigger to an action runner."""
ATTR_BEHAVIOR: Final = "behavior"
BEHAVIOR_FIRST: Final = "first"
BEHAVIOR_LAST: Final = "last"
BEHAVIOR_ANY: Final = "any"
ENTITY_STATE_TRIGGER_SCHEMA = vol.Schema(
{
vol.Required(CONF_OPTIONS): {
vol.Required(ATTR_BEHAVIOR, default=BEHAVIOR_ANY): vol.In(
[BEHAVIOR_FIRST, BEHAVIOR_LAST, BEHAVIOR_ANY]
),
},
vol.Required(CONF_TARGET): cv.TARGET_FIELDS,
}
)
class EntityTriggerBase(Trigger):
"""Trigger for entity state changes."""
_domain: str
_schema: vol.Schema = ENTITY_STATE_TRIGGER_SCHEMA
@override
@classmethod
async def async_validate_config(
cls, hass: HomeAssistant, config: ConfigType
) -> ConfigType:
"""Validate config."""
return cast(ConfigType, cls._schema(config))
def __init__(self, hass: HomeAssistant, config: TriggerConfig) -> None:
"""Initialize the state trigger."""
super().__init__(hass, config)
if TYPE_CHECKING:
assert config.options is not None
assert config.target is not None
self._options = config.options
self._target = config.target
def is_from_state(self, state: State) -> bool:
"""Check if the state matches the origin state."""
return not self.is_to_state(state)
@abc.abstractmethod
def is_to_state(self, state: State) -> bool:
"""Check if the state matches the target state."""
def check_all_match(self, entity_ids: set[str]) -> bool:
"""Check if all entity states match."""
return all(
self.is_to_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
)
def check_one_match(self, entity_ids: set[str]) -> bool:
"""Check that only one entity state matches."""
return (
sum(
self.is_to_state(state)
for entity_id in entity_ids
if (state := self._hass.states.get(entity_id)) is not None
)
== 1
)
def entity_filter(self, entities: set[str]) -> set[str]:
"""Filter entities of this domain."""
return {
entity_id
for entity_id in entities
if split_entity_id(entity_id)[0] == self._domain
}
@override
async def async_attach_runner(
self, run_action: TriggerActionRunner
) -> CALLBACK_TYPE:
"""Attach the trigger to an action runner."""
behavior = self._options.get(ATTR_BEHAVIOR)
@callback
def state_change_listener(
target_state_change_data: TargetStateChangedData,
) -> None:
"""Listen for state changes and call action."""
event = target_state_change_data.state_change_event
entity_id = event.data["entity_id"]
from_state = event.data["old_state"]
to_state = event.data["new_state"]
# The trigger should never fire if the previous state was not a valid state
if not from_state or from_state.state in (STATE_UNAVAILABLE, STATE_UNKNOWN):
return
# The trigger should never fire if the previous state was not the from state
if not self.is_from_state(from_state):
return
# The trigger should never fire if the new state is not the to state
if not to_state or not self.is_to_state(to_state):
return
if behavior == BEHAVIOR_LAST:
if not self.check_all_match(
target_state_change_data.targeted_entity_ids
):
return
elif behavior == BEHAVIOR_FIRST:
if not self.check_one_match(
target_state_change_data.targeted_entity_ids
):
return
run_action(
{
ATTR_ENTITY_ID: entity_id,
"from_state": from_state,
"to_state": to_state,
},
f"state of {entity_id}",
event.context,
)
return async_track_target_selector_state_change_event(
self._hass, self._target, state_change_listener, self.entity_filter
)
class EntityStateTriggerBase(EntityTriggerBase):
"""Trigger for entity state changes."""
_to_state: str
def is_to_state(self, state: State) -> bool:
"""Check if the state matches the target state."""
return state.state == self._to_state
class ConditionalEntityStateTriggerBase(EntityTriggerBase):
"""Class for entity state changes where the from state is restricted."""
_from_states: set[str]
_to_states: set[str]
def is_from_state(self, state: State) -> bool:
"""Check if the state matches the origin state."""
return state.state in self._from_states
def is_to_state(self, state: State) -> bool:
"""Check if the state matches the target state."""
return state.state in self._to_states
class EntityStateAttributeTriggerBase(EntityTriggerBase):
"""Trigger for entity state attribute changes."""
_attribute: str
_attribute_to_state: str
def is_to_state(self, state: State) -> bool:
"""Check if the state matches the target state."""
return state.attributes.get(self._attribute) == self._attribute_to_state
def make_entity_state_trigger(
domain: str, to_state: str
) -> type[EntityStateTriggerBase]:
"""Create an entity state trigger class."""
class CustomTrigger(EntityStateTriggerBase):
"""Trigger for entity state changes."""
_domain = domain
_to_state = to_state
return CustomTrigger
def make_conditional_entity_state_trigger(
domain: str, *, from_states: set[str], to_states: set[str]
) -> type[ConditionalEntityStateTriggerBase]:
"""Create a conditional entity state trigger class."""
class CustomTrigger(ConditionalEntityStateTriggerBase):
"""Trigger for conditional entity state changes."""
_domain = domain
_from_states = from_states
_to_states = to_states
return CustomTrigger
def make_entity_state_attribute_trigger(
domain: str, attribute: str, to_state: str
) -> type[EntityStateAttributeTriggerBase]:
"""Create an entity state attribute trigger class."""
class CustomTrigger(EntityStateAttributeTriggerBase):
"""Trigger for entity state changes."""
_domain = domain
_attribute = attribute
_attribute_to_state = to_state
return CustomTrigger
class TriggerProtocol(Protocol):
"""Define the format of trigger modules.

9
requirements_all.txt generated
View File

@@ -209,7 +209,7 @@ aioaseko==1.0.0
aioasuswrt==1.5.1
# homeassistant.components.husqvarna_automower
aioautomower==2.7.0
aioautomower==2.7.1
# homeassistant.components.azure_devops
aioazuredevops==2.2.2
@@ -1601,7 +1601,7 @@ odp-amsterdam==6.1.2
oemthermostat==1.1.1
# homeassistant.components.ohme
ohme==1.5.2
ohme==1.6.0
# homeassistant.components.ollama
ollama==0.5.1
@@ -2269,7 +2269,7 @@ pypaperless==4.1.1
pypca==0.0.7
# homeassistant.components.lcn
pypck==0.9.3
pypck==0.9.4
# homeassistant.components.pglab
pypglab==0.0.5
@@ -3088,6 +3088,9 @@ velbus-aio==2025.11.0
# homeassistant.components.venstar
venstarcolortouch==0.21
# homeassistant.components.victron_ble
victron-ble-ha-parser==0.4.9
# homeassistant.components.victron_remote_monitoring
victron-vrm==0.1.8

View File

@@ -197,7 +197,7 @@ aioaseko==1.0.0
aioasuswrt==1.5.1
# homeassistant.components.husqvarna_automower
aioautomower==2.7.0
aioautomower==2.7.1
# homeassistant.components.azure_devops
aioazuredevops==2.2.2
@@ -1372,7 +1372,7 @@ objgraph==3.5.0
odp-amsterdam==6.1.2
# homeassistant.components.ohme
ohme==1.5.2
ohme==1.6.0
# homeassistant.components.ollama
ollama==0.5.1
@@ -1892,7 +1892,7 @@ pypalazzetti==0.1.20
pypaperless==4.1.1
# homeassistant.components.lcn
pypck==0.9.3
pypck==0.9.4
# homeassistant.components.pglab
pypglab==0.0.5
@@ -2555,6 +2555,9 @@ velbus-aio==2025.11.0
# homeassistant.components.venstar
venstarcolortouch==0.21
# homeassistant.components.victron_ble
victron-ble-ha-parser==0.4.9
# homeassistant.components.victron_remote_monitoring
victron-vrm==0.1.8

View File

@@ -1608,12 +1608,16 @@ def mock_integration(
top_level_files: set[str] | None = None,
) -> loader.Integration:
"""Mock an integration."""
integration = loader.Integration(
hass,
path = (
f"{loader.PACKAGE_BUILTIN}.{module.DOMAIN}"
if built_in
else f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{module.DOMAIN}",
pathlib.Path(""),
else f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{module.DOMAIN}"
)
integration = loader.Integration(
hass,
path,
pathlib.Path(path.replace(".", "/")),
module.mock_manifest(),
top_level_files,
)

View File

@@ -1 +1,280 @@
"""The tests for components."""
from enum import StrEnum
import itertools
from typing import TypedDict
from homeassistant.const import (
ATTR_AREA_ID,
ATTR_DEVICE_ID,
ATTR_FLOOR_ID,
ATTR_LABEL_ID,
CONF_ENTITY_ID,
CONF_OPTIONS,
CONF_PLATFORM,
CONF_TARGET,
STATE_UNAVAILABLE,
STATE_UNKNOWN,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import (
area_registry as ar,
device_registry as dr,
entity_registry as er,
floor_registry as fr,
label_registry as lr,
)
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry, mock_device_registry
async def target_entities(hass: HomeAssistant, domain: str) -> None:
"""Create multiple entities associated with different targets."""
await async_setup_component(hass, domain, {})
config_entry = MockConfigEntry(domain="test")
config_entry.add_to_hass(hass)
floor_reg = fr.async_get(hass)
floor = floor_reg.async_create("Test Floor")
area_reg = ar.async_get(hass)
area = area_reg.async_create("Test Area", floor_id=floor.floor_id)
label_reg = lr.async_get(hass)
label = label_reg.async_create("Test Label")
device = dr.DeviceEntry(id="test_device", area_id=area.id, labels={label.label_id})
mock_device_registry(hass, {device.id: device})
entity_reg = er.async_get(hass)
# Entity associated with area
entity_area = entity_reg.async_get_or_create(
domain=domain,
platform="test",
unique_id=f"{domain}_area",
suggested_object_id=f"area_{domain}",
)
entity_reg.async_update_entity(entity_area.entity_id, area_id=area.id)
# Entity associated with device
entity_reg.async_get_or_create(
domain=domain,
platform="test",
unique_id=f"{domain}_device",
suggested_object_id=f"device_{domain}",
device_id=device.id,
)
# Entity associated with label
entity_label = entity_reg.async_get_or_create(
domain=domain,
platform="test",
unique_id=f"{domain}_label",
suggested_object_id=f"label_{domain}",
)
entity_reg.async_update_entity(entity_label.entity_id, labels={label.label_id})
# Return all available entities
return [
f"{domain}.standalone_{domain}",
f"{domain}.label_{domain}",
f"{domain}.area_{domain}",
f"{domain}.device_{domain}",
]
def parametrize_target_entities(domain: str) -> list[tuple[dict, str, int]]:
"""Parametrize target entities for different target types.
Meant to be used with target_entities.
"""
return [
(
{CONF_ENTITY_ID: f"{domain}.standalone_{domain}"},
f"{domain}.standalone_{domain}",
1,
),
({ATTR_LABEL_ID: "test_label"}, f"{domain}.label_{domain}", 2),
({ATTR_AREA_ID: "test_area"}, f"{domain}.area_{domain}", 2),
({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.area_{domain}", 2),
({ATTR_LABEL_ID: "test_label"}, f"{domain}.device_{domain}", 2),
({ATTR_AREA_ID: "test_area"}, f"{domain}.device_{domain}", 2),
({ATTR_FLOOR_ID: "test_floor"}, f"{domain}.device_{domain}", 2),
({ATTR_DEVICE_ID: "test_device"}, f"{domain}.device_{domain}", 1),
]
class StateDescription(TypedDict):
"""Test state and expected service call count."""
state: str | None
attributes: dict
count: int
def parametrize_trigger_states(
*,
trigger: str,
target_states: list[str | None | tuple[str | None, dict]],
other_states: list[str | None | tuple[str | None, dict]],
additional_attributes: dict | None = None,
trigger_from_none: bool = True,
) -> list[tuple[str, list[StateDescription]]]:
"""Parametrize states and expected service call counts.
The target_states and other_states iterables are either iterables of
states or iterables of (state, attributes) tuples.
Set `trigger_from_none` to False if the trigger is not expected to fire
when the initial state is None.
Returns a list of tuples with (trigger, list of states),
where states is a list of StateDescription dicts.
"""
additional_attributes = additional_attributes or {}
def state_with_attributes(
state: str | None | tuple[str | None, dict], count: int
) -> dict:
"""Return (state, attributes) dict."""
if isinstance(state, str) or state is None:
return {"state": state, "attributes": additional_attributes, "count": count}
return {
"state": state[0],
"attributes": state[1] | additional_attributes,
"count": count,
}
return [
# Initial state None
(
trigger,
list(
itertools.chain.from_iterable(
(
state_with_attributes(None, 0),
state_with_attributes(target_state, 0),
state_with_attributes(other_state, 0),
state_with_attributes(
target_state, 1 if trigger_from_none else 0
),
)
for target_state in target_states
for other_state in other_states
)
),
),
# Initial state different from target state
(
trigger,
# other_state,
list(
itertools.chain.from_iterable(
(
state_with_attributes(other_state, 0),
state_with_attributes(target_state, 1),
state_with_attributes(other_state, 0),
state_with_attributes(target_state, 1),
)
for target_state in target_states
for other_state in other_states
)
),
),
# Initial state same as target state
(
trigger,
list(
itertools.chain.from_iterable(
(
state_with_attributes(target_state, 0),
state_with_attributes(target_state, 0),
state_with_attributes(other_state, 0),
state_with_attributes(target_state, 1),
)
for target_state in target_states
for other_state in other_states
)
),
),
# Initial state unavailable / unknown
(
trigger,
list(
itertools.chain.from_iterable(
(
state_with_attributes(STATE_UNAVAILABLE, 0),
state_with_attributes(target_state, 0),
state_with_attributes(other_state, 0),
state_with_attributes(target_state, 1),
)
for target_state in target_states
for other_state in other_states
)
),
),
(
trigger,
list(
itertools.chain.from_iterable(
(
state_with_attributes(STATE_UNKNOWN, 0),
state_with_attributes(target_state, 0),
state_with_attributes(other_state, 0),
state_with_attributes(target_state, 1),
)
for target_state in target_states
for other_state in other_states
)
),
),
]
async def arm_trigger(
hass: HomeAssistant, trigger: str, trigger_options: dict, trigger_target: dict
) -> None:
"""Arm the specified trigger, call service test.automation when it triggers."""
# Local include to avoid importing the automation component unnecessarily
from homeassistant.components import automation # noqa: PLC0415
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: trigger,
CONF_OPTIONS: {**trigger_options},
CONF_TARGET: {**trigger_target},
},
"action": {
"service": "test.automation",
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
},
}
},
)
def set_or_remove_state(
hass: HomeAssistant,
entity_id: str,
state: StateDescription,
) -> None:
"""Set or remove the state of an entity."""
if state["state"] is None:
hass.states.async_remove(entity_id)
else:
hass.states.async_set(
entity_id, state["state"], state["attributes"], force_update=True
)
def other_states(state: StrEnum) -> list[str]:
"""Return a sorted list with all states except the specified one."""
return sorted({s.value for s in state.__class__} - {state.value})

View File

@@ -0,0 +1,359 @@
"""Test alarm control panel triggers."""
import pytest
from homeassistant.components.alarm_control_panel import (
AlarmControlPanelEntityFeature,
AlarmControlPanelState,
)
from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.components import (
StateDescription,
arm_trigger,
other_states,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def target_alarm_control_panels(hass: HomeAssistant) -> None:
"""Create multiple alarm control panel entities associated with different targets."""
return await target_entities(hass, "alarm_control_panel")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("alarm_control_panel"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="alarm_control_panel.armed",
target_states=[
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
],
other_states=[
AlarmControlPanelState.ARMING,
AlarmControlPanelState.DISARMED,
AlarmControlPanelState.DISARMING,
AlarmControlPanelState.PENDING,
AlarmControlPanelState.TRIGGERED,
],
),
*parametrize_trigger_states(
trigger="alarm_control_panel.armed_away",
target_states=[AlarmControlPanelState.ARMED_AWAY],
other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="alarm_control_panel.armed_home",
target_states=[AlarmControlPanelState.ARMED_HOME],
other_states=other_states(AlarmControlPanelState.ARMED_HOME),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="alarm_control_panel.armed_night",
target_states=[AlarmControlPanelState.ARMED_NIGHT],
other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="alarm_control_panel.armed_vacation",
target_states=[AlarmControlPanelState.ARMED_VACATION],
other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="alarm_control_panel.disarmed",
target_states=[AlarmControlPanelState.DISARMED],
other_states=other_states(AlarmControlPanelState.DISARMED),
),
*parametrize_trigger_states(
trigger="alarm_control_panel.triggered",
target_states=[AlarmControlPanelState.TRIGGERED],
other_states=other_states(AlarmControlPanelState.TRIGGERED),
),
],
)
async def test_alarm_control_panel_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_alarm_control_panels: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the alarm control panel state trigger fires when any alarm control panel state changes to a specific state."""
await async_setup_component(hass, "alarm_control_panel", {})
other_entity_ids = set(target_alarm_control_panels) - {entity_id}
# Set all alarm control panels, including the tested one, to the initial state
for eid in target_alarm_control_panels:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other alarm control panels also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("alarm_control_panel"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="alarm_control_panel.armed",
target_states=[
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
],
other_states=[
AlarmControlPanelState.ARMING,
AlarmControlPanelState.DISARMED,
AlarmControlPanelState.DISARMING,
AlarmControlPanelState.PENDING,
AlarmControlPanelState.TRIGGERED,
],
),
*parametrize_trigger_states(
trigger="alarm_control_panel.armed_away",
target_states=[AlarmControlPanelState.ARMED_AWAY],
other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="alarm_control_panel.armed_home",
target_states=[AlarmControlPanelState.ARMED_HOME],
other_states=other_states(AlarmControlPanelState.ARMED_HOME),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="alarm_control_panel.armed_night",
target_states=[AlarmControlPanelState.ARMED_NIGHT],
other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="alarm_control_panel.armed_vacation",
target_states=[AlarmControlPanelState.ARMED_VACATION],
other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="alarm_control_panel.disarmed",
target_states=[AlarmControlPanelState.DISARMED],
other_states=other_states(AlarmControlPanelState.DISARMED),
),
*parametrize_trigger_states(
trigger="alarm_control_panel.triggered",
target_states=[AlarmControlPanelState.TRIGGERED],
other_states=other_states(AlarmControlPanelState.TRIGGERED),
),
],
)
async def test_alarm_control_panel_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_alarm_control_panels: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the alarm control panel state trigger fires when the first alarm control panel changes to a specific state."""
await async_setup_component(hass, "alarm_control_panel", {})
other_entity_ids = set(target_alarm_control_panels) - {entity_id}
# Set all alarm control panels, including the tested one, to the initial state
for eid in target_alarm_control_panels:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other alarm control panels should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("alarm_control_panel"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="alarm_control_panel.armed",
target_states=[
AlarmControlPanelState.ARMED_AWAY,
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
AlarmControlPanelState.ARMED_HOME,
AlarmControlPanelState.ARMED_NIGHT,
AlarmControlPanelState.ARMED_VACATION,
],
other_states=[
AlarmControlPanelState.ARMING,
AlarmControlPanelState.DISARMED,
AlarmControlPanelState.DISARMING,
AlarmControlPanelState.PENDING,
AlarmControlPanelState.TRIGGERED,
],
),
*parametrize_trigger_states(
trigger="alarm_control_panel.armed_away",
target_states=[AlarmControlPanelState.ARMED_AWAY],
other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="alarm_control_panel.armed_home",
target_states=[AlarmControlPanelState.ARMED_HOME],
other_states=other_states(AlarmControlPanelState.ARMED_HOME),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="alarm_control_panel.armed_night",
target_states=[AlarmControlPanelState.ARMED_NIGHT],
other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="alarm_control_panel.armed_vacation",
target_states=[AlarmControlPanelState.ARMED_VACATION],
other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
additional_attributes={
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
},
trigger_from_none=False,
),
*parametrize_trigger_states(
trigger="alarm_control_panel.disarmed",
target_states=[AlarmControlPanelState.DISARMED],
other_states=other_states(AlarmControlPanelState.DISARMED),
),
*parametrize_trigger_states(
trigger="alarm_control_panel.triggered",
target_states=[AlarmControlPanelState.TRIGGERED],
other_states=other_states(AlarmControlPanelState.TRIGGERED),
),
],
)
async def test_alarm_control_panel_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_alarm_control_panels: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the alarm_control_panel state trigger fires when the last alarm_control_panel changes to a specific state."""
await async_setup_component(hass, "alarm_control_panel", {})
other_entity_ids = set(target_alarm_control_panels) - {entity_id}
# Set all alarm control panels, including the tested one, to the initial state
for eid in target_alarm_control_panels:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -0,0 +1,227 @@
"""Test assist satellite triggers."""
import pytest
from homeassistant.components.assist_satellite.entity import AssistSatelliteState
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.components import (
StateDescription,
arm_trigger,
other_states,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def target_assist_satellites(hass: HomeAssistant) -> None:
"""Create multiple assist satellite entities associated with different targets."""
return await target_entities(hass, "assist_satellite")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("assist_satellite"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="assist_satellite.idle",
target_states=[AssistSatelliteState.IDLE],
other_states=other_states(AssistSatelliteState.IDLE),
),
*parametrize_trigger_states(
trigger="assist_satellite.listening",
target_states=[AssistSatelliteState.LISTENING],
other_states=other_states(AssistSatelliteState.LISTENING),
),
*parametrize_trigger_states(
trigger="assist_satellite.processing",
target_states=[AssistSatelliteState.PROCESSING],
other_states=other_states(AssistSatelliteState.PROCESSING),
),
*parametrize_trigger_states(
trigger="assist_satellite.responding",
target_states=[AssistSatelliteState.RESPONDING],
other_states=other_states(AssistSatelliteState.RESPONDING),
),
],
)
async def test_assist_satellite_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_assist_satellites: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the assist satellite state trigger fires when any assist satellite state changes to a specific state."""
await async_setup_component(hass, "assist_satellite", {})
other_entity_ids = set(target_assist_satellites) - {entity_id}
# Set all assist satellites, including the tested one, to the initial state
for eid in target_assist_satellites:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other assist satellites also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("assist_satellite"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="assist_satellite.idle",
target_states=[AssistSatelliteState.IDLE],
other_states=other_states(AssistSatelliteState.IDLE),
),
*parametrize_trigger_states(
trigger="assist_satellite.listening",
target_states=[AssistSatelliteState.LISTENING],
other_states=other_states(AssistSatelliteState.LISTENING),
),
*parametrize_trigger_states(
trigger="assist_satellite.processing",
target_states=[AssistSatelliteState.PROCESSING],
other_states=other_states(AssistSatelliteState.PROCESSING),
),
*parametrize_trigger_states(
trigger="assist_satellite.responding",
target_states=[AssistSatelliteState.RESPONDING],
other_states=other_states(AssistSatelliteState.RESPONDING),
),
],
)
async def test_assist_satellite_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_assist_satellites: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the assist satellite state trigger fires when the first assist satellite changes to a specific state."""
await async_setup_component(hass, "assist_satellite", {})
other_entity_ids = set(target_assist_satellites) - {entity_id}
# Set all assist satellites, including the tested one, to the initial state
for eid in target_assist_satellites:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other assist satellites should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("assist_satellite"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="assist_satellite.idle",
target_states=[AssistSatelliteState.IDLE],
other_states=other_states(AssistSatelliteState.IDLE),
),
*parametrize_trigger_states(
trigger="assist_satellite.listening",
target_states=[AssistSatelliteState.LISTENING],
other_states=other_states(AssistSatelliteState.LISTENING),
),
*parametrize_trigger_states(
trigger="assist_satellite.processing",
target_states=[AssistSatelliteState.PROCESSING],
other_states=other_states(AssistSatelliteState.PROCESSING),
),
*parametrize_trigger_states(
trigger="assist_satellite.responding",
target_states=[AssistSatelliteState.RESPONDING],
other_states=other_states(AssistSatelliteState.RESPONDING),
),
],
)
async def test_assist_satellite_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_assist_satellites: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the assist_satellite state trigger fires when the last assist_satellite changes to a specific state."""
await async_setup_component(hass, "assist_satellite", {})
other_entity_ids = set(target_assist_satellites) - {entity_id}
# Set all assist satellites, including the tested one, to the initial state
for eid in target_assist_satellites:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -0,0 +1,338 @@
"""Test climate trigger."""
import pytest
from homeassistant.components.climate.const import (
ATTR_HVAC_ACTION,
HVACAction,
HVACMode,
)
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.components import (
StateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def target_climates(hass: HomeAssistant) -> None:
"""Create multiple climate entities associated with different targets."""
return await target_entities(hass, "climate")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="climate.turned_off",
target_states=[HVACMode.OFF],
other_states=[HVACMode.HEAT],
),
],
)
async def test_climate_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_climates: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the climate state trigger fires when any climate state changes to a specific state."""
await async_setup_component(hass, "climate", {})
other_entity_ids = set(target_climates) - {entity_id}
# Set all climates, including the tested climate, to the initial state
for eid in target_climates:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other climates also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="climate.started_heating",
target_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
other_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
)
],
)
async def test_climate_state_attribute_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_climates: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the climate state trigger fires when any climate state changes to a specific state."""
await async_setup_component(hass, "climate", {})
other_entity_ids = set(target_climates) - {entity_id}
# Set all climates, including the tested climate, to the initial state
for eid in target_climates:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other climates also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="climate.turned_off",
target_states=[HVACMode.OFF],
other_states=[HVACMode.HEAT],
),
],
)
async def test_climate_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_climates: list[str],
trigger_target_config: dict,
entities_in_target: int,
entity_id: str,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the climate state trigger fires when the first climate changes to a specific state."""
await async_setup_component(hass, "climate", {})
other_entity_ids = set(target_climates) - {entity_id}
# Set all climates, including the tested climate, to the initial state
for eid in target_climates:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other climates should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="climate.started_heating",
target_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
other_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
)
],
)
async def test_climate_state_attribute_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_climates: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[tuple[tuple[str, dict], int]],
) -> None:
"""Test that the climate state trigger fires when any climate state changes to a specific state."""
await async_setup_component(hass, "climate", {})
other_entity_ids = set(target_climates) - {entity_id}
# Set all climates, including the tested climate, to the initial state
for eid in target_climates:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other climates should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="climate.turned_off",
target_states=[HVACMode.OFF],
other_states=[HVACMode.HEAT],
),
],
)
async def test_climate_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_climates: list[str],
trigger_target_config: dict,
entities_in_target: int,
entity_id: str,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the climate state trigger fires when the last climate changes to a specific state."""
await async_setup_component(hass, "climate", {})
other_entity_ids = set(target_climates) - {entity_id}
# Set all climates, including the tested climate, to the initial state
for eid in target_climates:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("climate"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="climate.started_heating",
target_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.HEATING})],
other_states=[(HVACMode.OFF, {ATTR_HVAC_ACTION: HVACAction.IDLE})],
)
],
)
async def test_climate_state_attribute_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_climates: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[tuple[tuple[str, dict], int]],
) -> None:
"""Test that the climate state trigger fires when any climate state changes to a specific state."""
await async_setup_component(hass, "climate", {})
other_entity_ids = set(target_climates) - {entity_id}
# Set all climates, including the tested climate, to the initial state
for eid in target_climates:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -0,0 +1,254 @@
"""Test cover trigger."""
import pytest
from homeassistant.components.cover import ATTR_CURRENT_POSITION, CoverState
from homeassistant.const import ATTR_DEVICE_CLASS, CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.components import (
StateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def target_covers(hass: HomeAssistant) -> None:
"""Create multiple cover entities associated with different targets."""
return await target_entities(hass, "cover")
def parametrize_opened_trigger_states(
trigger: str, device_class: str
) -> list[tuple[str, dict, str, list[StateDescription]]]:
"""Parametrize states and expected service call counts.
Returns a list of tuples with (trigger, trigger_options,
list of StateDescription).
"""
additional_attributes = {ATTR_DEVICE_CLASS: device_class}
return [
# Test fully_opened = True
*(
(s[0], {"fully_opened": True}, *s[1:])
for s in parametrize_trigger_states(
trigger=trigger,
target_states=[
(CoverState.OPEN, {}),
(CoverState.OPENING, {}),
(CoverState.OPEN, {ATTR_CURRENT_POSITION: 100}),
(CoverState.OPENING, {ATTR_CURRENT_POSITION: 100}),
],
other_states=[
(CoverState.CLOSED, {}),
(CoverState.OPEN, {ATTR_CURRENT_POSITION: 0}),
],
additional_attributes=additional_attributes,
trigger_from_none=False,
)
),
# Test fully_opened = False
*(
(s[0], {}, *s[1:])
for s in parametrize_trigger_states(
trigger=trigger,
target_states=[
(CoverState.OPEN, {}),
(CoverState.OPENING, {}),
(CoverState.OPEN, {ATTR_CURRENT_POSITION: 1}),
(CoverState.OPENING, {ATTR_CURRENT_POSITION: 1}),
],
other_states=[
(CoverState.CLOSED, {}),
(CoverState.CLOSED, {ATTR_CURRENT_POSITION: 0}),
],
additional_attributes=additional_attributes,
trigger_from_none=False,
)
),
]
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("cover"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_opened_trigger_states("cover.awning_opened", "awning"),
*parametrize_opened_trigger_states("cover.blind_opened", "blind"),
*parametrize_opened_trigger_states("cover.curtain_opened", "curtain"),
*parametrize_opened_trigger_states("cover.door_opened", "door"),
*parametrize_opened_trigger_states("cover.garage_opened", "garage"),
*parametrize_opened_trigger_states("cover.gate_opened", "gate"),
*parametrize_opened_trigger_states("cover.shade_opened", "shade"),
*parametrize_opened_trigger_states("cover.shutter_opened", "shutter"),
*parametrize_opened_trigger_states("cover.window_opened", "window"),
],
)
async def test_cover_state_attribute_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_covers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict,
states: list[StateDescription],
) -> None:
"""Test that the cover state trigger fires when any cover state changes to a specific state."""
await async_setup_component(hass, "cover", {})
other_entity_ids = set(target_covers) - {entity_id}
# Set all covers, including the tested cover, to the initial state
for eid in target_covers:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, trigger_options, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other covers also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("cover"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_opened_trigger_states("cover.awning_opened", "awning"),
*parametrize_opened_trigger_states("cover.blind_opened", "blind"),
*parametrize_opened_trigger_states("cover.curtain_opened", "curtain"),
*parametrize_opened_trigger_states("cover.door_opened", "door"),
*parametrize_opened_trigger_states("cover.garage_opened", "garage"),
*parametrize_opened_trigger_states("cover.gate_opened", "gate"),
*parametrize_opened_trigger_states("cover.shade_opened", "shade"),
*parametrize_opened_trigger_states("cover.shutter_opened", "shutter"),
*parametrize_opened_trigger_states("cover.window_opened", "window"),
],
)
async def test_cover_state_attribute_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_covers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict,
states: list[StateDescription],
) -> None:
"""Test that the cover state trigger fires when the first cover state changes to a specific state."""
await async_setup_component(hass, "cover", {})
other_entity_ids = set(target_covers) - {entity_id}
# Set all covers, including the tested cover, to the initial state
for eid in target_covers:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(
hass,
trigger,
{"behavior": "first"} | trigger_options,
trigger_target_config,
)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other covers should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("cover"),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_opened_trigger_states("cover.awning_opened", "awning"),
*parametrize_opened_trigger_states("cover.blind_opened", "blind"),
*parametrize_opened_trigger_states("cover.curtain_opened", "curtain"),
*parametrize_opened_trigger_states("cover.door_opened", "door"),
*parametrize_opened_trigger_states("cover.garage_opened", "garage"),
*parametrize_opened_trigger_states("cover.gate_opened", "gate"),
*parametrize_opened_trigger_states("cover.shade_opened", "shade"),
*parametrize_opened_trigger_states("cover.shutter_opened", "shutter"),
*parametrize_opened_trigger_states("cover.window_opened", "window"),
],
)
async def test_cover_state_attribute_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_covers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict,
states: list[StateDescription],
) -> None:
"""Test that the cover state trigger fires when the last cover state changes to a specific state."""
await async_setup_component(hass, "cover", {})
other_entity_ids = set(target_covers) - {entity_id}
# Set all covers, including the tested cover, to the initial state
for eid in target_covers:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(
hass, trigger, {"behavior": "last"} | trigger_options, trigger_target_config
)
for state in states[1:]:
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -0,0 +1,195 @@
"""Test fan trigger."""
import pytest
from homeassistant.const import CONF_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.components import (
StateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def target_fans(hass: HomeAssistant) -> None:
"""Create multiple fan entities associated with different targets."""
return await target_entities(hass, "fan")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("fan"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="fan.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
*parametrize_trigger_states(
trigger="fan.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
],
)
async def test_fan_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_fans: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the fan state trigger fires when any fan state changes to a specific state."""
await async_setup_component(hass, "fan", {})
other_entity_ids = set(target_fans) - {entity_id}
# Set all fans, including the tested fan, to the initial state
for eid in target_fans:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other fans also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("fan"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="fan.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
*parametrize_trigger_states(
trigger="fan.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
],
)
async def test_fan_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_fans: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the fan state trigger fires when the first fan changes to a specific state."""
await async_setup_component(hass, "fan", {})
other_entity_ids = set(target_fans) - {entity_id}
# Set all fans, including the tested fan, to the initial state
for eid in target_fans:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other fans should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("fan"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="fan.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
*parametrize_trigger_states(
trigger="fan.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
],
)
async def test_fan_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_fans: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the fan state trigger fires when the last fan changes to a specific state."""
await async_setup_component(hass, "fan", {})
other_entity_ids = set(target_fans) - {entity_id}
# Set all fans, including the tested fan, to the initial state
for eid in target_fans:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -0,0 +1,227 @@
"""Test lawn mower triggers."""
import pytest
from homeassistant.components.lawn_mower import LawnMowerActivity
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.components import (
StateDescription,
arm_trigger,
other_states,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def target_lawn_mowers(hass: HomeAssistant) -> None:
"""Create multiple lawn mower entities associated with different targets."""
return await target_entities(hass, "lawn_mower")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("lawn_mower"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="lawn_mower.docked",
target_states=[LawnMowerActivity.DOCKED],
other_states=other_states(LawnMowerActivity.DOCKED),
),
*parametrize_trigger_states(
trigger="lawn_mower.errored",
target_states=[LawnMowerActivity.ERROR],
other_states=other_states(LawnMowerActivity.ERROR),
),
*parametrize_trigger_states(
trigger="lawn_mower.paused_mowing",
target_states=[LawnMowerActivity.PAUSED],
other_states=other_states(LawnMowerActivity.PAUSED),
),
*parametrize_trigger_states(
trigger="lawn_mower.started_mowing",
target_states=[LawnMowerActivity.MOWING],
other_states=other_states(LawnMowerActivity.MOWING),
),
],
)
async def test_lawn_mower_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_lawn_mowers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the lawn mower state trigger fires when any lawn mower state changes to a specific state."""
await async_setup_component(hass, "lawn_mower", {})
other_entity_ids = set(target_lawn_mowers) - {entity_id}
# Set all lawn mowers, including the tested one, to the initial state
for eid in target_lawn_mowers:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other lawn mowers also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("lawn_mower"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="lawn_mower.docked",
target_states=[LawnMowerActivity.DOCKED],
other_states=other_states(LawnMowerActivity.DOCKED),
),
*parametrize_trigger_states(
trigger="lawn_mower.errored",
target_states=[LawnMowerActivity.ERROR],
other_states=other_states(LawnMowerActivity.ERROR),
),
*parametrize_trigger_states(
trigger="lawn_mower.paused_mowing",
target_states=[LawnMowerActivity.PAUSED],
other_states=other_states(LawnMowerActivity.PAUSED),
),
*parametrize_trigger_states(
trigger="lawn_mower.started_mowing",
target_states=[LawnMowerActivity.MOWING],
other_states=other_states(LawnMowerActivity.MOWING),
),
],
)
async def test_lawn_mower_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_lawn_mowers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the lawn mower state trigger fires when the first lawn mower changes to a specific state."""
await async_setup_component(hass, "lawn_mower", {})
other_entity_ids = set(target_lawn_mowers) - {entity_id}
# Set all lawn mowers, including the tested one, to the initial state
for eid in target_lawn_mowers:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other lawn mowers should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("lawn_mower"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="lawn_mower.docked",
target_states=[LawnMowerActivity.DOCKED],
other_states=other_states(LawnMowerActivity.DOCKED),
),
*parametrize_trigger_states(
trigger="lawn_mower.errored",
target_states=[LawnMowerActivity.ERROR],
other_states=other_states(LawnMowerActivity.ERROR),
),
*parametrize_trigger_states(
trigger="lawn_mower.paused_mowing",
target_states=[LawnMowerActivity.PAUSED],
other_states=other_states(LawnMowerActivity.PAUSED),
),
*parametrize_trigger_states(
trigger="lawn_mower.started_mowing",
target_states=[LawnMowerActivity.MOWING],
other_states=other_states(LawnMowerActivity.MOWING),
),
],
)
async def test_lawn_mower_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_lawn_mowers: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the lawn_mower state trigger fires when the last lawn_mower changes to a specific state."""
await async_setup_component(hass, "lawn_mower", {})
other_entity_ids = set(target_lawn_mowers) - {entity_id}
# Set all lawn mowers, including the tested one, to the initial state
for eid in target_lawn_mowers:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -0,0 +1,203 @@
"""Test light conditions."""
import pytest
from homeassistant.components import automation
from homeassistant.const import (
ATTR_LABEL_ID,
CONF_CONDITION,
CONF_OPTIONS,
CONF_TARGET,
STATE_OFF,
STATE_ON,
STATE_UNAVAILABLE,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.helpers import entity_registry as er, label_registry as lr
from homeassistant.setup import async_setup_component
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def label_entities(hass: HomeAssistant) -> list[str]:
"""Create multiple entities associated with labels."""
await async_setup_component(hass, "light", {})
config_entry = MockConfigEntry(domain="test_labels")
config_entry.add_to_hass(hass)
label_reg = lr.async_get(hass)
label = label_reg.async_create("Test Label")
entity_reg = er.async_get(hass)
for i in range(3):
light_entity = entity_reg.async_get_or_create(
domain="light",
platform="test",
unique_id=f"label_light_{i}",
suggested_object_id=f"label_light_{i}",
)
entity_reg.async_update_entity(light_entity.entity_id, labels={label.label_id})
# Also create switches to test that they don't impact the conditions
for i in range(2):
switch_entity = entity_reg.async_get_or_create(
domain="switch",
platform="test",
unique_id=f"label_switch_{i}",
suggested_object_id=f"label_switch_{i}",
)
entity_reg.async_update_entity(switch_entity.entity_id, labels={label.label_id})
return [
"light.label_light_0",
"light.label_light_1",
"light.label_light_2",
]
async def setup_automation_with_light_condition(
hass: HomeAssistant,
*,
condition: str,
target: dict,
behavior: str,
) -> None:
"""Set up automation with light state condition."""
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {"platform": "event", "event_type": "test_event"},
"condition": {
CONF_CONDITION: condition,
CONF_TARGET: target,
CONF_OPTIONS: {"behavior": behavior},
},
"action": {
"service": "test.automation",
},
}
},
)
async def has_calls_after_trigger(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> bool:
"""Check if there are service calls after the trigger event."""
hass.bus.async_fire("test_event")
await hass.async_block_till_done()
has_calls = len(service_calls) == 1
service_calls.clear()
return has_calls
@pytest.mark.parametrize(
("condition", "state", "reverse_state"),
[("light.is_on", STATE_ON, STATE_OFF), ("light.is_off", STATE_OFF, STATE_ON)],
)
async def test_light_state_condition_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
label_entities: list[str],
condition: str,
state: str,
reverse_state: str,
) -> None:
"""Test the light state condition with the 'any' behavior."""
await async_setup_component(hass, "light", {})
for entity_id in label_entities:
hass.states.async_set(entity_id, reverse_state)
await setup_automation_with_light_condition(
hass,
condition=condition,
target={ATTR_LABEL_ID: "test_label", "entity_id": "light.nonexistent"},
behavior="any",
)
# Set state for two switches to ensure that they don't impact the condition
hass.states.async_set("switch.label_switch_1", STATE_OFF)
hass.states.async_set("switch.label_switch_2", STATE_ON)
# No lights on the condition state
assert not await has_calls_after_trigger(hass, service_calls)
# Set one light to the condition state -> condition pass
hass.states.async_set(label_entities[0], state)
assert await has_calls_after_trigger(hass, service_calls)
# Set all lights to the condition state -> condition pass
for entity_id in label_entities:
hass.states.async_set(entity_id, state)
assert await has_calls_after_trigger(hass, service_calls)
# Set one light to unavailable -> condition pass
hass.states.async_set(label_entities[0], STATE_UNAVAILABLE)
assert await has_calls_after_trigger(hass, service_calls)
# Set all lights to unavailable -> condition fail
for entity_id in label_entities:
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
assert not await has_calls_after_trigger(hass, service_calls)
@pytest.mark.parametrize(
("condition", "state", "reverse_state"),
[("light.is_on", STATE_ON, STATE_OFF), ("light.is_off", STATE_OFF, STATE_ON)],
)
async def test_light_state_condition_behavior_all(
hass: HomeAssistant,
service_calls: list[ServiceCall],
label_entities: list[str],
condition: str,
state: str,
reverse_state: str,
) -> None:
"""Test the light state condition with the 'all' behavior."""
await async_setup_component(hass, "light", {})
# Set state for two switches to ensure that they don't impact the condition
hass.states.async_set("switch.label_switch_1", STATE_OFF)
hass.states.async_set("switch.label_switch_2", STATE_ON)
for entity_id in label_entities:
hass.states.async_set(entity_id, reverse_state)
await setup_automation_with_light_condition(
hass,
condition=condition,
target={ATTR_LABEL_ID: "test_label", "entity_id": "light.nonexistent"},
behavior="all",
)
# No lights on the condition state
assert not await has_calls_after_trigger(hass, service_calls)
# Set one light to the condition state -> condition fail
hass.states.async_set(label_entities[0], state)
assert not await has_calls_after_trigger(hass, service_calls)
# Set all lights to the condition state -> condition pass
for entity_id in label_entities:
hass.states.async_set(entity_id, state)
assert await has_calls_after_trigger(hass, service_calls)
# Set one light to unavailable -> condition still pass
hass.states.async_set(label_entities[0], STATE_UNAVAILABLE)
assert await has_calls_after_trigger(hass, service_calls)
# Set all lights to unavailable -> condition passes
for entity_id in label_entities:
hass.states.async_set(entity_id, STATE_UNAVAILABLE)
assert await has_calls_after_trigger(hass, service_calls)

View File

@@ -0,0 +1,195 @@
"""Test light trigger."""
import pytest
from homeassistant.const import CONF_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.components import (
StateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def target_lights(hass: HomeAssistant) -> None:
"""Create multiple light entities associated with different targets."""
return await target_entities(hass, "light")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("light"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="light.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
*parametrize_trigger_states(
trigger="light.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
],
)
async def test_light_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_lights: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the light state trigger fires when any light state changes to a specific state."""
await async_setup_component(hass, "light", {})
other_entity_ids = set(target_lights) - {entity_id}
# Set all lights, including the tested light, to the initial state
for eid in target_lights:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other lights also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("light"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="light.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
*parametrize_trigger_states(
trigger="light.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
],
)
async def test_light_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_lights: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the light state trigger fires when the first light changes to a specific state."""
await async_setup_component(hass, "light", {})
other_entity_ids = set(target_lights) - {entity_id}
# Set all lights, including the tested light, to the initial state
for eid in target_lights:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other lights should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("light"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="light.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
*parametrize_trigger_states(
trigger="light.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
],
)
async def test_light_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_lights: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the light state trigger fires when the last light changes to a specific state."""
await async_setup_component(hass, "light", {})
other_entity_ids = set(target_lights) - {entity_id}
# Set all lights, including the tested light, to the initial state
for eid in target_lights:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -0,0 +1,252 @@
"""Test media player trigger."""
import pytest
from homeassistant.components.media_player import MediaPlayerState
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.components import (
StateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def target_media_players(hass: HomeAssistant) -> None:
"""Create multiple media player entities associated with different targets."""
return await target_entities(hass, "media_player")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("media_player"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="media_player.paused_playing",
target_states=[
MediaPlayerState.PAUSED,
],
other_states=[
MediaPlayerState.PLAYING,
],
),
*parametrize_trigger_states(
trigger="media_player.started_playing",
target_states=[
MediaPlayerState.PLAYING,
],
other_states=[
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
],
),
*parametrize_trigger_states(
trigger="media_player.stopped_playing",
target_states=[
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
],
other_states=[
MediaPlayerState.BUFFERING,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
],
),
*parametrize_trigger_states(
trigger="media_player.turned_off",
target_states=[
MediaPlayerState.OFF,
],
other_states=[
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
],
),
*parametrize_trigger_states(
trigger="media_player.turned_on",
target_states=[
MediaPlayerState.BUFFERING,
MediaPlayerState.IDLE,
MediaPlayerState.ON,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
],
other_states=[
MediaPlayerState.OFF,
],
),
],
)
async def test_media_player_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_media_players: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the media player state trigger fires when any media player state changes to a specific state."""
await async_setup_component(hass, "media_player", {})
other_entity_ids = set(target_media_players) - {entity_id}
# Set all media players, including the tested media player, to the initial state
for eid in target_media_players:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other media players also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("media_player"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="media_player.stopped_playing",
target_states=[
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
],
other_states=[
MediaPlayerState.BUFFERING,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
],
),
],
)
async def test_media_player_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_media_players: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the media player state trigger fires when the first media player changes to a specific state."""
await async_setup_component(hass, "media_player", {})
other_entity_ids = set(target_media_players) - {entity_id}
# Set all media players, including the tested media player, to the initial state
for eid in target_media_players:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other media players should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("media_player"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="media_player.stopped_playing",
target_states=[
MediaPlayerState.IDLE,
MediaPlayerState.OFF,
MediaPlayerState.ON,
],
other_states=[
MediaPlayerState.BUFFERING,
MediaPlayerState.PAUSED,
MediaPlayerState.PLAYING,
],
),
],
)
async def test_media_player_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_media_players: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the media player state trigger fires when the last media player changes to a specific state."""
await async_setup_component(hass, "media_player", {})
other_entity_ids = set(target_media_players) - {entity_id}
# Set all media players, including the tested media player, to the initial state
for eid in target_media_players:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -52,7 +52,8 @@ def mock_client():
client = client.return_value
client.async_login.return_value = True
client.status = ChargerStatus.CHARGING
client.power = ChargerPower(0, 0, 0, 0)
client.power = ChargerPower(0, 0, 0)
client.available = True
client.target_soc = 50
client.target_time = (8, 0)

View File

@@ -47,59 +47,6 @@
'state': 'unknown',
})
# ---
# name: test_sensors[sensor.ohme_home_pro_ct_current-entry]
EntityRegistryEntrySnapshot({
'aliases': set({
}),
'area_id': None,
'capabilities': None,
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.ohme_home_pro_ct_current',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'options': dict({
'sensor': dict({
'suggested_display_precision': 2,
}),
}),
'original_device_class': <SensorDeviceClass.CURRENT: 'current'>,
'original_icon': None,
'original_name': 'CT current',
'platform': 'ohme',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'ct_current',
'unique_id': 'chargerid_ct_current',
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
})
# ---
# name: test_sensors[sensor.ohme_home_pro_ct_current-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'device_class': 'current',
'friendly_name': 'Ohme Home Pro CT current',
'unit_of_measurement': <UnitOfElectricCurrent.AMPERE: 'A'>,
}),
'context': <ANY>,
'entity_id': 'sensor.ohme_home_pro_ct_current',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '0',
})
# ---
# name: test_sensors[sensor.ohme_home_pro_current-entry]
EntityRegistryEntrySnapshot({
'aliases': set({

View File

@@ -5,6 +5,7 @@
dict({
'end': '2024-12-30T04:30:39+00:00',
'energy': 2.042,
'power': 4.0,
'start': '2024-12-30T04:00:00+00:00',
}),
]),

View File

@@ -0,0 +1,227 @@
"""Test vacuum triggers."""
import pytest
from homeassistant.components.vacuum import VacuumActivity
from homeassistant.const import CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.setup import async_setup_component
from tests.components import (
StateDescription,
arm_trigger,
other_states,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
"""Stub copying the blueprints to the config folder."""
@pytest.fixture
async def target_vacuums(hass: HomeAssistant) -> None:
"""Create multiple vacuum entities associated with different targets."""
return await target_entities(hass, "vacuum")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("vacuum"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="vacuum.docked",
target_states=[VacuumActivity.DOCKED],
other_states=other_states(VacuumActivity.DOCKED),
),
*parametrize_trigger_states(
trigger="vacuum.errored",
target_states=[VacuumActivity.ERROR],
other_states=other_states(VacuumActivity.ERROR),
),
*parametrize_trigger_states(
trigger="vacuum.paused_cleaning",
target_states=[VacuumActivity.PAUSED],
other_states=other_states(VacuumActivity.PAUSED),
),
*parametrize_trigger_states(
trigger="vacuum.started_cleaning",
target_states=[VacuumActivity.CLEANING],
other_states=other_states(VacuumActivity.CLEANING),
),
],
)
async def test_vacuum_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_vacuums: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the vacuum state trigger fires when any vacuum state changes to a specific state."""
await async_setup_component(hass, "vacuum", {})
other_entity_ids = set(target_vacuums) - {entity_id}
# Set all vacuums, including the tested one, to the initial state
for eid in target_vacuums:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check if changing other vacuums also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("vacuum"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="vacuum.docked",
target_states=[VacuumActivity.DOCKED],
other_states=other_states(VacuumActivity.DOCKED),
),
*parametrize_trigger_states(
trigger="vacuum.errored",
target_states=[VacuumActivity.ERROR],
other_states=other_states(VacuumActivity.ERROR),
),
*parametrize_trigger_states(
trigger="vacuum.paused_cleaning",
target_states=[VacuumActivity.PAUSED],
other_states=other_states(VacuumActivity.PAUSED),
),
*parametrize_trigger_states(
trigger="vacuum.started_cleaning",
target_states=[VacuumActivity.CLEANING],
other_states=other_states(VacuumActivity.CLEANING),
),
],
)
async def test_vacuum_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_vacuums: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the vacuum state trigger fires when the first vacuum changes to a specific state."""
await async_setup_component(hass, "vacuum", {})
other_entity_ids = set(target_vacuums) - {entity_id}
# Set all vacuums, including the tested one, to the initial state
for eid in target_vacuums:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, trigger_target_config)
for state in states[1:]:
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other vacuums should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("vacuum"),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="vacuum.docked",
target_states=[VacuumActivity.DOCKED],
other_states=other_states(VacuumActivity.DOCKED),
),
*parametrize_trigger_states(
trigger="vacuum.errored",
target_states=[VacuumActivity.ERROR],
other_states=other_states(VacuumActivity.ERROR),
),
*parametrize_trigger_states(
trigger="vacuum.paused_cleaning",
target_states=[VacuumActivity.PAUSED],
other_states=other_states(VacuumActivity.PAUSED),
),
*parametrize_trigger_states(
trigger="vacuum.started_cleaning",
target_states=[VacuumActivity.CLEANING],
other_states=other_states(VacuumActivity.CLEANING),
),
],
)
async def test_vacuum_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_vacuums: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the vacuum state trigger fires when the last vacuum changes to a specific state."""
await async_setup_component(hass, "vacuum", {})
other_entity_ids = set(target_vacuums) - {entity_id}
# Set all vacuums, including the tested one, to the initial state
for eid in target_vacuums:
set_or_remove_state(hass, eid, states[0])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == 0
set_or_remove_state(hass, entity_id, state)
await hass.async_block_till_done()
assert len(service_calls) == state["count"]
for service_call in service_calls:
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()

View File

@@ -0,0 +1 @@
"""Tests for the Victron Bluetooth Low Energy integration."""

View File

@@ -0,0 +1,75 @@
"""Test the Victron Bluetooth Low Energy config flow."""
from collections.abc import Generator
from unittest.mock import AsyncMock, patch
from home_assistant_bluetooth import BluetoothServiceInfo
import pytest
from homeassistant.components.victron_ble.const import DOMAIN
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS
from homeassistant.core import HomeAssistant
from .fixtures import VICTRON_VEBUS_SERVICE_INFO, VICTRON_VEBUS_TOKEN
from tests.common import MockConfigEntry
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.victron_ble.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_discovered_service_info() -> Generator[AsyncMock]:
"""Mock discovered service info."""
with patch(
"homeassistant.components.victron_ble.config_flow.async_discovered_service_info",
return_value=[VICTRON_VEBUS_SERVICE_INFO],
) as mock_discovered_service_info:
yield mock_discovered_service_info
@pytest.fixture
def service_info() -> BluetoothServiceInfo:
"""Return service info."""
return VICTRON_VEBUS_SERVICE_INFO
@pytest.fixture
def access_token() -> str:
"""Return access token."""
return VICTRON_VEBUS_TOKEN
@pytest.fixture
def mock_config_entry(
service_info: BluetoothServiceInfo, access_token: str
) -> MockConfigEntry:
"""Mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
data={
CONF_ADDRESS: service_info.address,
CONF_ACCESS_TOKEN: access_token,
},
unique_id=service_info.address,
)
@pytest.fixture
def mock_config_entry_added_to_hass(
mock_config_entry,
hass: HomeAssistant,
service_info: BluetoothServiceInfo,
access_token: str,
) -> MockConfigEntry:
"""Mock config entry factory that added to hass."""
entry = mock_config_entry
entry.add_to_hass(hass)
return entry

View File

@@ -0,0 +1,147 @@
"""Fixtures for testing victron_ble."""
from home_assistant_bluetooth import BluetoothServiceInfo
NOT_VICTRON_SERVICE_INFO = BluetoothServiceInfo(
name="Not it",
address="61DE521B-F0BF-9F44-64D4-75BBE1738105",
rssi=-63,
manufacturer_data={3234: b"\x00\x01"},
service_data={},
service_uuids=[],
source="local",
)
VICTRON_TEST_WRONG_TOKEN = "00000000000000000000000000000000"
# battery monitor
VICTRON_BATTERY_MONITOR_SERVICE_INFO = BluetoothServiceInfo(
name="Battery Monitor",
address="01:02:03:04:05:07",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("100289a302b040af925d09a4d89aa0128bdef48c6298a9")
},
service_data={},
service_uuids=[],
source="local",
)
VICTRON_BATTERY_MONITOR_TOKEN = "aff4d0995b7d1e176c0c33ecb9e70dcd"
VICTRON_BATTERY_MONITOR_SENSORS = {
"battery_monitor_aux_mode": "disabled",
"battery_monitor_consumed_ampere_hours": "-50.0",
"battery_monitor_current": "0.0",
"battery_monitor_remaining_minutes": "unknown",
"battery_monitor_state_of_charge": "50.0",
"battery_monitor_voltage": "12.53",
"battery_monitor_alarm": "none",
"battery_monitor_temperature": "unknown",
"battery_monitor_starter_voltage": "unknown",
"battery_monitor_midpoint_voltage": "unknown",
}
# DC/DC converter
VICTRON_DC_DC_CONVERTER_SERVICE_INFO = BluetoothServiceInfo(
name="DC/DC Converter",
address="01:02:03:04:05:08",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("1000c0a304121d64ca8d442b90bbdf6a8cba"),
},
service_data={},
service_uuids=[],
source="local",
)
# DC energy meter
VICTRON_DC_ENERGY_METER_SERVICE_INFO = BluetoothServiceInfo(
name="DC Energy Meter",
address="01:02:03:04:05:09",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("100289a30d787fafde83ccec982199fd815286"),
},
service_data={},
service_uuids=[],
source="local",
)
VICTRON_DC_ENERGY_METER_TOKEN = "aff4d0995b7d1e176c0c33ecb9e70dcd"
VICTRON_DC_ENERGY_METER_SENSORS = {
"dc_energy_meter_meter_type": "dc_dc_charger",
"dc_energy_meter_aux_mode": "starter_voltage",
"dc_energy_meter_current": "0.0",
"dc_energy_meter_voltage": "12.52",
"dc_energy_meter_starter_voltage": "-0.01",
"dc_energy_meter_alarm": "none",
"dc_energy_meter_temperature": "unknown",
}
# Inverter
VICTRON_INVERTER_SERVICE_INFO = BluetoothServiceInfo(
name="Inverter",
address="01:02:03:04:05:10",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("1003a2a2031252dad26f0b8eb39162074d140df410"),
}, # not a valid advertisement, but model id mangled to match inverter
service_data={},
service_uuids=[],
source="local",
)
# Solar charger
VICTRON_SOLAR_CHARGER_SERVICE_INFO = BluetoothServiceInfo(
name="Solar Charger",
address="01:02:03:04:05:11",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("100242a0016207adceb37b605d7e0ee21b24df5c"),
},
service_data={},
service_uuids=[],
source="local",
)
VICTRON_SOLAR_CHARGER_TOKEN = "adeccb947395801a4dd45a2eaa44bf17"
VICTRON_SOLAR_CHARGER_SENSORS = {
"solar_charger_charge_state": "absorption",
"solar_charger_battery_voltage": "13.88",
"solar_charger_battery_current": "1.4",
"solar_charger_yield_today": "30",
"solar_charger_solar_power": "19",
"solar_charger_external_device_load": "0.0",
}
# ve.bus
VICTRON_VEBUS_SERVICE_INFO = BluetoothServiceInfo(
name="Inverter Charger",
address="01:02:03:04:05:06",
rssi=-60,
manufacturer_data={
0x02E1: bytes.fromhex("100380270c1252dad26f0b8eb39162074d140df410")
},
service_data={},
service_uuids=[],
source="local",
)
VICTRON_VEBUS_TOKEN = "da3f5fa2860cb1cf86ba7a6d1d16b9dd"
VICTRON_VEBUS_SENSORS = {
"inverter_charger_device_state": "float",
"inverter_charger_battery_voltage": "14.45",
"inverter_charger_battery_current": "23.2",
"inverter_charger_ac_in_state": "AC_IN_1",
"inverter_charger_ac_in_power": "1459",
"inverter_charger_ac_out_power": "1046",
"inverter_charger_battery_temperature": "32",
"inverter_charger_state_of_charge": "unknown",
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,189 @@
"""Test the Victron Bluetooth Low Energy config flow."""
from unittest.mock import AsyncMock
from home_assistant_bluetooth import BluetoothServiceInfo
import pytest
from homeassistant import config_entries
from homeassistant.components.victron_ble.const import DOMAIN
from homeassistant.config_entries import SOURCE_BLUETOOTH, SOURCE_USER
from homeassistant.const import CONF_ACCESS_TOKEN, CONF_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .fixtures import (
NOT_VICTRON_SERVICE_INFO,
VICTRON_INVERTER_SERVICE_INFO,
VICTRON_TEST_WRONG_TOKEN,
VICTRON_VEBUS_SERVICE_INFO,
VICTRON_VEBUS_TOKEN,
)
from tests.common import MockConfigEntry
@pytest.fixture(autouse=True)
def mock_bluetooth(enable_bluetooth: None) -> None:
"""Mock bluetooth for all tests in this module."""
async def test_async_step_bluetooth_valid_device(
hass: HomeAssistant, mock_setup_entry: AsyncMock
) -> None:
"""Test discovery via bluetooth with a valid device."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": SOURCE_BLUETOOTH},
data=VICTRON_VEBUS_SERVICE_INFO,
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "access_token"
# test valid access token
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_ACCESS_TOKEN: VICTRON_VEBUS_TOKEN},
)
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("title") == VICTRON_VEBUS_SERVICE_INFO.name
flow_result = result.get("result")
assert flow_result is not None
assert flow_result.unique_id == VICTRON_VEBUS_SERVICE_INFO.address
assert flow_result.data == {
CONF_ACCESS_TOKEN: VICTRON_VEBUS_TOKEN,
}
assert set(flow_result.data.keys()) == {CONF_ACCESS_TOKEN}
@pytest.mark.parametrize(
("source", "service_info", "expected_reason"),
[
(
SOURCE_BLUETOOTH,
NOT_VICTRON_SERVICE_INFO,
"not_supported",
),
(
SOURCE_BLUETOOTH,
VICTRON_INVERTER_SERVICE_INFO,
"not_supported",
),
(
SOURCE_USER,
None,
"no_devices_found",
),
],
ids=["bluetooth_not_victron", "bluetooth_unsupported_device", "user_no_devices"],
)
async def test_abort_scenarios(
hass: HomeAssistant,
source: str,
service_info: BluetoothServiceInfo | None,
expected_reason: str,
) -> None:
"""Test flows that result in abort."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": source},
data=service_info,
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == expected_reason
async def test_async_step_user_with_devices_found(
hass: HomeAssistant, mock_discovered_service_info: AsyncMock
) -> None:
"""Test setup from service info cache with devices found."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={CONF_ADDRESS: VICTRON_VEBUS_SERVICE_INFO.address},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "access_token"
# test invalid access token (valid already tested above)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input={CONF_ACCESS_TOKEN: VICTRON_TEST_WRONG_TOKEN}
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "invalid_access_token"
async def test_async_step_user_device_added_between_steps(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_discovered_service_info: AsyncMock,
) -> None:
"""Test abort when the device gets added via another flow between steps."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "user"
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={"address": VICTRON_VEBUS_SERVICE_INFO.address},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "already_configured"
async def test_async_step_user_with_found_devices_already_setup(
hass: HomeAssistant,
mock_config_entry_added_to_hass: MockConfigEntry,
mock_discovered_service_info: AsyncMock,
) -> None:
"""Test setup from service info cache with devices found."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_USER},
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "no_devices_found"
async def test_async_step_bluetooth_devices_already_setup(
hass: HomeAssistant, mock_config_entry_added_to_hass: MockConfigEntry
) -> None:
"""Test we can't start a flow if there is already a config entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=VICTRON_VEBUS_SERVICE_INFO,
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "already_configured"
async def test_async_step_bluetooth_already_in_progress(hass: HomeAssistant) -> None:
"""Test we can't start a flow for the same device twice."""
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=VICTRON_VEBUS_SERVICE_INFO,
)
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "access_token"
result = await hass.config_entries.flow.async_init(
DOMAIN,
context={"source": config_entries.SOURCE_BLUETOOTH},
data=VICTRON_VEBUS_SERVICE_INFO,
)
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "already_in_progress"

View File

@@ -0,0 +1,61 @@
"""Test updating sensors in the victron_ble integration."""
from home_assistant_bluetooth import BluetoothServiceInfo
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from .fixtures import (
VICTRON_BATTERY_MONITOR_SERVICE_INFO,
VICTRON_BATTERY_MONITOR_TOKEN,
VICTRON_DC_ENERGY_METER_SERVICE_INFO,
VICTRON_DC_ENERGY_METER_TOKEN,
VICTRON_SOLAR_CHARGER_SERVICE_INFO,
VICTRON_SOLAR_CHARGER_TOKEN,
VICTRON_VEBUS_SERVICE_INFO,
VICTRON_VEBUS_TOKEN,
)
from tests.common import MockConfigEntry, snapshot_platform
from tests.components.bluetooth import inject_bluetooth_service_info
@pytest.mark.usefixtures("enable_bluetooth")
@pytest.mark.parametrize(
(
"service_info",
"access_token",
),
[
(VICTRON_BATTERY_MONITOR_SERVICE_INFO, VICTRON_BATTERY_MONITOR_TOKEN),
(VICTRON_DC_ENERGY_METER_SERVICE_INFO, VICTRON_DC_ENERGY_METER_TOKEN),
(VICTRON_SOLAR_CHARGER_SERVICE_INFO, VICTRON_SOLAR_CHARGER_TOKEN),
(VICTRON_VEBUS_SERVICE_INFO, VICTRON_VEBUS_TOKEN),
],
ids=["battery_monitor", "dc_energy_meter", "solar_charger", "vebus"],
)
async def test_sensors(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
snapshot: SnapshotAssertion,
mock_config_entry_added_to_hass: MockConfigEntry,
service_info: BluetoothServiceInfo,
access_token: str,
) -> None:
"""Test sensor entities."""
entry = mock_config_entry_added_to_hass
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
# Initially no entities should be created until bluetooth data is received
assert len(hass.states.async_all()) == 0
# Inject bluetooth service info to trigger entity creation
inject_bluetooth_service_info(hass, service_info)
await hass.async_block_till_done()
# Use snapshot testing to verify all entity states and registry entries
await snapshot_platform(hass, entity_registry, snapshot, entry.entry_id)

View File

@@ -1029,7 +1029,9 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None:
):
descriptions = await service.async_get_all_descriptions(hass)
mock_load_yaml.assert_called_once_with("services.yaml", None)
mock_load_yaml.assert_called_once_with(
"homeassistant/components/test_domain/services.yaml", None
)
assert proxy_load_services_files.mock_calls[0][1][0] == unordered(
[
await async_get_integration(hass, domain),
@@ -1117,7 +1119,9 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None:
):
descriptions = await service.async_get_all_descriptions(hass)
mock_load_yaml.assert_called_once_with("services.yaml", None)
mock_load_yaml.assert_called_once_with(
"homeassistant/components/test_domain/services.yaml", None
)
assert proxy_load_services_files.mock_calls[0][1][0] == unordered(
[
await async_get_integration(hass, domain),

View File

@@ -450,10 +450,10 @@ async def test_caching(hass: HomeAssistant) -> None:
side_effect=translation.build_resources,
) as mock_build_resources:
load1 = await translation.async_get_translations(hass, "en", "entity_component")
assert len(mock_build_resources.mock_calls) == 7
assert len(mock_build_resources.mock_calls) == 9
load2 = await translation.async_get_translations(hass, "en", "entity_component")
assert len(mock_build_resources.mock_calls) == 7
assert len(mock_build_resources.mock_calls) == 9
assert load1 == load2

View File

@@ -84,7 +84,10 @@ async def test_evict_faked_translations_assumptions(hass: HomeAssistant) -> None
If this test fails, the evict_faked_translations may need to be updated.
"""
integration = mock_integration(hass, MockModule("test"), built_in=True)
assert integration.file_path == pathlib.Path("")
assert integration.file_path == pathlib.Path("homeassistant/components/test")
integration = mock_integration(hass, MockModule("test"), built_in=False)
assert integration.file_path == pathlib.Path("custom_components/test")
async def test_evict_faked_translations(hass: HomeAssistant, translations_once) -> None: