Compare commits

..

8 Commits

Author SHA1 Message Date
dependabot[bot]
41d5415c86 Bump actions/cache from 4.3.0 to 5.0.0 (#158771) 2025-12-12 10:39:58 +01:00
Allen Porter
052d56f358 Bump ical to 12.1.1 (#158770) 2025-12-12 08:34:22 +01:00
Abílio Costa
0a676b5812 Remove alarm panel test from text tests (#158743) 2025-12-12 02:18:40 +01:00
Michael
1f4cf67daa Add turned off and turned on triggers to switch platform (#158688)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-11 22:02:22 +00:00
Maikel Punie
bb4ec229ce Add Velbus VLP file loading (#154883) 2025-12-11 22:53:01 +01:00
ndrwrbgs
ff62b460d5 Update advanced_options display text for MQTT (#158728) 2025-12-11 22:16:35 +01:00
Willem-Jan van Rootselaar
9b48e92940 Bump python-bsblan to 3.1.4 (#158725)
Co-authored-by: Abílio Costa <abmantis@users.noreply.github.com>
2025-12-11 18:04:57 +00:00
Magnus
c03b9d1f87 Bump aioasuswrt to 1.5.2 (#158727) 2025-12-11 17:31:46 +00:00
28 changed files with 583 additions and 218 deletions

View File

@@ -263,7 +263,7 @@ jobs:
check-latest: true
- name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: &actions-cache actions/cache@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
with:
path: venv
key: &key-pre-commit-venv >-
@@ -304,7 +304,7 @@ jobs:
- &cache-restore-pre-commit-venv
name: Restore base Python virtual environment
id: cache-venv
uses: &actions-cache-restore actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: &actions-cache-restore actions/cache/restore@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
with:
path: venv
fail-on-cache-miss: true
@@ -511,7 +511,7 @@ jobs:
fi
- name: Save apt cache
if: steps.cache-apt-check.outputs.cache-hit != 'true'
uses: &actions-cache-save actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4.3.0
uses: &actions-cache-save actions/cache/save@a7833574556fa59680c1b7cb190c1735db73ebf0 # v5.0.0
with:
path: *path-apt-cache
key: *key-apt-cache

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "local_polling",
"loggers": ["aioasuswrt", "asusrouter", "asyncssh"],
"requirements": ["aioasuswrt==1.5.1", "asusrouter==1.21.3"]
"requirements": ["aioasuswrt==1.5.2", "asusrouter==1.21.3"]
}

View File

@@ -127,11 +127,11 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"binary_sensor",
"climate",
"cover",
"device_tracker",
"fan",
"lawn_mower",
"light",
"media_player",
"switch",
"text",
"vacuum",
}

View File

@@ -7,7 +7,7 @@
"integration_type": "device",
"iot_class": "local_polling",
"loggers": ["bsblan"],
"requirements": ["python-bsblan==3.1.3"],
"requirements": ["python-bsblan==3.1.4"],
"zeroconf": [
{
"name": "bsb-lan*",

View File

@@ -11,13 +11,5 @@
"see": {
"service": "mdi:account-eye"
}
},
"triggers": {
"entered_home": {
"trigger": "mdi:account-arrow-left"
},
"left_home": {
"trigger": "mdi:account-arrow-right"
}
}
}

View File

@@ -1,8 +1,4 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted device trackers to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"condition_type": {
"is_home": "{entity_name} is home",
@@ -48,15 +44,6 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"see": {
"description": "Manually update the records of a seen legacy device tracker in the known_devices.yaml file.",
@@ -93,27 +80,5 @@
"name": "See"
}
},
"title": "Device tracker",
"triggers": {
"entered_home": {
"description": "Triggers when one or more device trackers enter home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::trigger_behavior_description%]",
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
}
},
"name": "Entered home"
},
"left_home": {
"description": "Triggers when one or more device trackers leave home.",
"fields": {
"behavior": {
"description": "[%key:component::device_tracker::common::trigger_behavior_description%]",
"name": "[%key:component::device_tracker::common::trigger_behavior_name%]"
}
},
"name": "Left home"
}
}
"title": "Device tracker"
}

View File

@@ -1,21 +0,0 @@
"""Provides triggers for device_trackers."""
from homeassistant.const import STATE_HOME
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import (
Trigger,
make_entity_state_trigger,
make_from_entity_state_trigger,
)
from .const import DOMAIN
TRIGGERS: dict[str, type[Trigger]] = {
"entered_home": make_entity_state_trigger(DOMAIN, STATE_HOME),
"left_home": make_from_entity_state_trigger(DOMAIN, from_state=STATE_HOME),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for device trackers."""
return TRIGGERS

View File

@@ -8,5 +8,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["googleapiclient"],
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==11.1.0"]
"requirements": ["gcal-sync==8.0.0", "oauth2client==4.1.3", "ical==12.1.1"]
}

View File

@@ -7,5 +7,5 @@
"documentation": "https://www.home-assistant.io/integrations/local_calendar",
"iot_class": "local_polling",
"loggers": ["ical"],
"requirements": ["ical==11.1.0"]
"requirements": ["ical==12.1.1"]
}

View File

@@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/local_todo",
"iot_class": "local_polling",
"requirements": ["ical==11.1.0"]
"requirements": ["ical==12.1.1"]
}

View File

@@ -46,7 +46,7 @@
"ws_path": "WebSocket path"
},
"data_description": {
"advanced_options": "Enable and select **Next** to set advanced options.",
"advanced_options": "Enable and select **Submit** to set advanced options.",
"broker": "The hostname or IP address of your MQTT broker.",
"certificate": "The custom CA certificate file to validate your MQTT brokers certificate.",
"client_cert": "The client certificate to authenticate against your MQTT broker.",

View File

@@ -8,5 +8,5 @@
"iot_class": "cloud_polling",
"loggers": ["ical"],
"quality_scale": "silver",
"requirements": ["ical==11.1.0"]
"requirements": ["ical==12.1.1"]
}

View File

@@ -29,5 +29,13 @@
"turn_on": {
"service": "mdi:toggle-switch-variant"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:toggle-switch-variant-off"
},
"turned_on": {
"trigger": "mdi:toggle-switch-variant"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted switches to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"toggle": "[%key:common::device_automation::action_type::toggle%]",
@@ -41,6 +45,15 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"toggle": {
"description": "Toggles a switch on/off.",
@@ -55,5 +68,27 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Switch"
"title": "Switch",
"triggers": {
"turned_off": {
"description": "Triggers after one or more switches turn off.",
"fields": {
"behavior": {
"description": "[%key:component::switch::common::trigger_behavior_description%]",
"name": "[%key:component::switch::common::trigger_behavior_name%]"
}
},
"name": "Switch turned off"
},
"turned_on": {
"description": "Triggers after one or more switches turn on.",
"fields": {
"behavior": {
"description": "[%key:component::switch::common::trigger_behavior_description%]",
"name": "[%key:component::switch::common::trigger_behavior_name%]"
}
},
"name": "Switch turned on"
}
}
}

View File

@@ -0,0 +1,17 @@
"""Provides triggers for switch platform."""
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_on": make_entity_state_trigger(DOMAIN, STATE_ON),
"turned_off": make_entity_state_trigger(DOMAIN, STATE_OFF),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for switch platform."""
return TRIGGERS

View File

@@ -1,7 +1,7 @@
.trigger_common: &trigger_common
target:
entity:
domain: device_tracker
domain: switch
fields:
behavior:
required: true
@@ -14,5 +14,5 @@
- any
translation_key: trigger_behavior
entered_home: *trigger_common
left_home: *trigger_common
turned_off: *trigger_common
turned_on: *trigger_common

View File

@@ -19,7 +19,7 @@ from homeassistant.helpers import config_validation as cv, device_registry as dr
from homeassistant.helpers.storage import STORAGE_DIR
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN
from .const import CONF_VLP_FILE, DOMAIN
from .services import async_setup_services
_LOGGER = logging.getLogger(__name__)
@@ -98,8 +98,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
async def async_setup_entry(hass: HomeAssistant, entry: VelbusConfigEntry) -> bool:
"""Establish connection with velbus."""
controller = Velbus(
entry.data[CONF_PORT],
dsn=entry.data[CONF_PORT],
cache_dir=hass.config.path(STORAGE_DIR, f"velbuscache-{entry.entry_id}"),
vlp_file=entry.data.get(CONF_VLP_FILE),
)
try:
await controller.connect()

View File

@@ -2,18 +2,35 @@
from __future__ import annotations
from typing import Any
from pathlib import Path
import shutil
from typing import Any, Final
import serial.tools.list_ports
import velbusaio.controller
from velbusaio.exceptions import VelbusConnectionFailed
from velbusaio.vlp_reader import VlpFile
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.components.file_upload import process_uploaded_file
from homeassistant.config_entries import (
SOURCE_RECONFIGURE,
ConfigFlow,
ConfigFlowResult,
)
from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_PORT
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import selector
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from .const import CONF_TLS, DOMAIN
from .const import CONF_TLS, CONF_VLP_FILE, DOMAIN
STORAGE_PATH: Final = ".storage/velbus.{key}.vlp"
class InvalidVlpFile(HomeAssistantError):
"""Error to indicate that the uploaded file is not a valid VLP file."""
class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
@@ -24,14 +41,15 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
def __init__(self) -> None:
"""Initialize the velbus config flow."""
self._errors: dict[str, str] = {}
self._device: str = ""
self._vlp_file: str | None = None
self._title: str = ""
def _create_device(self) -> ConfigFlowResult:
"""Create an entry async."""
return self.async_create_entry(
title=self._title, data={CONF_PORT: self._device}
title=self._title,
data={CONF_PORT: self._device, CONF_VLP_FILE: self._vlp_file},
)
async def _test_connection(self) -> bool:
@@ -41,7 +59,6 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
await controller.connect()
await controller.stop()
except VelbusConnectionFailed:
self._errors[CONF_PORT] = "cannot_connect"
return False
return True
@@ -57,6 +74,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle network step."""
step_errors: dict[str, str] = {}
if user_input is not None:
self._title = "Velbus Network"
if user_input[CONF_TLS]:
@@ -68,7 +86,8 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
self._device += f"{user_input[CONF_HOST]}:{user_input[CONF_PORT]}"
self._async_abort_entries_match({CONF_PORT: self._device})
if await self._test_connection():
return self._create_device()
return await self.async_step_vlp()
step_errors[CONF_HOST] = "cannot_connect"
else:
user_input = {
CONF_TLS: True,
@@ -88,13 +107,14 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
),
suggested_values=user_input,
),
errors=self._errors,
errors=step_errors,
)
async def async_step_usbselect(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle usb select step."""
step_errors: dict[str, str] = {}
ports = await self.hass.async_add_executor_job(serial.tools.list_ports.comports)
list_of_ports = [
f"{p}{', s/n: ' + p.serial_number if p.serial_number else ''}"
@@ -107,7 +127,8 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
self._device = ports[list_of_ports.index(user_input[CONF_PORT])].device
self._async_abort_entries_match({CONF_PORT: self._device})
if await self._test_connection():
return self._create_device()
return await self.async_step_vlp()
step_errors[CONF_PORT] = "cannot_connect"
else:
user_input = {}
user_input[CONF_PORT] = ""
@@ -118,7 +139,7 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
vol.Schema({vol.Required(CONF_PORT): vol.In(list_of_ports)}),
suggested_values=user_input,
),
errors=self._errors,
errors=step_errors,
)
async def async_step_usb(self, discovery_info: UsbServiceInfo) -> ConfigFlowResult:
@@ -144,3 +165,75 @@ class VelbusConfigFlow(ConfigFlow, domain=DOMAIN):
step_id="discovery_confirm",
description_placeholders={CONF_NAME: self._title},
)
async def _validate_vlp_file(self, file_path: str) -> None:
"""Validate VLP file and raise exception if invalid."""
vlpfile = VlpFile(file_path)
await vlpfile.read()
if not vlpfile.get():
raise InvalidVlpFile("no_modules")
async def async_step_vlp(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Step when user wants to use the VLP file."""
step_errors: dict[str, str] = {}
if user_input is not None:
if CONF_VLP_FILE not in user_input or user_input[CONF_VLP_FILE] == "":
# The VLP file is optional, so allow skipping it
self._vlp_file = None
else:
try:
# handle the file upload
self._vlp_file = await self.hass.async_add_executor_job(
save_uploaded_vlp_file, self.hass, user_input[CONF_VLP_FILE]
)
# validate it
await self._validate_vlp_file(self._vlp_file)
except InvalidVlpFile as e:
step_errors[CONF_VLP_FILE] = str(e)
if self.source == SOURCE_RECONFIGURE:
old_entry = self._get_reconfigure_entry()
return self.async_update_reload_and_abort(
old_entry,
data={
CONF_VLP_FILE: self._vlp_file,
CONF_PORT: old_entry.data.get(CONF_PORT),
},
)
if not step_errors:
return self._create_device()
return self.async_show_form(
step_id="vlp",
data_schema=self.add_suggested_values_to_schema(
vol.Schema(
{
vol.Optional(CONF_VLP_FILE): selector.FileSelector(
config=selector.FileSelectorConfig(accept=".vlp")
),
}
),
suggested_values=user_input,
),
errors=step_errors,
)
async def async_step_reconfigure(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle reconfiguration."""
return await self.async_step_vlp()
def save_uploaded_vlp_file(hass: HomeAssistant, uploaded_file_id: str) -> str:
"""Validate the uploaded file and move it to the storage directory.
Blocking function needs to be called in executor.
"""
with process_uploaded_file(hass, uploaded_file_id) as file:
dest_path = Path(hass.config.path(STORAGE_PATH.format(key=uploaded_file_id)))
dest_path.parent.mkdir(parents=True, exist_ok=True)
shutil.move(file, dest_path)
return str(dest_path)

View File

@@ -14,6 +14,7 @@ DOMAIN: Final = "velbus"
CONF_CONFIG_ENTRY: Final = "config_entry"
CONF_MEMO_TEXT: Final = "memo_text"
CONF_TLS: Final = "tls"
CONF_VLP_FILE: Final = "vlp_file"
SERVICE_SCAN: Final = "scan"
SERVICE_SYNC: Final = "sync_clock"

View File

@@ -3,7 +3,7 @@
"name": "Velbus",
"codeowners": ["@Cereal2nd", "@brefra"],
"config_flow": true,
"dependencies": ["usb"],
"dependencies": ["usb", "file_upload"],
"documentation": "https://www.home-assistant.io/integrations/velbus",
"integration_type": "hub",
"iot_class": "local_push",

View File

@@ -1,11 +1,13 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]"
},
"error": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"no_modules": "No Velbus modules found, please check your VLP file."
},
"step": {
"network": {
@@ -41,6 +43,16 @@
"usbselect": "Via USB device"
},
"title": "Define the Velbus connection"
},
"vlp": {
"data": {
"vlp_file": "Path to VLP file"
},
"data_description": {
"vlp_file": "Select the VLP file from your filesystem."
},
"description": "You can optionally provide a VLP file to improve module detection. The VLP file is the config file from VelbusLink that contains all module information. If you do not provide it now, you can always add it later in the integration options. Without this file, Home Assistant will try to detect the modules automatically, but this can take longer time and some modules might not be detected correctly.",
"title": "Optional VLP file"
}
}
},

View File

@@ -453,22 +453,6 @@ class ConditionalEntityStateTriggerBase(EntityTriggerBase):
return state.state in self._to_states
class EntityFromStateTriggerBase(EntityTriggerBase):
"""Class for entity state changes from a specific state."""
_from_state: str
def is_valid_transition(self, from_state: State, to_state: State) -> bool:
"""Check if the origin state matches the expected one and that the state changed."""
return (
from_state.state == self._from_state and to_state.state != self._from_state
)
def is_valid_state(self, state: State) -> bool:
"""Check if the new state is not the same as the expected origin state."""
return state.state != self._from_state
class EntityStateAttributeTriggerBase(EntityTriggerBase):
"""Trigger for entity state attribute changes."""
@@ -509,20 +493,6 @@ def make_conditional_entity_state_trigger(
return CustomTrigger
def make_from_entity_state_trigger(
domain: str, *, from_state: str
) -> type[EntityFromStateTriggerBase]:
"""Create a "from" entity state trigger class."""
class CustomTrigger(EntityFromStateTriggerBase):
"""Trigger for "from" entity state changes."""
_domain = domain
_from_state = from_state
return CustomTrigger
def make_entity_state_attribute_trigger(
domain: str, attribute: str, to_state: str
) -> type[EntityStateAttributeTriggerBase]:

6
requirements_all.txt generated
View File

@@ -206,7 +206,7 @@ aioaquacell==0.2.0
aioaseko==1.0.0
# homeassistant.components.asuswrt
aioasuswrt==1.5.1
aioasuswrt==1.5.2
# homeassistant.components.husqvarna_automower
aioautomower==2.7.1
@@ -1249,7 +1249,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
# homeassistant.components.remote_calendar
ical==11.1.0
ical==12.1.1
# homeassistant.components.caldav
icalendar==6.3.1
@@ -2475,7 +2475,7 @@ python-awair==0.2.5
python-blockchain-api==0.0.2
# homeassistant.components.bsblan
python-bsblan==3.1.3
python-bsblan==3.1.4
# homeassistant.components.citybikes
python-citybikes==0.3.3

View File

@@ -197,7 +197,7 @@ aioaquacell==0.2.0
aioaseko==1.0.0
# homeassistant.components.asuswrt
aioasuswrt==1.5.1
aioasuswrt==1.5.2
# homeassistant.components.husqvarna_automower
aioautomower==2.7.1
@@ -1101,7 +1101,7 @@ ibeacon-ble==1.2.0
# homeassistant.components.local_calendar
# homeassistant.components.local_todo
# homeassistant.components.remote_calendar
ical==11.1.0
ical==12.1.1
# homeassistant.components.caldav
icalendar==6.3.1
@@ -2086,7 +2086,7 @@ python-MotionMount==2.3.0
python-awair==0.2.5
# homeassistant.components.bsblan
python-bsblan==3.1.3
python-bsblan==3.1.4
# homeassistant.components.ecobee
python-ecobee-api==0.3.2

View File

@@ -1,16 +1,12 @@
"""Test device_tracker trigger."""
"""Test switch triggers."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.const import (
ATTR_LABEL_ID,
CONF_ENTITY_ID,
STATE_HOME,
STATE_NOT_HOME,
)
from homeassistant.components.switch import DOMAIN
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID, STATE_OFF, STATE_ON
from homeassistant.core import HomeAssistant, ServiceCall
from tests.components import (
@@ -22,8 +18,6 @@ from tests.components import (
target_entities,
)
STATE_WORK_ZONE = "work"
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
@@ -41,19 +35,22 @@ def enable_experimental_triggers_conditions() -> Generator[None]:
@pytest.fixture
async def target_device_trackers(hass: HomeAssistant) -> list[str]:
"""Create multiple device_trackers entities associated with different targets."""
return (await target_entities(hass, "device_tracker"))["included"]
async def target_switches(hass: HomeAssistant) -> list[str]:
"""Create multiple switch entities associated with different targets."""
return (await target_entities(hass, DOMAIN))["included"]
@pytest.mark.parametrize(
"trigger_key",
["device_tracker.entered_home", "device_tracker.left_home"],
[
"switch.turned_off",
"switch.turned_on",
],
)
async def test_device_tracker_triggers_gated_by_labs_flag(
async def test_switch_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the device_tracker triggers are gated by the labs flag."""
"""Test the switch 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 "
@@ -66,38 +63,38 @@ async def test_device_tracker_triggers_gated_by_labs_flag(
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("device_tracker"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="device_tracker.entered_home",
target_states=[STATE_HOME],
other_states=[STATE_NOT_HOME, STATE_WORK_ZONE],
trigger="switch.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="device_tracker.left_home",
target_states=[STATE_NOT_HOME, STATE_WORK_ZONE],
other_states=[STATE_HOME],
trigger="switch.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_device_tracker_home_trigger_behavior_any(
async def test_switch_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_device_trackers: list[str],
target_switches: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the device_tracker home triggers when any device_tracker changes to a specific state."""
other_entity_ids = set(target_device_trackers) - {entity_id}
"""Test that the switch state trigger fires when any switch state changes to a specific state."""
other_entity_ids = set(target_switches) - {entity_id}
# Set all device_trackers, including the tested device_tracker, to the initial state
for eid in target_device_trackers:
# Set all switches, including the tested one, to the initial state
for eid in target_switches:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
@@ -112,7 +109,7 @@ async def test_device_tracker_home_trigger_behavior_any(
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Check that changing other device_trackers also triggers
# Check if changing other switches 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()
@@ -123,38 +120,38 @@ async def test_device_tracker_home_trigger_behavior_any(
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("device_tracker"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="device_tracker.entered_home",
target_states=[STATE_HOME],
other_states=[STATE_NOT_HOME, STATE_WORK_ZONE],
trigger="switch.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="device_tracker.left_home",
target_states=[STATE_NOT_HOME, STATE_WORK_ZONE],
other_states=[STATE_HOME],
trigger="switch.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_device_tracker_state_trigger_behavior_first(
async def test_switch_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_device_trackers: list[str],
target_switches: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the device_tracker home triggers when the first device_tracker changes to a specific state."""
other_entity_ids = set(target_device_trackers) - {entity_id}
"""Test that the switch state trigger fires when the first switch changes to a specific state."""
other_entity_ids = set(target_switches) - {entity_id}
# Set all device_trackers, including the tested device_tracker, to the initial state
for eid in target_device_trackers:
# Set all switches, including the tested one, to the initial state
for eid in target_switches:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
@@ -169,7 +166,7 @@ async def test_device_tracker_state_trigger_behavior_first(
assert service_call.data[CONF_ENTITY_ID] == entity_id
service_calls.clear()
# Triggering other device_trackers should not cause the trigger to fire again
# Triggering other switches should not cause the trigger to fire again
for other_entity_id in other_entity_ids:
set_or_remove_state(hass, other_entity_id, included_state)
await hass.async_block_till_done()
@@ -179,38 +176,38 @@ async def test_device_tracker_state_trigger_behavior_first(
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities("device_tracker"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="device_tracker.entered_home",
target_states=[STATE_HOME],
other_states=[STATE_NOT_HOME, STATE_WORK_ZONE],
trigger="switch.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="device_tracker.left_home",
target_states=[STATE_NOT_HOME, STATE_WORK_ZONE],
other_states=[STATE_HOME],
trigger="switch.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_device_tracker_state_trigger_behavior_last(
async def test_switch_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_device_trackers: list[str],
target_switches: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the device_tracker home triggers when the last device_tracker changes to a specific state."""
other_entity_ids = set(target_device_trackers) - {entity_id}
"""Test that the switch state trigger fires when the last switch changes to a specific state."""
other_entity_ids = set(target_switches) - {entity_id}
# Set all device_trackers, including the tested device_tracker, to the initial state
for eid in target_device_trackers:
# Set all switches, including the tested one, to the initial state
for eid in target_switches:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()

View File

@@ -43,31 +43,6 @@ async def target_texts(hass: HomeAssistant) -> list[str]:
return (await target_entities(hass, "text"))["included"]
@pytest.mark.parametrize(
"trigger_key",
[
"alarm_control_panel.armed",
"alarm_control_panel.armed_away",
"alarm_control_panel.armed_home",
"alarm_control_panel.armed_night",
"alarm_control_panel.armed_vacation",
"alarm_control_panel.disarmed",
"alarm_control_panel.triggered",
],
)
async def test_alarm_control_panel_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the ACP 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.parametrize("trigger_key", ["text.changed"])
async def test_text_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str

View File

@@ -333,3 +333,82 @@ async def mock_config_entry(
)
config_entry.add_to_hass(hass)
return config_entry
@pytest.fixture(name="mock_vlp_file")
def mock_vlp_content():
"""Mock vlp file content."""
return b"""<?xml version="1.0" encoding="utf-8"?>
<Project>
<Settings>
<Name></Name>
<Comments></Comments>
<DateModified>21/03/2025 12:30:06</DateModified>
<Version>11.6.4.0</Version>
</Settings>
<Preferences>
<MasterClock>
<IgnoreMultipleClocks>0</IgnoreMultipleClocks>
<IgnoreNoSignumClock>0</IgnoreNoSignumClock>
<IgnoreNoMasterClock>0</IgnoreNoMasterClock>
</MasterClock>
</Preferences>
<Customer>
<Name></Name>
<Address></Address>
<City></City>
<Zip></Zip>
<Phone></Phone>
<Fax></Fax>
<Email></Email>
<Email2></Email2>
<Country></Country>
</Customer>
<Installer>
<Name></Name>
<Address></Address>
<City></City>
<Zip></Zip>
<Phone></Phone>
<Fax></Fax>
<Email></Email>
<Email2></Email2>
<Country></Country>
</Installer>
<Connection>
<NetworkHost>192.168.88.9</NetworkHost>
<NetworkPort>27015</NetworkPort>
<NetworkHostname>7b95834e</NetworkHostname>
<NetworkSsl>True</NetworkSsl>
<NetworkPassword></NetworkPassword>
<NetworkRequireAuth>False</NetworkRequireAuth>
</Connection>
<Modules>
<Module build="2235" address="FE" type="VMBSIG" serial="34DF" locked="0" layer="0" terminator="0">
<Caption>VMBSIG</Caption>
<Remark></Remark>
<Memory>564D42534947FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0300010001000101FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF</Memory>
<Snapshot>564D42534947FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF030001000100010100000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000</Snapshot>
<SnapshotVerification>FFFFFFFFFFFFFFFFFF0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000</SnapshotVerification>
</Module>
<Module build="2423" address="CE" type="VMB4LEDPWM-20" serial="BD30" locked="0" layer="0" terminator="0">
<Caption>VMB4LEDPWM-20</Caption>
<Remark></Remark>
<Memory>4368616E6E656C2031FFFFFFFFFFFFFF4368616E6E656C2032FFFFFFFFFFFFFF4368616E6E656C2033FFFFFFFFFFFFFF4368616E6E656C2034FFFFFFFFFFFFFF000000750700160008001700082AF5ECE3E7DDDFDEE5E8F2FD071115181818171A1A1A150E02FFFF1027171A1C151B181A17171209FEF2EAE2E0DCE1DEE4EAF5010DFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF00000000564D42344C454450574D2D3230FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFFFFFFFFFF</Memory>
<Snapshot>4368616E6E656C2031FFFFFFFFFFFFFF4368616E6E656C2032FFFFFFFFFFFFFF4368616E6E656C2033FFFFFFFFFFFFFF4368616E6E656C2034FFFFFFFFFFFFFF000000750700160008001700082AF5ECE3E7DDDFDEE5E8F2FD071115181818171A1A1A150E02FFFF1027171A1C151B181A17171209FEF2EAE2E0DCE1DEE4EAF5010DFFFFFFFFFFFFFFFFFFFF000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000564D42344C454450574D2D3230FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFF0601FE873FFFFFFFFF7FFFFFFFFFBFFFFFFFFFFEFFFFFFFFBFFFFFFFFF7FFFFFFFFF3FFFFFFFFF00FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000FFFFFFFFFFFFFFFF</Snapshot>
<SnapshotVerification>FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000F00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF</SnapshotVerification>
</Module>
</Modules>
<Layers/>
<EnergyMonitoring>
<Counters/>
<Consumers/>
<Functions/>
<Settings>
<AutoMaxConsumption>True</AutoMaxConsumption>
<MaxConsumption>0</MaxConsumption>
<MinInjection>0</MinInjection>
<IdleConsumption>0</IdleConsumption>
</Settings>
</EnergyMonitoring>
</Project>"""

View File

@@ -1,19 +1,23 @@
"""Tests for the Velbus config flow."""
from collections.abc import Generator
from collections.abc import Generator, Iterator
from contextlib import contextmanager
from pathlib import Path
from unittest.mock import AsyncMock, MagicMock, patch
from uuid import uuid4
import pytest
import serial.tools.list_ports
from velbusaio.exceptions import VelbusConnectionFailed
from homeassistant.components.velbus.const import CONF_TLS, DOMAIN
from homeassistant.components.velbus.const import CONF_TLS, CONF_VLP_FILE, DOMAIN
from homeassistant.config_entries import SOURCE_USB, SOURCE_USER
from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_PORT, CONF_SOURCE
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers.service_info.usb import UsbServiceInfo
from . import init_integration
from .const import PORT_SERIAL
from tests.common import MockConfigEntry
@@ -40,6 +44,36 @@ def com_port():
return port
@pytest.fixture
def mock_process_uploaded_file(
tmp_path: Path, mock_vlp_file: str
) -> Generator[MagicMock]:
"""Mock upload vlp file."""
file_id_vlp = str(uuid4())
@contextmanager
def _mock_process_uploaded_file(
hass: HomeAssistant, uploaded_file_id: str
) -> Iterator[Path | None]:
with open(tmp_path / uploaded_file_id, "wb") as vlpfile:
vlpfile.write(mock_vlp_file)
yield tmp_path / uploaded_file_id
with (
patch(
"homeassistant.components.velbus.config_flow.process_uploaded_file",
side_effect=_mock_process_uploaded_file,
) as mock_upload,
patch(
"shutil.move",
),
):
mock_upload.file_id = {
CONF_VLP_FILE: file_id_vlp,
}
yield mock_upload
@pytest.fixture(autouse=True)
def override_async_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
@@ -109,16 +143,57 @@ async def test_user_network_succes(
},
)
assert result
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "vlp"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("title") == "Velbus Network"
data = result.get("data")
assert data
assert data[CONF_PORT] == expected
@pytest.mark.usefixtures("controller")
@pytest.mark.usefixtures("controller_connection_failed")
async def test_user_network_connect_failure(
hass: HomeAssistant,
) -> None:
"""Test user network config."""
# inttial menu show
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result
assert result.get("flow_id")
assert result.get("type") is FlowResultType.MENU
assert result.get("step_id") == "user"
assert result.get("menu_options") == ["network", "usbselect"]
# select the network option
result = await hass.config_entries.flow.async_configure(
result.get("flow_id"),
{"next_step_id": "network"},
)
assert result["type"] is FlowResultType.FORM
# fill in the network form
result = await hass.config_entries.flow.async_configure(
result.get("flow_id"),
{
CONF_HOST: "velbus",
CONF_PORT: 6000,
CONF_TLS: True,
CONF_PASSWORD: "password",
},
)
assert result
assert result.get("type") is FlowResultType.FORM
assert result.get("errors") == {"host": "cannot_connect"}
@pytest.mark.usefixtures("controller_connection_failed")
@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()]))
async def test_user_usb_succes(hass: HomeAssistant) -> None:
async def test_user_usb_connect_failure(hass: HomeAssistant) -> None:
"""Test user usb step."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
@@ -135,6 +210,36 @@ async def test_user_usb_succes(hass: HomeAssistant) -> None:
},
)
assert result
assert result.get("type") is FlowResultType.FORM
assert result.get("errors") == {"port": "cannot_connect"}
@pytest.mark.usefixtures("controller")
@patch("serial.tools.list_ports.comports", MagicMock(return_value=[com_port()]))
async def test_user_usb_success(hass: HomeAssistant) -> None:
"""Test user usb step."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result.get("flow_id"),
{"next_step_id": "usbselect"},
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_PORT: USB_DEV,
},
)
assert result
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "vlp"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{},
)
assert result
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert result.get("title") == "Velbus USB"
data = result.get("data")
@@ -142,6 +247,142 @@ async def test_user_usb_succes(hass: HomeAssistant) -> None:
assert data[CONF_PORT] == PORT_SERIAL
@pytest.mark.usefixtures("controller")
async def test_vlp_step_no_modules(
hass: HomeAssistant,
mock_process_uploaded_file: MagicMock,
) -> None:
"""Test VLP step."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result.get("flow_id"),
{"next_step_id": "network"},
)
result = await hass.config_entries.flow.async_configure(
result.get("flow_id"),
{
CONF_TLS: False,
CONF_HOST: "192.168.88.9",
CONF_PORT: 27015,
CONF_PASSWORD: "",
},
)
assert result
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "vlp"
with (
patch(
"homeassistant.components.velbus.async_setup_entry",
return_value=True,
),
patch(
"velbusaio.vlp_reader.VlpFile.read",
AsyncMock(return_value=True),
),
patch(
"velbusaio.vlp_reader.VlpFile.get",
return_value=[],
),
):
file_id = mock_process_uploaded_file.file_id
result = await hass.config_entries.flow.async_configure(
result.get("flow_id"),
{CONF_VLP_FILE: file_id[CONF_VLP_FILE]},
)
await hass.async_block_till_done()
assert result.get("type") is FlowResultType.FORM
assert result.get("errors") == {CONF_VLP_FILE: "no_modules"}
@pytest.mark.usefixtures("controller")
async def test_vlp_step_success(
hass: HomeAssistant,
mock_process_uploaded_file: MagicMock,
) -> None:
"""Test VLP step."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result.get("flow_id"),
{"next_step_id": "network"},
)
result = await hass.config_entries.flow.async_configure(
result.get("flow_id"),
{
CONF_TLS: False,
CONF_HOST: "192.168.88.9",
CONF_PORT: 27015,
CONF_PASSWORD: "",
},
)
assert result
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "vlp"
with (
patch(
"homeassistant.components.velbus.async_setup_entry",
return_value=True,
) as mock_setup_entry,
patch(
"velbusaio.vlp_reader.VlpFile.read",
AsyncMock(return_value=True),
),
patch(
"velbusaio.vlp_reader.VlpFile.get",
return_value=[1, 2, 3, 4],
),
):
file_id = mock_process_uploaded_file.file_id
result = await hass.config_entries.flow.async_configure(
result.get("flow_id"),
{CONF_VLP_FILE: file_id[CONF_VLP_FILE]},
)
await hass.async_block_till_done()
assert result.get("type") is FlowResultType.CREATE_ENTRY
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("controller")
async def test_reconfigure_step(
hass: HomeAssistant,
mock_process_uploaded_file: MagicMock,
config_entry: MockConfigEntry,
) -> None:
"""Testcase for the reconfigure step."""
await init_integration(hass, config_entry)
result = await config_entry.start_reconfigure_flow(hass)
assert result
assert result.get("type") is FlowResultType.FORM
assert result.get("step_id") == "vlp"
with (
patch(
"velbusaio.vlp_reader.VlpFile.read",
AsyncMock(return_value=True),
),
patch(
"velbusaio.vlp_reader.VlpFile.get",
return_value=[1, 2, 3, 4],
),
):
file_id = mock_process_uploaded_file.file_id
result = await hass.config_entries.flow.async_configure(
result.get("flow_id"),
{CONF_VLP_FILE: file_id[CONF_VLP_FILE]},
)
await hass.async_block_till_done()
assert result.get("type") is FlowResultType.ABORT
assert result["reason"] == "reconfigure_successful"
@pytest.mark.usefixtures("controller")
async def test_network_abort_if_already_setup(hass: HomeAssistant) -> None:
"""Test we abort if Velbus is already setup."""
@@ -158,7 +399,7 @@ async def test_network_abort_if_already_setup(hass: HomeAssistant) -> None:
{"next_step_id": "network"},
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
result.get("flow_id"),
{
CONF_TLS: False,
CONF_HOST: "127.0.0.1",