mirror of
https://github.com/home-assistant/core.git
synced 2026-10-06 02:18:33 +01:00
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker <joostlek@outlook.com>
388 lines
14 KiB
Python
388 lines
14 KiB
Python
"""Test the ISY994 event platform."""
|
|
|
|
from collections.abc import Callable, Generator
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from pyisy.constants import (
|
|
CMD_FADE_DOWN,
|
|
CMD_FADE_STOP,
|
|
CMD_FADE_UP,
|
|
CMD_OFF,
|
|
CMD_OFF_FAST,
|
|
CMD_ON,
|
|
CMD_ON_FAST,
|
|
ES_SYNCING,
|
|
)
|
|
from pyisy.helpers import NodeProperty
|
|
from pyisy.nodes import NodeChangedEvent
|
|
import pytest
|
|
from syrupy.assertion import SnapshotAssertion
|
|
|
|
from homeassistant.components.isy994.const import EVENT_ISY994_CONTROL
|
|
from homeassistant.components.isy994.event import _sub_button_name
|
|
from homeassistant.const import STATE_UNAVAILABLE, Platform
|
|
from homeassistant.core import HomeAssistant
|
|
from homeassistant.helpers import entity_registry as er
|
|
|
|
from tests.common import MockConfigEntry, async_capture_events, snapshot_platform
|
|
|
|
SWITCH_ENTITY_ID = "switch.garage_relay"
|
|
|
|
|
|
@pytest.fixture
|
|
def platforms() -> list[Platform]:
|
|
"""Return the platforms to set up, overridden by parametrization."""
|
|
return [Platform.EVENT]
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def mock_event_platform(platforms: list[Platform]) -> Generator[None]:
|
|
"""Mock the platforms that are set up."""
|
|
with patch("homeassistant.components.isy994.PLATFORMS", platforms):
|
|
yield
|
|
|
|
|
|
def _make_button_nodes(
|
|
mock_isy: MagicMock, mock_node: Callable[..., Any]
|
|
) -> list[tuple[str, MagicMock]]:
|
|
"""Build a representative set of button-emitting Insteon nodes."""
|
|
nodes: list[tuple[str, MagicMock]] = []
|
|
|
|
# Primary loads — enabled by default
|
|
primary = mock_node(
|
|
mock_isy, "11 11 11 1", "Living Room Switch", "DimmerLampSwitch_ADV"
|
|
)
|
|
nodes.append(("Living Room Switch", primary))
|
|
|
|
relay = mock_node(mock_isy, "22 22 22 1", "Garage Relay", "RelayLampSwitch_ADV")
|
|
nodes.append(("Garage Relay", relay))
|
|
|
|
keypad_load = mock_node(
|
|
mock_isy, "33 33 33 1", "Hallway Keypad", "KeypadDimmer_ADV"
|
|
)
|
|
nodes.append(("Hallway Keypad", keypad_load))
|
|
|
|
# Secondary keypad button — disabled by default
|
|
sub_button = mock_node(
|
|
mock_isy, "33 33 33 2", "Hallway Keypad B", "KeypadButton_ADV"
|
|
)
|
|
sub_button.parent_node = keypad_load
|
|
sub_button.primary_node = "33 33 33 1"
|
|
nodes.append(("Hallway Keypad B", sub_button))
|
|
|
|
return nodes
|
|
|
|
|
|
async def test_event_entity_snapshot(
|
|
hass: HomeAssistant,
|
|
snapshot: SnapshotAssertion,
|
|
mock_config_entry: MockConfigEntry,
|
|
entity_registry: er.EntityRegistry,
|
|
mock_isy: MagicMock,
|
|
mock_node: Callable[..., Any],
|
|
) -> None:
|
|
"""Snapshot the event entities created for supported Insteon nodes."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
mock_isy.nodes.__iter__.return_value = _make_button_nodes(mock_isy, mock_node)
|
|
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
entries = er.async_entries_for_config_entry(
|
|
entity_registry, mock_config_entry.entry_id
|
|
)
|
|
assert any(entry.disabled_by is not None for entry in entries)
|
|
|
|
for entry in entries:
|
|
if entry.disabled_by:
|
|
entity_registry.async_update_entity(entry.entity_id, disabled_by=None)
|
|
|
|
await hass.config_entries.async_reload(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("parent_name", "node_name", "expected"),
|
|
[
|
|
("Hallway Keypad", "Hallway Keypad B", "B"),
|
|
("Hall", "Hallway B", "Hallway B"), # prefix with no separator: unchanged
|
|
],
|
|
)
|
|
def test_sub_button_name_requires_separator(
|
|
parent_name: str, node_name: str, expected: str
|
|
) -> None:
|
|
"""A parent name that is a bare prefix (no separator) must not be stripped.
|
|
|
|
"Hall" is a prefix of "Hallway B" with no separator between them, so the
|
|
sub-button label must stay "Hallway B" rather than being corrupted to
|
|
"way B".
|
|
"""
|
|
node = MagicMock()
|
|
node.parent_node.name = parent_name
|
|
node.name = node_name
|
|
assert _sub_button_name(node) == expected
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("control", "expected_event_type", "expected_direction", "expected_count"),
|
|
[
|
|
(CMD_ON, "press_end", "up", None),
|
|
(CMD_OFF, "press_end", "down", None),
|
|
(CMD_ON_FAST, "multi_press_end", "up", 2),
|
|
(CMD_OFF_FAST, "multi_press_end", "down", 2),
|
|
(CMD_FADE_UP, "long_press_start", "up", None),
|
|
(CMD_FADE_DOWN, "long_press_start", "down", None),
|
|
],
|
|
)
|
|
async def test_control_event_triggers_entity(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_isy: MagicMock,
|
|
mock_node: Callable[..., Any],
|
|
control: str,
|
|
expected_event_type: str,
|
|
expected_direction: str,
|
|
expected_count: int | None,
|
|
) -> None:
|
|
"""Control events from pyisy translate into the standard button event types."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
node = mock_node(mock_isy, "11 11 11 1", "Test Switch", "DimmerLampSwitch_ADV")
|
|
mock_isy.nodes.__iter__.return_value = [("Test Switch", node)]
|
|
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
handler = node.control_events.subscribe.call_args.args[0]
|
|
handler(MagicMock(spec=NodeProperty, control=control))
|
|
await hass.async_block_till_done()
|
|
|
|
entity_ids = hass.states.async_entity_ids("event")
|
|
assert len(entity_ids) == 1
|
|
state = hass.states.get(entity_ids[0])
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == expected_event_type
|
|
assert state.attributes.get("direction") == expected_direction
|
|
assert state.attributes.get("multi_press_count") == expected_count
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("controls", "expected_direction"),
|
|
[
|
|
pytest.param([CMD_FADE_DOWN, CMD_FADE_STOP], "down", id="paired_stop"),
|
|
pytest.param([CMD_FADE_STOP], None, id="orphan_stop"),
|
|
pytest.param(
|
|
[CMD_FADE_DOWN, CMD_FADE_STOP, CMD_FADE_STOP], None, id="duplicate_stop"
|
|
),
|
|
],
|
|
)
|
|
async def test_fade_stop_direction(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_isy: MagicMock,
|
|
mock_node: Callable[..., Any],
|
|
controls: list[str],
|
|
expected_direction: str | None,
|
|
) -> None:
|
|
"""CMD_FADE_STOP reports the direction of the fade it ends, once.
|
|
|
|
The remembered direction is consumed by the stop that uses it, so a stop
|
|
with no preceding fade start reports no direction instead of a stale one.
|
|
"""
|
|
mock_config_entry.add_to_hass(hass)
|
|
node = mock_node(mock_isy, "11 11 11 1", "Test Switch", "DimmerLampSwitch_ADV")
|
|
mock_isy.nodes.__iter__.return_value = [("Test Switch", node)]
|
|
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
handler = node.control_events.subscribe.call_args.args[0]
|
|
for control in controls:
|
|
handler(MagicMock(spec=NodeProperty, control=control))
|
|
await hass.async_block_till_done()
|
|
|
|
entity_ids = hass.states.async_entity_ids("event")
|
|
state = hass.states.get(entity_ids[0])
|
|
assert state is not None
|
|
assert state.attributes["event_type"] == "long_press_end"
|
|
assert state.attributes.get("direction") == expected_direction
|
|
# An unknown direction omits the attribute entirely rather than
|
|
# publishing it as null.
|
|
assert ("direction" in state.attributes) is (expected_direction is not None)
|
|
|
|
|
|
async def test_disabling_node_marks_entity_unavailable(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_isy: MagicMock,
|
|
mock_node: Callable[..., Any],
|
|
) -> None:
|
|
"""Availability tracks the node's enabled flag.
|
|
|
|
The entity skips the base class's status_events subscription, so only the
|
|
filtered enabled/disabled subscription keeps `available` current.
|
|
"""
|
|
mock_config_entry.add_to_hass(hass)
|
|
node = mock_node(mock_isy, "11 11 11 1", "Test Switch", "DimmerLampSwitch_ADV")
|
|
mock_isy.nodes.__iter__.return_value = [("Test Switch", node)]
|
|
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
entity_id = hass.states.async_entity_ids("event")[0]
|
|
assert hass.states.get(entity_id).state != STATE_UNAVAILABLE
|
|
|
|
node.enabled = False
|
|
handler = mock_isy.nodes.status_events.subscribe.call_args.args[0]
|
|
handler(MagicMock(spec=NodeChangedEvent), "key")
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.states.get(entity_id).state == STATE_UNAVAILABLE
|
|
|
|
|
|
async def test_control_event_suppressed_while_websocket_syncing(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_isy: MagicMock,
|
|
mock_node: Callable[..., Any],
|
|
) -> None:
|
|
"""Control events are dropped while the websocket replays status on connect.
|
|
|
|
Without this guard, PyISY's post-connect status replay fires stale
|
|
button events on every startup, config-entry reload, and reconnect.
|
|
"""
|
|
mock_config_entry.add_to_hass(hass)
|
|
node = mock_node(mock_isy, "11 11 11 1", "Test Switch", "DimmerLampSwitch_ADV")
|
|
mock_isy.nodes.__iter__.return_value = [("Test Switch", node)]
|
|
mock_isy.websocket.status = ES_SYNCING
|
|
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
handler = node.control_events.subscribe.call_args.args[0]
|
|
handler(MagicMock(spec=NodeProperty, control=CMD_ON))
|
|
await hass.async_block_till_done()
|
|
|
|
entity_ids = hass.states.async_entity_ids("event")
|
|
state = hass.states.get(entity_ids[0])
|
|
assert state is not None
|
|
assert state.attributes.get("event_type") is None
|
|
|
|
|
|
@pytest.mark.parametrize("platforms", [[Platform.EVENT, Platform.SWITCH]])
|
|
@pytest.mark.parametrize(
|
|
"control", [CMD_ON, CMD_OFF, CMD_ON_FAST, CMD_OFF_FAST, CMD_FADE_UP, CMD_FADE_STOP]
|
|
)
|
|
async def test_legacy_control_bus_event_not_duplicated(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_isy: MagicMock,
|
|
mock_node: Callable[..., Any],
|
|
control: str,
|
|
) -> None:
|
|
"""The legacy isy994_control bus event still fires exactly once per control.
|
|
|
|
The node's primary (switch) entity keeps firing the bus event from the
|
|
base class, while the event entity overrides async_on_control and must
|
|
not fire a second one for the same control.
|
|
"""
|
|
mock_config_entry.add_to_hass(hass)
|
|
node = mock_node(mock_isy, "22 22 22 1", "Garage Relay", "RelayLampSwitch_ADV")
|
|
mock_isy.nodes.__iter__.return_value = [("Garage Relay", node)]
|
|
events = async_capture_events(hass, EVENT_ISY994_CONTROL)
|
|
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert hass.states.get(SWITCH_ENTITY_ID) is not None
|
|
assert len(hass.states.async_entity_ids("event")) == 1
|
|
|
|
for call in node.control_events.subscribe.call_args_list:
|
|
call.args[0](MagicMock(spec=NodeProperty, control=control))
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(events) == 1
|
|
assert events[0].data["entity_id"] == SWITCH_ENTITY_ID
|
|
assert events[0].data["control"] == control
|
|
|
|
|
|
async def test_unsupported_control_is_ignored(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_isy: MagicMock,
|
|
mock_node: Callable[..., Any],
|
|
) -> None:
|
|
"""Control events not in the mapping must not trigger the entity."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
node = mock_node(mock_isy, "11 11 11 1", "Test Switch", "DimmerLampSwitch_ADV")
|
|
mock_isy.nodes.__iter__.return_value = [("Test Switch", node)]
|
|
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
handler = node.control_events.subscribe.call_args.args[0]
|
|
handler(MagicMock(spec=NodeProperty, control="ST"))
|
|
await hass.async_block_till_done()
|
|
|
|
entity_ids = hass.states.async_entity_ids("event")
|
|
assert len(entity_ids) == 1
|
|
state = hass.states.get(entity_ids[0])
|
|
assert state is not None
|
|
assert state.attributes.get("event_type") is None
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("node_type", "expect_event_entity"),
|
|
[
|
|
("1.14.1", True), # SwitchLinc prefix from FILTER_INSTEON_TYPE
|
|
("2.44.1", True), # KeypadLinc dimmer prefix from FILTER_INSTEON_TYPE
|
|
("3.32.1", True), # BallastLinc prefix from FILTER_INSTEON_TYPE
|
|
("1.20.1", False), # Not in FILTER_INSTEON_TYPE
|
|
],
|
|
)
|
|
async def test_legacy_insteon_type_fallback(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_isy: MagicMock,
|
|
mock_node: Callable[..., Any],
|
|
node_type: str,
|
|
expect_event_entity: bool,
|
|
) -> None:
|
|
"""Pre-5.0-firmware nodes with no node_def_id fall back to type-prefix matching."""
|
|
mock_config_entry.add_to_hass(hass)
|
|
node = mock_node(mock_isy, "11 11 11 1", "Legacy Switch", None, node_type=node_type)
|
|
mock_isy.nodes.__iter__.return_value = [("Legacy Switch", node)]
|
|
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
entity_ids = hass.states.async_entity_ids("event")
|
|
assert (len(entity_ids) == 1) is expect_event_entity
|
|
|
|
|
|
async def test_event_entity_created_despite_sensor_string_override(
|
|
hass: HomeAssistant,
|
|
mock_config_entry: MockConfigEntry,
|
|
mock_isy: MagicMock,
|
|
mock_node: Callable[..., Any],
|
|
) -> None:
|
|
"""A node forced into Platform.SENSOR by the sensor_string option still gets its event entity.
|
|
|
|
Platform.EVENT is a parallel classification, not exclusive with the
|
|
user's sensor_string override -- a SwitchLinc/KeypadLinc whose name
|
|
happens to contain the (default "sensor") override string must still be
|
|
matched against NODE_PARALLEL_PLATFORMS.
|
|
"""
|
|
mock_config_entry.add_to_hass(hass)
|
|
node = mock_node(
|
|
mock_isy, "11 11 11 1", "Garage sensor Switch", "DimmerLampSwitch_ADV"
|
|
)
|
|
mock_isy.nodes.__iter__.return_value = [("Garage sensor Switch", node)]
|
|
|
|
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
|
await hass.async_block_till_done()
|
|
|
|
assert len(hass.states.async_entity_ids("event")) == 1
|