Compare commits

...

5 Commits

Author SHA1 Message Date
mib1185
476a1e3920 remove trigger behavior 2026-03-01 22:45:40 +00:00
mib1185
3648a7affd improve decrement and increment trigger classes 2026-03-01 22:23:31 +00:00
mib1185
e3cc6cf3be add counter.decremented and counter.incremented trigger 2026-03-01 21:30:31 +00:00
mib1185
824cdab56b add counter.maximum_reached trigger 2026-03-01 21:17:29 +00:00
mib1185
ab56734241 add counter.reset trigger 2026-03-01 20:36:11 +00:00
6 changed files with 259 additions and 1 deletions

View File

@@ -140,6 +140,7 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"binary_sensor",
"button",
"climate",
"counter",
"cover",
"device_tracker",
"fan",

View File

@@ -12,5 +12,19 @@
"set_value": {
"service": "mdi:counter"
}
},
"triggers": {
"decremented": {
"trigger": "mdi:numeric-negative-1"
},
"incremented": {
"trigger": "mdi:numeric-positive-1"
},
"maximum_reached": {
"trigger": "mdi:sort-numeric-ascending-variant"
},
"reset": {
"trigger": "mdi:refresh"
}
}
}

View File

@@ -49,5 +49,23 @@
"name": "Set"
}
},
"title": "Counter"
"title": "Counter",
"triggers": {
"decremented": {
"description": "Triggers when a counter decrements.",
"name": "Counter decremented"
},
"incremented": {
"description": "Triggers when a counter increments.",
"name": "Counter incremented"
},
"maximum_reached": {
"description": "Triggers when a counter reaches its maximum value.",
"name": "Counter reached maximum"
},
"reset": {
"description": "Triggers when a counter is reset.",
"name": "Counter reset"
}
}
}

View File

@@ -0,0 +1,88 @@
"""Provides triggers for counters."""
from typing import TYPE_CHECKING
from homeassistant.core import HomeAssistant, State
from homeassistant.helpers.trigger import EntityTriggerBase, Trigger
from . import ATTR_STEP, CONF_INITIAL, CONF_MAXIMUM, DOMAIN
class CounterStepTrigger(EntityTriggerBase):
"""Base trigger for when a counter value is changed by one step."""
_domain = DOMAIN
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if not super().is_valid_transition(from_state, to_state):
return False
step = to_state.attributes[ATTR_STEP]
if TYPE_CHECKING:
assert isinstance(step, int)
return abs(int(from_state.state) - int(to_state.state)) == step
def is_valid_state(self, state: State) -> bool:
"""Check if the new state attribute matches the expected one."""
try:
int(state.state)
except TypeError, ValueError:
return False
return True
class CounterDecrementedTrigger(CounterStepTrigger):
"""Trigger for when a counter is decremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if not super().is_valid_transition(from_state, to_state):
return False
return int(from_state.state) > int(to_state.state)
class CounterIncrementedTrigger(CounterStepTrigger):
"""Trigger for when a counter is incremented."""
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state is valid and the state has changed."""
if not super().is_valid_transition(from_state, to_state):
return False
return int(from_state.state) < int(to_state.state)
class CounterMaxReachedTrigger(EntityTriggerBase):
"""Trigger for when a counter reaches its maximum value."""
_domain = DOMAIN
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
max_value = state.attributes.get(CONF_MAXIMUM)
if max_value is None:
return False
return state.state == str(max_value)
class CounterResetTrigger(EntityTriggerBase):
"""Trigger for reset of counter entities."""
_domain = DOMAIN
def is_valid_state(self, state: State) -> bool:
"""Check if the new state matches the expected state(s)."""
init_state = state.attributes.get(CONF_INITIAL)
return state.state == str(init_state)
TRIGGERS: dict[str, type[Trigger]] = {
"decremented": CounterDecrementedTrigger,
"incremented": CounterIncrementedTrigger,
"maximum_reached": CounterMaxReachedTrigger,
"reset": CounterResetTrigger,
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for counters."""
return TRIGGERS

View File

@@ -0,0 +1,9 @@
.trigger_common: &trigger_common
target:
entity:
domain: counter
incremented: *trigger_common
decremented: *trigger_common
maximum_reached: *trigger_common
reset: *trigger_common

View File

@@ -0,0 +1,128 @@
"""Test counter triggers."""
from typing import Any
import pytest
from homeassistant.components.counter import (
ATTR_STEP,
CONF_INITIAL,
CONF_MAXIMUM,
DOMAIN,
)
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
TriggerStateDescription,
arm_trigger,
parametrize_target_entities,
parametrize_trigger_states,
set_or_remove_state,
target_entities,
)
@pytest.fixture
async def target_counters(hass: HomeAssistant) -> list[str]:
"""Create multiple counter entities associated with different targets."""
return (await target_entities(hass, DOMAIN))["included"]
@pytest.mark.parametrize(
"trigger_key",
[
"counter.decremented",
"counter.incremented",
"counter.maximum_reached",
"counter.reset",
],
)
async def test_counter_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the counter triggers are gated by the labs flag."""
await arm_trigger(hass, trigger_key, None, {ATTR_LABEL_ID: "test_label"})
assert (
"Unnamed automation failed to setup triggers and has been disabled: Trigger "
f"'{trigger_key}' requires the experimental 'New triggers and conditions' "
"feature to be enabled in Home Assistant Labs settings (feature flag: "
"'new_triggers_conditions')"
) in caplog.text
@pytest.mark.usefixtures("enable_labs_preview_features")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_trigger_states(
trigger="counter.decremented",
target_states=[("1", {ATTR_STEP: 1})],
other_states=[("2", {ATTR_STEP: 1})],
),
*parametrize_trigger_states(
trigger="counter.incremented",
target_states=[("2", {ATTR_STEP: 1})],
other_states=[("1", {ATTR_STEP: 1})],
),
*parametrize_trigger_states(
trigger="counter.maximum_reached",
target_states=[("2", {CONF_MAXIMUM: 2})],
other_states=[("1", {CONF_MAXIMUM: 2})],
),
(
"counter.maximum_reached",
{},
[
{"included": {"state": None, "attributes": {}}, "count": 0},
{"included": {"state": "1", "attributes": {}}, "count": 0},
{"included": {"state": None, "attributes": {}}, "count": 0},
],
),
*parametrize_trigger_states(
trigger="counter.reset",
target_states=[("2", {CONF_INITIAL: 2})],
other_states=[("3", {CONF_INITIAL: 2})],
),
],
)
async def test_counter_state_trigger_behavior(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_counters: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
trigger_options: dict[str, Any],
states: list[TriggerStateDescription],
) -> None:
"""Test that the counter state trigger fires when any counter state changes to a specific state."""
other_entity_ids = set(target_counters) - {entity_id}
# Set all counters, including the tested one, to the initial state
for eid in target_counters:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
set_or_remove_state(hass, entity_id, included_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 counters also triggers
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
assert len(service_calls) == (entities_in_target - 1) * state["count"]
service_calls.clear()