Compare commits

..

20 Commits

Author SHA1 Message Date
wollew
7dc9084f06 Velux action setup (#159502) 2025-12-20 19:49:15 +01:00
Svetoslav
39ba36d642 Fix syntax error in mute_volume method (#159458) 2025-12-20 19:45:02 +01:00
Álvaro Fernández Rojas
5009560f57 Update aioqsw to v0.4.2 (#159467) 2025-12-20 19:43:20 +01:00
Niracler
41e88573bb Enhance Sunricher DALI with stale-device cleanup (#156015)
Co-authored-by: Joostlek <joostlek@outlook.com>
2025-12-20 18:26:57 +01:00
Markus Jacobsen
27ee986b1b Add Beoremote One diagnostics to Bang & Olufsen (#159447) 2025-12-20 18:25:04 +01:00
Lukas
c9d21c1851 Pooldose: Add parallel updates (Silver Qly Scale) (#159479) 2025-12-20 18:23:25 +01:00
wollew
2afbdc5757 add gateway disconnect on unload of velux integration (#159497) 2025-12-20 18:16:58 +01:00
Joost Lekkerkerker
14cb8af9fe Add integration_type service to meteoclimatic (#159488) 2025-12-20 15:16:31 +01:00
Joost Lekkerkerker
74ae0f8297 Add integration_type service to metoffice (#159489) 2025-12-20 15:14:18 +01:00
Paul Tarjan
3050a5c896 Support NVR Hikvision devices (#159253) 2025-12-20 10:08:48 +01:00
Raphael Hehl
9f886e66c7 Update UniFi Protect select entities to use snake_case state values with proper translations (#159284)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
Co-authored-by: J. Nick Koston <nick@koston.org>
2025-12-20 10:07:21 +01:00
Tom Harris
3c752d4516 Bump insteon panel to 0.6.0 to fix dialog button issues (#159449) 2025-12-20 10:05:03 +01:00
Raphael Hehl
e4bfdf5b30 Add quality scale configuration for UniFi Protect integration (#157568)
Co-authored-by: RaHehl <rahehl@users.noreply.github.com>
2025-12-20 10:03:02 +01:00
Artur Pragacz
3e43424a73 Add myself as codeowner to intent script (#159454) 2025-12-20 10:00:58 +01:00
Matthias Alphart
0db9dcfd1c Fix knx translation typos (#159486) 2025-12-20 09:53:45 +01:00
J. Nick Koston
5b5850224a Bump yalexs-ble to 3.2.4 (#159476) 2025-12-19 14:05:07 -10:00
Erik Montnemery
065b0eb5b2 Fix siren entity triggers (#159474) 2025-12-19 22:45:32 +01:00
Michael
6a1d86d5db Add domain driven triggers to lock platform (#159327)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-19 22:34:33 +01:00
Petro31
f99a73ef28 Modernize template weather platform and add config flow (#156399) 2025-12-19 22:28:26 +01:00
Michael
0436d30062 Add turned off and turned on triggers to siren platform (#158847)
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-12-19 22:15:06 +01:00
66 changed files with 1910 additions and 789 deletions

2
CODEOWNERS generated
View File

@@ -794,6 +794,8 @@ build.json @home-assistant/supervisor
/tests/components/intellifire/ @jeeftor
/homeassistant/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/tests/components/intent/ @home-assistant/core @synesthesiam @arturpragacz
/homeassistant/components/intent_script/ @arturpragacz
/tests/components/intent_script/ @arturpragacz
/homeassistant/components/intesishome/ @jnimmo
/homeassistant/components/iometer/ @jukrebs
/tests/components/iometer/ @jukrebs

View File

@@ -30,5 +30,5 @@
"integration_type": "hub",
"iot_class": "cloud_push",
"loggers": ["pubnub", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.2"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"]
}

View File

@@ -134,7 +134,9 @@ _EXPERIMENTAL_TRIGGER_PLATFORMS = {
"humidifier",
"lawn_mower",
"light",
"lock",
"media_player",
"siren",
"switch",
"text",
"update",

View File

@@ -12,7 +12,7 @@ from homeassistant.helpers import entity_registry as er
from . import BeoConfigEntry
from .const import DOMAIN
from .util import get_device_buttons
from .util import get_device_buttons, get_remote_keys, get_remotes
async def async_get_config_entry_diagnostics(
@@ -53,4 +53,23 @@ async def async_get_config_entry_diagnostics(
state_dict.pop("context")
data[f"{device_button}_event"] = state_dict
# Get remotes
for remote in await get_remotes(config_entry.runtime_data.client):
# Get key Event entity states (if enabled)
for key_type in get_remote_keys():
if entity_id := entity_registry.async_get_entity_id(
EVENT_DOMAIN,
DOMAIN,
f"{remote.serial_number}_{config_entry.unique_id}_{key_type}",
):
if state := hass.states.get(entity_id):
state_dict = dict(state.as_dict())
# Remove context as it is not relevant
state_dict.pop("context")
data[f"remote_{remote.serial_number}_{key_type}_event"] = state_dict
# Add remote Mozart model
data[f"remote_{remote.serial_number}"] = dict(remote)
return data

View File

@@ -16,11 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from . import BeoConfigEntry
from .const import (
BEO_REMOTE_CONTROL_KEYS,
BEO_REMOTE_KEY_EVENTS,
BEO_REMOTE_KEYS,
BEO_REMOTE_SUBMENU_CONTROL,
BEO_REMOTE_SUBMENU_LIGHT,
CONNECTION_STATUS,
DEVICE_BUTTON_EVENTS,
DOMAIN,
@@ -29,7 +25,7 @@ from .const import (
WebsocketNotification,
)
from .entity import BeoEntity
from .util import get_device_buttons, get_remotes
from .util import get_device_buttons, get_remote_keys, get_remotes
PARALLEL_UPDATES = 0
@@ -40,38 +36,19 @@ async def async_setup_entry(
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Event entities from config entry."""
entities: list[BeoEvent] = []
async_add_entities(
entities: list[BeoEvent] = [
BeoButtonEvent(config_entry, button_type)
for button_type in get_device_buttons(config_entry.data[CONF_MODEL])
)
]
# Check for connected Beoremote One
remotes = await get_remotes(config_entry.runtime_data.client)
for remote in remotes:
# Add Light keys
entities.extend(
[
BeoRemoteKeyEvent(
config_entry,
remote,
f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}",
)
for key_type in BEO_REMOTE_KEYS
]
)
# Add Control keys
entities.extend(
[
BeoRemoteKeyEvent(
config_entry,
remote,
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}",
)
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
BeoRemoteKeyEvent(config_entry, remote, key_type)
for key_type in get_remote_keys()
]
)

View File

@@ -11,7 +11,16 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from homeassistant.helpers.device_registry import DeviceEntry
from .const import DEVICE_BUTTONS, DOMAIN, BeoButtons, BeoModel
from .const import (
BEO_REMOTE_CONTROL_KEYS,
BEO_REMOTE_KEYS,
BEO_REMOTE_SUBMENU_CONTROL,
BEO_REMOTE_SUBMENU_LIGHT,
DEVICE_BUTTONS,
DOMAIN,
BeoButtons,
BeoModel,
)
def get_device(hass: HomeAssistant, unique_id: str) -> DeviceEntry:
@@ -64,3 +73,14 @@ def get_device_buttons(model: BeoModel) -> list[str]:
buttons.remove(BeoButtons.BLUETOOTH)
return buttons
def get_remote_keys() -> list[str]:
"""Get remote keys for the Beoremote One. Formatted for Home Assistant use."""
return [
*[f"{BEO_REMOTE_SUBMENU_LIGHT}/{key_type}" for key_type in BEO_REMOTE_KEYS],
*[
f"{BEO_REMOTE_SUBMENU_CONTROL}/{key_type}"
for key_type in (*BEO_REMOTE_KEYS, *BEO_REMOTE_CONTROL_KEYS)
],
]

View File

@@ -98,18 +98,6 @@
}
},
"triggers": {
"current_humidity_changed": {
"trigger": "mdi:water-percent"
},
"current_humidity_crossed_threshold": {
"trigger": "mdi:water-percent"
},
"current_temperature_changed": {
"trigger": "mdi:thermometer"
},
"current_temperature_crossed_threshold": {
"trigger": "mdi:thermometer"
},
"hvac_mode_changed": {
"trigger": "mdi:thermostat"
},
@@ -122,12 +110,6 @@
"started_heating": {
"trigger": "mdi:fire"
},
"target_humidity_changed": {
"trigger": "mdi:water-percent"
},
"target_humidity_crossed_threshold": {
"trigger": "mdi:water-percent"
},
"target_temperature_changed": {
"trigger": "mdi:thermometer"
},

View File

@@ -312,78 +312,6 @@
},
"title": "Climate",
"triggers": {
"current_humidity_changed": {
"description": "Triggers after the humidity measured by one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the humidity is below this value.",
"name": "Below"
}
},
"name": "Climate-control device current humidity changed"
},
"current_humidity_crossed_threshold": {
"description": "Triggers after the humidity measured by one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device current humidity crossed threshold"
},
"current_temperature_changed": {
"description": "Triggers after the temperature measured by one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the temperature is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the temperature is below this value.",
"name": "Below"
}
},
"name": "Climate-control device current temperature changed"
},
"current_temperature_crossed_threshold": {
"description": "Triggers after the temperature measured by one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device current temperature crossed threshold"
},
"hvac_mode_changed": {
"description": "Triggers after the mode of one or more climate-control devices changes.",
"fields": {
@@ -428,42 +356,6 @@
},
"name": "Climate-control device started heating"
},
"target_humidity_changed": {
"description": "Triggers after the humidity setpoint of one or more climate-control devices changes.",
"fields": {
"above": {
"description": "Trigger when the target humidity is above this value.",
"name": "Above"
},
"below": {
"description": "Trigger when the target humidity is below this value.",
"name": "Below"
}
},
"name": "Climate-control device target humidity changed"
},
"target_humidity_crossed_threshold": {
"description": "Triggers after the humidity setpoint of one or more climate-control devices crosses a threshold.",
"fields": {
"behavior": {
"description": "[%key:component::climate::common::trigger_behavior_description%]",
"name": "[%key:component::climate::common::trigger_behavior_name%]"
},
"lower_limit": {
"description": "Lower threshold limit.",
"name": "Lower threshold"
},
"threshold_type": {
"description": "Type of threshold crossing to trigger on.",
"name": "Threshold type"
},
"upper_limit": {
"description": "Upper threshold limit.",
"name": "Upper threshold"
}
},
"name": "Climate-control device target humidity crossed threshold"
},
"target_temperature_changed": {
"description": "Triggers after the temperature setpoint of one or more climate-control devices changes.",
"fields": {

View File

@@ -17,15 +17,7 @@ from homeassistant.helpers.trigger import (
make_entity_transition_trigger,
)
from .const import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
DOMAIN,
HVACAction,
HVACMode,
)
from .const import ATTR_HVAC_ACTION, DOMAIN, HVACAction, HVACMode
CONF_HVAC_MODE = "hvac_mode"
@@ -53,18 +45,6 @@ class HVACModeChangedTrigger(EntityTargetStateTriggerBase):
TRIGGERS: dict[str, type[Trigger]] = {
"current_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_CURRENT_HUMIDITY
),
"current_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_CURRENT_HUMIDITY
),
"current_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_CURRENT_TEMPERATURE
),
"current_temperature_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_CURRENT_TEMPERATURE
),
"hvac_mode_changed": HVACModeChangedTrigger,
"started_cooling": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.COOLING
@@ -72,12 +52,6 @@ TRIGGERS: dict[str, type[Trigger]] = {
"started_drying": make_entity_target_state_attribute_trigger(
DOMAIN, ATTR_HVAC_ACTION, HVACAction.DRYING
),
"target_humidity_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_HUMIDITY
),
"target_humidity_crossed_threshold": make_entity_numerical_state_attribute_crossed_threshold_trigger(
DOMAIN, ATTR_HUMIDITY
),
"target_temperature_changed": make_entity_numerical_state_attribute_changed_trigger(
DOMAIN, ATTR_TEMPERATURE
),

View File

@@ -65,48 +65,6 @@ hvac_mode_changed:
- unknown
multiple: true
current_humidity_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
current_humidity_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
target_humidity_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
target_humidity_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
current_temperature_changed:
target: *trigger_climate_target
fields:
above: *number_or_entity
below: *number_or_entity
current_temperature_crossed_threshold:
target: *trigger_climate_target
fields:
behavior: *trigger_behavior
threshold_type: *trigger_threshold_type
lower_limit: *number_or_entity
upper_limit: *number_or_entity
target_temperature_changed:
target: *trigger_climate_target
fields:

View File

@@ -70,6 +70,7 @@ MEDIA_MODES = {
"Favorites": "FAVORITES",
"Internet Radio": "IRADIO",
"USB/IPOD": "USB/IPOD",
"USB": "USB",
}
# Sub-modes of 'NET/USB'
@@ -279,7 +280,7 @@ class DenonDevice(MediaPlayerEntity):
def mute_volume(self, mute: bool) -> None:
"""Mute (true) or unmute (false) media player."""
mute_status = "ON" if mute else "OFF"
self.telnet_command(f"MU{mute_status})")
self.telnet_command(f"MU{mute_status}")
def media_play(self) -> None:
"""Play media player."""

View File

@@ -110,7 +110,7 @@ async def async_register_dynalite_frontend(hass: HomeAssistant):
frontend_url_path=DOMAIN,
config_panel_domain=DOMAIN,
webcomponent_name="dynalite-panel",
module_url=f"{URL_BASE}/entrypoint-{build_id}.js",
module_url=f"{URL_BASE}/entrypoint.{build_id}.js",
embed_iframe=True,
require_admin=True,
)

View File

@@ -70,6 +70,16 @@ async def async_setup_entry(hass: HomeAssistant, entry: HikvisionConfigEntry) ->
device_type=device_type,
)
# For NVRs or devices with no detected events, try to fetch events from ISAPI
if device_type == "NVR" or not camera.current_event_states:
def fetch_and_inject_nvr_events() -> None:
"""Fetch and inject NVR events in a single executor job."""
if nvr_events := camera.get_event_triggers(None):
camera.inject_events(nvr_events)
await hass.async_add_executor_job(fetch_and_inject_nvr_events)
# Start the event stream
await hass.async_add_executor_job(camera.start_stream)

View File

@@ -107,7 +107,7 @@ async def async_register_insteon_frontend(hass: HomeAssistant):
frontend_url_path=DOMAIN,
webcomponent_name="insteon-frontend",
config_panel_domain=DOMAIN,
module_url=f"{URL_BASE}/entrypoint-{build_id}.js",
module_url=f"{URL_BASE}/entrypoint.{build_id}.js",
embed_iframe=True,
require_admin=True,
)

View File

@@ -282,6 +282,8 @@ async def websocket_reset_properties(
notify_device_not_found(connection, msg, INSTEON_DEVICE_NOT_FOUND)
return
for prop in device.configuration.values():
prop.new_value = None
for prop in device.operating_flags:
device.operating_flags[prop].new_value = None
for prop in device.properties:

View File

@@ -19,7 +19,7 @@
"loggers": ["pyinsteon", "pypubsub"],
"requirements": [
"pyinsteon==1.6.4",
"insteon-frontend-home-assistant==0.5.0"
"insteon-frontend-home-assistant==0.6.0"
],
"single_config_entry": true,
"usb": [

View File

@@ -1,7 +1,7 @@
{
"domain": "intent_script",
"name": "Intent Script",
"codeowners": [],
"codeowners": ["@arturpragacz"],
"documentation": "https://www.home-assistant.io/integrations/intent_script",
"quality_scale": "internal"
}

View File

@@ -187,7 +187,7 @@
"8_005": "[%key:component::knx::config_panel::dpt::options::8_002%]",
"8_006": "[%key:component::knx::config_panel::dpt::options::8_002%]",
"8_007": "[%key:component::knx::config_panel::dpt::options::8_002%]",
"8_010": "Percent (-327,68 … 327,67)",
"8_010": "Percent (-327.68 … 327.67)",
"8_011": "Rotation angle",
"8_012": "Length (Altitude)",
"9": "Generic 2-byte floating point",
@@ -1061,7 +1061,7 @@
"name": "[%key:component::knx::services::send::fields::address::name%]"
},
"attribute": {
"description": "Attribute of the entity that shall be sent to the KNX bus. If not set the state will be sent. Eg. for a light the state is eigther “on” or “off” - with attribute you can expose its “brightness”.",
"description": "Attribute of the entity that shall be sent to the KNX bus. If not set, the state will be sent. Eg. for a light the state is either “on” or “off” - with attribute you can expose its “brightness”.",
"name": "Entity attribute"
},
"default": {

View File

@@ -22,5 +22,19 @@
"unlock": {
"service": "mdi:lock-open-variant"
}
},
"triggers": {
"jammed": {
"trigger": "mdi:lock-alert"
},
"locked": {
"trigger": "mdi:lock"
},
"opened": {
"trigger": "mdi:lock-open-variant"
},
"unlocked": {
"trigger": "mdi:lock-open-variant"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted locks to trigger on.",
"trigger_behavior_name": "Behavior"
},
"device_automation": {
"action_type": {
"lock": "Lock {entity_name}",
@@ -50,6 +54,15 @@
"message": "The code for {entity_id} doesn't match pattern {code_format}."
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"lock": {
"description": "Locks a lock.",
@@ -82,5 +95,47 @@
"name": "Unlock"
}
},
"title": "Lock"
"title": "Lock",
"triggers": {
"jammed": {
"description": "Triggers after one or more locks jam.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock jammed"
},
"locked": {
"description": "Triggers after one or more locks lock.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock locked"
},
"opened": {
"description": "Triggers after one or more locks open.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock opened"
},
"unlocked": {
"description": "Triggers after one or more locks unlock.",
"fields": {
"behavior": {
"description": "[%key:component::lock::common::trigger_behavior_description%]",
"name": "[%key:component::lock::common::trigger_behavior_name%]"
}
},
"name": "Lock unlocked"
}
}
}

View File

@@ -0,0 +1,18 @@
"""Provides triggers for locks."""
from homeassistant.core import HomeAssistant
from homeassistant.helpers.trigger import Trigger, make_entity_target_state_trigger
from .const import DOMAIN, LockState
TRIGGERS: dict[str, type[Trigger]] = {
"jammed": make_entity_target_state_trigger(DOMAIN, LockState.JAMMED),
"locked": make_entity_target_state_trigger(DOMAIN, LockState.LOCKED),
"opened": make_entity_target_state_trigger(DOMAIN, LockState.OPEN),
"unlocked": make_entity_target_state_trigger(DOMAIN, LockState.UNLOCKED),
}
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
"""Return the triggers for locks."""
return TRIGGERS

View File

@@ -0,0 +1,20 @@
.trigger_common: &trigger_common
target:
entity:
domain: lock
fields:
behavior:
required: true
default: any
selector:
select:
options:
- first
- last
- any
translation_key: trigger_behavior
jammed: *trigger_common
locked: *trigger_common
opened: *trigger_common
unlocked: *trigger_common

View File

@@ -4,6 +4,7 @@
"codeowners": ["@adrianmo"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/meteoclimatic",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["meteoclimatic"],
"requirements": ["pymeteoclimatic==0.1.0"]

View File

@@ -4,6 +4,7 @@
"codeowners": ["@MrHarcombe", "@avee87"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/metoffice",
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["datapoint"],
"requirements": ["datapoint==0.12.1"]

View File

@@ -19,6 +19,8 @@ from .entity import PooldoseEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
BINARY_SENSOR_DESCRIPTIONS: tuple[BinarySensorEntityDescription, ...] = (
BinarySensorEntityDescription(

View File

@@ -26,6 +26,8 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
NUMBER_DESCRIPTIONS: tuple[NumberEntityDescription, ...] = (
NumberEntityDescription(

View File

@@ -35,7 +35,7 @@ rules:
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: todo
parallel-updates: done
reauthentication-flow:
status: exempt
comment: This integration does not need authentication for the local API.

View File

@@ -20,6 +20,8 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class PooldoseSelectEntityDescription(SelectEntityDescription):

View File

@@ -27,6 +27,8 @@ from .entity import PooldoseEntity
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
@dataclass(frozen=True, kw_only=True)
class PooldoseSensorEntityDescription(SensorEntityDescription):

View File

@@ -18,6 +18,8 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 1
SWITCH_DESCRIPTIONS: tuple[SwitchEntityDescription, ...] = (
SwitchEntityDescription(

View File

@@ -11,5 +11,5 @@
"documentation": "https://www.home-assistant.io/integrations/qnap_qsw",
"iot_class": "local_polling",
"loggers": ["aioqsw"],
"requirements": ["aioqsw==0.4.1"]
"requirements": ["aioqsw==0.4.2"]
}

View File

@@ -14,5 +14,13 @@
"turn_on": {
"service": "mdi:bullhorn"
}
},
"triggers": {
"turned_off": {
"trigger": "mdi:bullhorn-outline"
},
"turned_on": {
"trigger": "mdi:bullhorn"
}
}
}

View File

@@ -1,4 +1,8 @@
{
"common": {
"trigger_behavior_description": "The behavior of the targeted sirens to trigger on.",
"trigger_behavior_name": "Behavior"
},
"entity_component": {
"_": {
"name": "[%key:component::siren::title%]",
@@ -13,6 +17,15 @@
}
}
},
"selector": {
"trigger_behavior": {
"options": {
"any": "Any",
"first": "First",
"last": "Last"
}
}
},
"services": {
"toggle": {
"description": "Toggles the siren on/off.",
@@ -41,5 +54,27 @@
"name": "[%key:common::action::turn_on%]"
}
},
"title": "Siren"
"title": "Siren",
"triggers": {
"turned_off": {
"description": "Triggers after one or more sirens turn off.",
"fields": {
"behavior": {
"description": "[%key:component::siren::common::trigger_behavior_description%]",
"name": "[%key:component::siren::common::trigger_behavior_name%]"
}
},
"name": "Siren turned off"
},
"turned_on": {
"description": "Triggers after one or more sirens turn on.",
"fields": {
"behavior": {
"description": "[%key:component::siren::common::trigger_behavior_description%]",
"name": "[%key:component::siren::common::trigger_behavior_name%]"
}
},
"name": "Siren turned on"
}
}
}

View File

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

View File

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

View File

@@ -3,9 +3,10 @@
from __future__ import annotations
import asyncio
from collections.abc import Sequence
import logging
from PySrDaliGateway import DaliGateway
from PySrDaliGateway import DaliGateway, Device
from PySrDaliGateway.exceptions import DaliGatewayError
from homeassistant.const import (
@@ -28,6 +29,38 @@ _PLATFORMS: list[Platform] = [Platform.LIGHT, Platform.SCENE]
_LOGGER = logging.getLogger(__name__)
def _remove_missing_devices(
hass: HomeAssistant,
entry: DaliCenterConfigEntry,
devices: Sequence[Device],
gateway_identifier: tuple[str, str],
) -> None:
"""Detach devices that are no longer provided by the gateway."""
device_registry = dr.async_get(hass)
known_device_ids = {device.dev_id for device in devices}
for device_entry in dr.async_entries_for_config_entry(
device_registry, entry.entry_id
):
if gateway_identifier in device_entry.identifiers:
continue
domain_device_ids = {
identifier[1]
for identifier in device_entry.identifiers
if identifier[0] == DOMAIN
}
if not domain_device_ids:
continue
if domain_device_ids.isdisjoint(known_device_ids):
device_registry.async_update_device(
device_entry.id,
remove_config_entry_id=entry.entry_id,
)
async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool:
"""Set up Sunricher DALI from a config entry."""
@@ -70,6 +103,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -
model="SR-GW-EDA",
serial_number=gw_sn,
)
_remove_missing_devices(hass, entry, devices, (DOMAIN, gw_sn))
entry.runtime_data = DaliCenterData(
gateway=gateway,

View File

@@ -31,6 +31,7 @@ from homeassistant.const import (
CONF_VALUE_TEMPLATE,
CONF_VERIFY_SSL,
Platform,
UnitOfTemperature,
)
from homeassistant.core import HomeAssistant, callback
from homeassistant.data_entry_flow import section
@@ -132,6 +133,15 @@ from .vacuum import (
SERVICE_STOP,
async_create_preview_vacuum,
)
from .weather import (
CONF_CONDITION,
CONF_FORECAST_DAILY,
CONF_FORECAST_HOURLY,
CONF_HUMIDITY,
CONF_TEMPERATURE as CONF_WEATHER_TEMPERATURE,
CONF_TEMPERATURE_UNIT,
async_create_preview_weather,
)
_SCHEMA_STATE: dict[vol.Marker, Any] = {
vol.Required(CONF_STATE): selector.TemplateSelector(),
@@ -394,6 +404,22 @@ def generate_schema(domain: str, flow_type: str) -> vol.Schema:
vol.Optional(SERVICE_LOCATE): selector.ActionSelector(),
}
if domain == Platform.WEATHER:
schema |= {
vol.Required(CONF_CONDITION): selector.TemplateSelector(),
vol.Required(CONF_HUMIDITY): selector.TemplateSelector(),
vol.Required(CONF_WEATHER_TEMPERATURE): selector.TemplateSelector(),
vol.Optional(CONF_TEMPERATURE_UNIT): selector.SelectSelector(
selector.SelectSelectorConfig(
options=[cls.value for cls in UnitOfTemperature],
mode=selector.SelectSelectorMode.DROPDOWN,
sort=True,
),
),
vol.Optional(CONF_FORECAST_DAILY): selector.TemplateSelector(),
vol.Optional(CONF_FORECAST_HOURLY): selector.TemplateSelector(),
}
schema |= {
vol.Optional(CONF_DEVICE_ID): selector.DeviceSelector(),
vol.Optional(CONF_ADVANCED_OPTIONS): section(
@@ -414,6 +440,15 @@ options_schema = partial(generate_schema, flow_type="options")
config_schema = partial(generate_schema, flow_type="config")
async def _get_forecast_description_place_holders(
handler: SchemaCommonFlowHandler,
) -> dict[str, str]:
return {
"daily_link": "https://www.home-assistant.io/integrations/template/#daily-weather-forecast",
"hourly_link": "https://www.home-assistant.io/integrations/template/#hourly-weather-forecast",
}
async def choose_options_step(options: dict[str, Any]) -> str:
"""Return next step_id for options flow according to template_type."""
return cast(str, options["template_type"])
@@ -511,6 +546,7 @@ TEMPLATE_TYPES = [
Platform.SWITCH,
Platform.UPDATE,
Platform.VACUUM,
Platform.WEATHER,
]
CONFIG_FLOW = {
@@ -589,6 +625,12 @@ CONFIG_FLOW = {
preview="template",
validate_user_input=validate_user_input(Platform.VACUUM),
),
Platform.WEATHER: SchemaFlowFormStep(
config_schema(Platform.WEATHER),
preview="template",
validate_user_input=validate_user_input(Platform.WEATHER),
description_placeholders=_get_forecast_description_place_holders,
),
}
@@ -668,6 +710,12 @@ OPTIONS_FLOW = {
preview="template",
validate_user_input=validate_user_input(Platform.VACUUM),
),
Platform.WEATHER: SchemaFlowFormStep(
options_schema(Platform.WEATHER),
preview="template",
validate_user_input=validate_user_input(Platform.WEATHER),
description_placeholders=_get_forecast_description_place_holders,
),
}
CREATE_PREVIEW_ENTITY: dict[
@@ -687,6 +735,7 @@ CREATE_PREVIEW_ENTITY: dict[
Platform.SWITCH: async_create_preview_switch,
Platform.UPDATE: async_create_preview_update,
Platform.VACUUM: async_create_preview_vacuum,
Platform.WEATHER: async_create_preview_weather,
}

View File

@@ -463,7 +463,8 @@
"sensor": "[%key:component::sensor::title%]",
"switch": "[%key:component::switch::title%]",
"update": "[%key:component::update::title%]",
"vacuum": "[%key:component::vacuum::title%]"
"vacuum": "[%key:component::vacuum::title%]",
"weather": "[%key:component::weather::title%]"
},
"title": "Template helper"
},
@@ -507,6 +508,36 @@
}
},
"title": "Template vacuum"
},
"weather": {
"data": {
"condition": "Condition",
"device_id": "[%key:common::config_flow::data::device%]",
"forecast_daily": "Forecast daily",
"forecast_hourly": "Forecast hourly",
"humidity": "Humidity",
"name": "[%key:common::config_flow::data::name%]",
"temperature": "Temperature",
"temperature_unit": "Temperature unit"
},
"data_description": {
"condition": "Defines a template to get the current weather condition",
"device_id": "[%key:component::template::common::device_id_description%]",
"forecast_daily": "Defines a template to get the [daily forecast data]({daily_link})",
"forecast_hourly": "Defines a template to get the [hourly forecast data]({hourly_link})",
"humidity": "Defines a template to get the current humidity",
"temperature": "Defines a template to get the current temperature",
"temperature_unit": "The temperature unit"
},
"sections": {
"advanced_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"name": "[%key:component::template::common::advanced_options%]"
}
},
"title": "Template weather"
}
}
},
@@ -995,6 +1026,36 @@
}
},
"title": "[%key:component::template::config::step::vacuum::title%]"
},
"weather": {
"data": {
"condition": "[%key:component::template::config::step::weather::data::condition%]",
"device_id": "[%key:common::config_flow::data::device%]",
"forecast_daily": "[%key:component::template::config::step::weather::data::forecast_daily%]",
"forecast_hourly": "[%key:component::template::config::step::weather::data::forecast_hourly%]",
"humidity": "[%key:component::template::config::step::weather::data::humidity%]",
"name": "[%key:common::config_flow::data::name%]",
"temperature": "[%key:component::template::config::step::weather::data::temperature%]",
"temperature_unit": "[%key:component::template::config::step::weather::data::temperature_unit%]"
},
"data_description": {
"condition": "[%key:component::template::config::step::weather::data_description::condition%]",
"device_id": "[%key:component::template::common::device_id_description%]",
"forecast_daily": "[%key:component::template::config::step::weather::data_description::forecast_daily%]",
"forecast_hourly": "[%key:component::template::config::step::weather::data_description::forecast_hourly%]",
"humidity": "[%key:component::template::config::step::weather::data_description::humidity%]",
"temperature": "[%key:component::template::config::step::weather::data_description::temperature%]",
"temperature_unit": "[%key:component::template::config::step::weather::data_description::temperature_unit%]"
},
"sections": {
"advanced_options": {
"data": {
"availability": "[%key:component::template::common::availability%]"
},
"name": "[%key:component::template::common::advanced_options%]"
}
},
"title": "[%key:component::template::config::step::weather::title%]"
}
}
},

File diff suppressed because it is too large Load Diff

View File

@@ -40,6 +40,7 @@
"integration_type": "hub",
"iot_class": "local_push",
"loggers": ["uiprotect", "unifi_discovery"],
"quality_scale": "platinum",
"requirements": ["uiprotect==7.33.2", "unifi-discovery==1.2.0"],
"ssdp": [
{

View File

@@ -0,0 +1,68 @@
rules:
# Bronze
action-setup: done
appropriate-polling:
status: exempt
comment: Integration is push-based using WebSockets (iot_class local_push).
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions: done
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: done
unique-config-entry: done
# Silver
action-exceptions: done
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow: done
test-coverage:
status: done
comment: Diagnostics tests could use snapshot testing.
# Gold
devices: done
diagnostics: done
discovery-update-info: done
discovery: done
docs-data-update: done
docs-examples: done
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: done
docs-use-cases: done
dynamic-devices: done
entity-category: done
entity-device-class:
status: done
comment: "Planned improvement: remove doorbell occupancy binary sensor and keep the event sensor after a solution for https://github.com/home-assistant/core/issues/145941 is available."
entity-disabled-by-default: done
entity-translations:
status: done
comment: "Planned improvement: camera insecure is not translated but will be dropped soon with public api migration"
exception-translations: done
icon-translations: done
reconfiguration-flow: done
repair-issues: done
stale-devices: done
# Platinum
async-dependency: done
inject-websession: done
strict-typing: done

View File

@@ -48,37 +48,37 @@ _KEY_LIGHT_MOTION = "light_motion"
PARALLEL_UPDATES = 0
HDR_MODES = [
{"id": "always", "name": "Always On"},
{"id": "off", "name": "Always Off"},
{"id": "auto", "name": "Auto"},
{"id": "always", "name": "always"},
{"id": "off", "name": "off"},
{"id": "auto", "name": "auto"},
]
INFRARED_MODES = [
{"id": IRLEDMode.AUTO.value, "name": "Auto"},
{"id": IRLEDMode.ON.value, "name": "Always Enable"},
{"id": IRLEDMode.AUTO_NO_LED.value, "name": "Auto (Filter Only, no LED's)"},
{"id": IRLEDMode.CUSTOM.value, "name": "Auto (Custom Lux)"},
{"id": IRLEDMode.OFF.value, "name": "Always Disable"},
{"id": IRLEDMode.AUTO.value, "name": "auto"},
{"id": IRLEDMode.ON.value, "name": "on"},
{"id": IRLEDMode.AUTO_NO_LED.value, "name": "auto_filter_only"},
{"id": IRLEDMode.CUSTOM.value, "name": "custom"},
{"id": IRLEDMode.OFF.value, "name": "off"},
]
CHIME_TYPES = [
{"id": ChimeType.NONE.value, "name": "None"},
{"id": ChimeType.MECHANICAL.value, "name": "Mechanical"},
{"id": ChimeType.DIGITAL.value, "name": "Digital"},
{"id": ChimeType.NONE.value, "name": "none"},
{"id": ChimeType.MECHANICAL.value, "name": "mechanical"},
{"id": ChimeType.DIGITAL.value, "name": "digital"},
]
MOUNT_TYPES = [
{"id": MountType.NONE.value, "name": "None"},
{"id": MountType.DOOR.value, "name": "Door"},
{"id": MountType.WINDOW.value, "name": "Window"},
{"id": MountType.GARAGE.value, "name": "Garage"},
{"id": MountType.LEAK.value, "name": "Leak"},
{"id": MountType.NONE.value, "name": MountType.NONE.value},
{"id": MountType.DOOR.value, "name": MountType.DOOR.value},
{"id": MountType.WINDOW.value, "name": MountType.WINDOW.value},
{"id": MountType.GARAGE.value, "name": MountType.GARAGE.value},
{"id": MountType.LEAK.value, "name": MountType.LEAK.value},
]
LIGHT_MODE_MOTION = "On Motion - Always"
LIGHT_MODE_MOTION_DARK = "On Motion - When Dark"
LIGHT_MODE_DARK = "When Dark"
LIGHT_MODE_OFF = "Manual"
LIGHT_MODE_MOTION = "motion"
LIGHT_MODE_MOTION_DARK = "motion_dark"
LIGHT_MODE_DARK = "when_dark"
LIGHT_MODE_OFF = "manual"
LIGHT_MODES = [LIGHT_MODE_MOTION, LIGHT_MODE_DARK, LIGHT_MODE_OFF]
LIGHT_MODE_TO_SETTINGS = {
@@ -93,13 +93,13 @@ LIGHT_MODE_TO_SETTINGS = {
MOTION_MODE_TO_LIGHT_MODE = [
{"id": LightModeType.MOTION.value, "name": LIGHT_MODE_MOTION},
{"id": f"{LightModeType.MOTION.value}Dark", "name": LIGHT_MODE_MOTION_DARK},
{"id": f"{LightModeType.MOTION.value}_dark", "name": LIGHT_MODE_MOTION_DARK},
{"id": LightModeType.WHEN_DARK.value, "name": LIGHT_MODE_DARK},
{"id": LightModeType.MANUAL.value, "name": LIGHT_MODE_OFF},
]
DEVICE_RECORDING_MODES = [
{"id": mode.value, "name": mode.value.title()} for mode in list(RecordingMode)
{"id": mode.value, "name": mode.value} for mode in list(RecordingMode)
]

View File

@@ -350,31 +350,68 @@
},
"select": {
"chime_type": {
"name": "Chime type"
"name": "Chime type",
"state": {
"digital": "Digital",
"mechanical": "Mechanical",
"none": "[%key:common::state::off%]"
}
},
"doorbell_text": {
"name": "Doorbell text"
},
"hdr_mode": {
"name": "[%key:component::unifiprotect::entity::binary_sensor::hdr_mode::name%]"
"name": "[%key:component::unifiprotect::entity::binary_sensor::hdr_mode::name%]",
"state": {
"always": "Always on",
"auto": "Auto",
"off": "Always off"
}
},
"infrared_mode": {
"name": "Infrared mode"
"name": "Infrared mode",
"state": {
"auto": "Auto",
"auto_filter_only": "Auto (filter only, no LEDs)",
"custom": "Auto (custom lux)",
"off": "Always disable",
"on": "Always enable"
}
},
"light_mode": {
"name": "Light mode"
"name": "Light mode",
"state": {
"manual": "Manual",
"motion": "On motion - always",
"motion_dark": "On motion - when dark",
"when_dark": "When dark"
}
},
"liveview": {
"name": "Liveview"
},
"mount_type": {
"name": "Mount type"
"name": "Mount type",
"state": {
"door": "Door",
"garage": "Garage",
"leak": "Leak",
"none": "[%key:common::state::off%]",
"window": "Window"
}
},
"paired_camera": {
"name": "Paired camera"
},
"recording_mode": {
"name": "Recording mode"
"name": "Recording mode",
"state": {
"adaptive": "Adaptive",
"always": "Always",
"detections": "Detections",
"never": "Never",
"schedule": "Schedule"
}
}
},
"sensor": {

View File

@@ -104,7 +104,7 @@ def async_get_light_motion_current(obj: Light) -> str:
obj.light_mode_settings.mode is LightModeType.MOTION
and obj.light_mode_settings.enable_at is LightModeEnableType.DARK
):
return f"{LightModeType.MOTION.value}Dark"
return f"{LightModeType.MOTION.value}_dark"
return obj.light_mode_settings.mode.value

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
from pyvlx import PyVLX, PyVLXException
from homeassistant.config_entries import ConfigEntry
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.const import (
CONF_HOST,
CONF_MAC,
@@ -12,13 +12,54 @@ from homeassistant.const import (
EVENT_HOMEASSISTANT_STOP,
)
from homeassistant.core import HomeAssistant, ServiceCall
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import device_registry as dr, issue_registry as ir
from homeassistant.exceptions import ConfigEntryNotReady, ServiceValidationError
from homeassistant.helpers import (
config_validation as cv,
device_registry as dr,
issue_registry as ir,
)
from homeassistant.helpers.typing import ConfigType
from .const import DOMAIN, LOGGER, PLATFORMS
type VeluxConfigEntry = ConfigEntry[PyVLX]
CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the Velux component."""
async def async_reboot_gateway(service_call: ServiceCall) -> None:
"""Reboot the gateway (deprecated - use button entity instead)."""
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_reboot_service",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_reboot_service",
breaks_in_ha_version="2026.6.0",
)
# Find a loaded config entry to get the PyVLX instance
# We assume only one gateway is set up or we just reboot the first one found
# (this is no change to the previous behavior, the alternative would be to reboot all)
for entry in hass.config_entries.async_entries(DOMAIN):
if entry.state is ConfigEntryState.LOADED:
await entry.runtime_data.reboot_gateway()
return
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="no_gateway_loaded",
)
hass.services.async_register(DOMAIN, "reboot_gateway", async_reboot_gateway)
return True
async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
"""Set up the velux component."""
@@ -67,27 +108,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
LOGGER.debug("Velux interface terminated")
await pyvlx.disconnect()
async def async_reboot_gateway(service_call: ServiceCall) -> None:
"""Reboot the gateway (deprecated - use button entity instead)."""
ir.async_create_issue(
hass,
DOMAIN,
"deprecated_reboot_service",
is_fixable=False,
issue_domain=DOMAIN,
severity=ir.IssueSeverity.WARNING,
translation_key="deprecated_reboot_service",
breaks_in_ha_version="2026.6.0",
)
await pyvlx.reboot_gateway()
entry.async_on_unload(
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, on_hass_stop)
)
hass.services.async_register(DOMAIN, "reboot_gateway", async_reboot_gateway)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
@@ -95,4 +119,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> boo
async def async_unload_entry(hass: HomeAssistant, entry: VeluxConfigEntry) -> bool:
"""Unload a config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
# Disconnect from gateway only after platforms are successfully unloaded.
# Disconnecting will reboot the gateway in the pyvlx library, which is needed to allow new
# connections to be made later.
await entry.runtime_data.disconnect()
return unload_ok

View File

@@ -1,8 +1,6 @@
rules:
# Bronze
action-setup:
status: todo
comment: needs to move to async_setup
action-setup: done
appropriate-polling: done
brands: done
common-modules: done
@@ -25,7 +23,7 @@ rules:
# Silver
action-exceptions: todo
config-entry-unloading: todo
config-entry-unloading: done
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo

View File

@@ -37,6 +37,9 @@
}
},
"exceptions": {
"no_gateway_loaded": {
"message": "No loaded Velux gateway found"
},
"reboot_failed": {
"message": "Failed to reboot gateway. Try again in a few moments or power cycle the device manually"
}

View File

@@ -13,5 +13,5 @@
"documentation": "https://www.home-assistant.io/integrations/yale",
"iot_class": "cloud_push",
"loggers": ["socketio", "engineio", "yalexs"],
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.2"]
"requirements": ["yalexs==9.2.0", "yalexs-ble==3.2.4"]
}

View File

@@ -12,5 +12,5 @@
"dependencies": ["bluetooth_adapters"],
"documentation": "https://www.home-assistant.io/integrations/yalexs_ble",
"iot_class": "local_push",
"requirements": ["yalexs-ble==3.2.2"]
"requirements": ["yalexs-ble==3.2.4"]
}

View File

@@ -3956,13 +3956,13 @@
},
"meteoclimatic": {
"name": "Meteoclimatic",
"integration_type": "hub",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},
"metoffice": {
"name": "Met Office",
"integration_type": "hub",
"integration_type": "service",
"config_flow": true,
"iot_class": "cloud_polling"
},

6
requirements_all.txt generated
View File

@@ -369,7 +369,7 @@ aiopvpc==4.2.2
aiopyarr==23.4.0
# homeassistant.components.qnap_qsw
aioqsw==0.4.1
aioqsw==0.4.2
# homeassistant.components.rainforest_raven
aioraven==0.7.1
@@ -1291,7 +1291,7 @@ influxdb==5.3.1
inkbird-ble==1.1.1
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.5.0
insteon-frontend-home-assistant==0.6.0
# homeassistant.components.intellifire
intellifire4py==4.2.1
@@ -3224,7 +3224,7 @@ yalesmartalarmclient==0.4.3
# homeassistant.components.august
# homeassistant.components.yale
# homeassistant.components.yalexs_ble
yalexs-ble==3.2.2
yalexs-ble==3.2.4
# homeassistant.components.august
# homeassistant.components.yale

View File

@@ -354,7 +354,7 @@ aiopvpc==4.2.2
aiopyarr==23.4.0
# homeassistant.components.qnap_qsw
aioqsw==0.4.1
aioqsw==0.4.2
# homeassistant.components.rainforest_raven
aioraven==0.7.1
@@ -1137,7 +1137,7 @@ influxdb==5.3.1
inkbird-ble==1.1.1
# homeassistant.components.insteon
insteon-frontend-home-assistant==0.5.0
insteon-frontend-home-assistant==0.6.0
# homeassistant.components.intellifire
intellifire4py==4.2.1
@@ -2691,7 +2691,7 @@ yalesmartalarmclient==0.4.3
# homeassistant.components.august
# homeassistant.components.yale
# homeassistant.components.yalexs_ble
yalexs-ble==3.2.2
yalexs-ble==3.2.4
# homeassistant.components.august
# homeassistant.components.yale

View File

@@ -1008,7 +1008,6 @@ INTEGRATIONS_WITHOUT_QUALITY_SCALE_FILE = [
"unifi",
"unifi_direct",
"unifiled",
"unifiprotect",
"universal",
"upb",
"upc_connect",
@@ -2032,7 +2031,6 @@ INTEGRATIONS_WITHOUT_SCALE = [
"unifi",
"unifi_direct",
"unifiled",
"unifiprotect",
"universal",
"upb",
"upc_connect",

View File

@@ -82,6 +82,30 @@
'entity_id': 'media_player.beosound_balance_11111111',
'state': 'playing',
}),
'remote_55555555': dict({
'address': '',
'app_version': '1.0.0',
'battery_level': 50,
'connected': True,
'db_version': None,
'last_seen': None,
'name': 'BEORC',
'serial_number': '55555555',
'updated': None,
}),
'remote_55555555_Control/Play_event': dict({
'attributes': dict({
'device_class': 'button',
'event_type': None,
'event_types': list([
'key_press',
'key_release',
]),
'friendly_name': 'Beoremote One-55555555-11111111 Control - Play',
}),
'entity_id': 'event.beoremote_one_55555555_11111111_control_play',
'state': 'unknown',
}),
'websocket_connected': False,
})
# ---

View File

@@ -7,7 +7,7 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_registry import EntityRegistry
from .conftest import mock_websocket_connection
from .const import TEST_BUTTON_EVENT_ENTITY_ID
from .const import TEST_BUTTON_EVENT_ENTITY_ID, TEST_REMOTE_KEY_EVENT_ENTITY_ID
from tests.common import AsyncMock, MockConfigEntry
from tests.components.diagnostics import get_diagnostics_for_config_entry
@@ -25,8 +25,11 @@ async def test_async_get_config_entry_diagnostics(
) -> None:
"""Test config entry diagnostics."""
# Enable an Event entity
# Enable a button and remote key Event entity
entity_registry.async_update_entity(TEST_BUTTON_EVENT_ENTITY_ID, disabled_by=None)
entity_registry.async_update_entity(
TEST_REMOTE_KEY_EVENT_ENTITY_ID, disabled_by=None
)
hass.config_entries.async_schedule_reload(mock_config_entry.entry_id)
# Re-trigger WebSocket events after the reload

View File

@@ -9,9 +9,6 @@ import pytest
import voluptuous as vol
from homeassistant.components.climate.const import (
ATTR_CURRENT_HUMIDITY,
ATTR_CURRENT_TEMPERATURE,
ATTR_HUMIDITY,
ATTR_HVAC_ACTION,
HVACAction,
HVACMode,
@@ -70,13 +67,7 @@ async def target_climates(hass: HomeAssistant) -> list[str]:
@pytest.mark.parametrize(
"trigger_key",
[
"climate.current_humidity_changed",
"climate.current_humidity_crossed_threshold",
"climate.current_temperature_changed",
"climate.current_temperature_crossed_threshold",
"climate.hvac_mode_changed",
"climate.target_humidity_changed",
"climate.target_humidity_crossed_threshold",
"climate.target_temperature_changed",
"climate.target_temperature_crossed_threshold",
"climate.turned_off",
@@ -376,27 +367,9 @@ async def test_climate_state_trigger_behavior_any(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_xxx_changed_trigger_states(
"climate.current_humidity_changed", ATTR_CURRENT_HUMIDITY
),
*parametrize_xxx_changed_trigger_states(
"climate.current_temperature_changed", ATTR_CURRENT_TEMPERATURE
),
*parametrize_xxx_changed_trigger_states(
"climate.target_humidity_changed", ATTR_HUMIDITY
),
*parametrize_xxx_changed_trigger_states(
"climate.target_temperature_changed", ATTR_TEMPERATURE
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.current_temperature_crossed_threshold", ATTR_CURRENT_TEMPERATURE
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", ATTR_HUMIDITY
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
),
@@ -537,15 +510,6 @@ async def test_climate_state_trigger_behavior_first(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.current_temperature_crossed_threshold", ATTR_CURRENT_TEMPERATURE
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", ATTR_HUMIDITY
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
),
@@ -686,15 +650,6 @@ async def test_climate_state_trigger_behavior_last(
@pytest.mark.parametrize(
("trigger", "trigger_options", "states"),
[
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.current_humidity_crossed_threshold", ATTR_CURRENT_HUMIDITY
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.current_temperature_crossed_threshold", ATTR_CURRENT_TEMPERATURE
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_humidity_crossed_threshold", ATTR_HUMIDITY
),
*parametrize_xxx_crossed_threshold_trigger_states(
"climate.target_temperature_crossed_threshold", ATTR_TEMPERATURE
),

View File

@@ -82,9 +82,20 @@ def mock_hikcamera() -> Generator[MagicMock]:
None,
"2024-01-01T00:00:00Z",
)
camera.get_event_triggers.return_value = {}
yield hikcamera_mock
@pytest.fixture
def mock_hik_nvr(mock_hikcamera: MagicMock) -> MagicMock:
"""Return a mocked HikCamera configured as an NVR."""
camera = mock_hikcamera.return_value
camera.get_type = "NVR"
camera.current_event_states = {}
camera.get_event_triggers.return_value = {"Motion": [1, 2]}
return mock_hikcamera
@pytest.fixture
async def init_integration(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_hikcamera: MagicMock

View File

@@ -89,3 +89,16 @@ async def test_setup_entry_no_device_id(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
async def test_setup_entry_nvr_fetches_events(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_hik_nvr: MagicMock,
) -> None:
"""Test setup fetches NVR events for NVR devices."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
mock_hik_nvr.return_value.get_event_triggers.assert_called_once()
mock_hik_nvr.return_value.inject_events.assert_called_once()

View File

@@ -0,0 +1,261 @@
"""Test lock triggers."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.components.lock import DOMAIN, LockState
from homeassistant.const import ATTR_LABEL_ID, CONF_ENTITY_ID
from homeassistant.core import HomeAssistant, ServiceCall
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(name="enable_experimental_triggers_conditions")
def enable_experimental_triggers_conditions() -> Generator[None]:
"""Enable experimental triggers and conditions."""
with patch(
"homeassistant.components.labs.async_is_preview_feature_enabled",
return_value=True,
):
yield
@pytest.fixture
async def target_locks(hass: HomeAssistant) -> list[str]:
"""Create multiple lock entities associated with different targets."""
return (await target_entities(hass, DOMAIN))["included"]
@pytest.mark.parametrize(
"trigger_key",
[
"lock.jammed",
"lock.locked",
"lock.opened",
"lock.unlocked",
],
)
async def test_lock_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the lock 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_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="lock.jammed",
target_states=[LockState.JAMMED],
other_states=other_states(LockState.JAMMED),
),
*parametrize_trigger_states(
trigger="lock.locked",
target_states=[LockState.LOCKED],
other_states=other_states(LockState.LOCKED),
),
*parametrize_trigger_states(
trigger="lock.opened",
target_states=[LockState.OPEN],
other_states=other_states(LockState.OPEN),
),
*parametrize_trigger_states(
trigger="lock.unlocked",
target_states=[LockState.UNLOCKED],
other_states=other_states(LockState.UNLOCKED),
),
],
)
async def test_lock_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_locks: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the lock state trigger fires when any lock state changes to a specific state."""
other_entity_ids = set(target_locks) - {entity_id}
# Set all locks, including the tested one, to the initial state
for eid in target_locks:
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 locks 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()
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="lock.jammed",
target_states=[LockState.JAMMED],
other_states=other_states(LockState.JAMMED),
),
*parametrize_trigger_states(
trigger="lock.locked",
target_states=[LockState.LOCKED],
other_states=other_states(LockState.LOCKED),
),
*parametrize_trigger_states(
trigger="lock.opened",
target_states=[LockState.OPEN],
other_states=other_states(LockState.OPEN),
),
*parametrize_trigger_states(
trigger="lock.unlocked",
target_states=[LockState.UNLOCKED],
other_states=other_states(LockState.UNLOCKED),
),
],
)
async def test_lock_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_locks: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the lock state trigger fires when the first lock changes to a specific state."""
other_entity_ids = set(target_locks) - {entity_id}
# Set all locks, including the tested one, to the initial state
for eid in target_locks:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, 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()
# Triggering other locks 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()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="lock.jammed",
target_states=[LockState.JAMMED],
other_states=other_states(LockState.JAMMED),
),
*parametrize_trigger_states(
trigger="lock.locked",
target_states=[LockState.LOCKED],
other_states=other_states(LockState.LOCKED),
),
*parametrize_trigger_states(
trigger="lock.opened",
target_states=[LockState.OPEN],
other_states=other_states(LockState.OPEN),
),
*parametrize_trigger_states(
trigger="lock.unlocked",
target_states=[LockState.UNLOCKED],
other_states=other_states(LockState.UNLOCKED),
),
],
)
async def test_lock_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_locks: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the lock state trigger fires when the last lock changes to a specific state."""
other_entity_ids = set(target_locks) - {entity_id}
# Set all locks, including the tested one, to the initial state
for eid in target_locks:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
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) == 0
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()

View File

@@ -0,0 +1,228 @@
"""Test siren triggers."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.components.siren 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 (
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(name="enable_experimental_triggers_conditions")
def enable_experimental_triggers_conditions() -> Generator[None]:
"""Enable experimental triggers and conditions."""
with patch(
"homeassistant.components.labs.async_is_preview_feature_enabled",
return_value=True,
):
yield
@pytest.fixture
async def target_sirens(hass: HomeAssistant) -> list[str]:
"""Create multiple siren entities associated with different targets."""
return (await target_entities(hass, DOMAIN))["included"]
@pytest.mark.parametrize(
"trigger_key",
[
"siren.turned_off",
"siren.turned_on",
],
)
async def test_siren_triggers_gated_by_labs_flag(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, trigger_key: str
) -> None:
"""Test the siren 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_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="siren.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="siren.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_siren_state_trigger_behavior_any(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_sirens: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the siren state trigger fires when any siren state changes to a specific state."""
other_entity_ids = set(target_sirens) - {entity_id}
# Set all sirens, including the tested one, to the initial state
for eid in target_sirens:
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 sirens 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()
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="siren.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="siren.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_siren_state_trigger_behavior_first(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_sirens: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the siren state trigger fires when the first siren changes to a specific state."""
other_entity_ids = set(target_sirens) - {entity_id}
# Set all sirens, including the tested one, to the initial state
for eid in target_sirens:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "first"}, 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()
# Triggering other sirens 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()
assert len(service_calls) == 0
@pytest.mark.usefixtures("enable_experimental_triggers_conditions")
@pytest.mark.parametrize(
("trigger_target_config", "entity_id", "entities_in_target"),
parametrize_target_entities(DOMAIN),
)
@pytest.mark.parametrize(
("trigger", "states"),
[
*parametrize_trigger_states(
trigger="siren.turned_off",
target_states=[STATE_OFF],
other_states=[STATE_ON],
),
*parametrize_trigger_states(
trigger="siren.turned_on",
target_states=[STATE_ON],
other_states=[STATE_OFF],
),
],
)
async def test_siren_state_trigger_behavior_last(
hass: HomeAssistant,
service_calls: list[ServiceCall],
target_sirens: list[str],
trigger_target_config: dict,
entity_id: str,
entities_in_target: int,
trigger: str,
states: list[StateDescription],
) -> None:
"""Test that the siren state trigger fires when the last siren changes to a specific state."""
other_entity_ids = set(target_sirens) - {entity_id}
# Set all sirens, including the tested one, to the initial state
for eid in target_sirens:
set_or_remove_state(hass, eid, states[0]["included"])
await hass.async_block_till_done()
await arm_trigger(hass, trigger, {"behavior": "last"}, trigger_target_config)
for state in states[1:]:
included_state = state["included"]
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) == 0
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()

View File

@@ -5,9 +5,10 @@ from unittest.mock import MagicMock
from PySrDaliGateway.exceptions import DaliGatewayError
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.sunricher_dali.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
import homeassistant.helpers.device_registry as dr
from homeassistant.helpers import device_registry as dr
from tests.common import MockConfigEntry
@@ -63,6 +64,23 @@ async def test_setup_entry_connection_error(
mock_gateway.connect.assert_called_once()
async def test_setup_entry_discovery_error(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_gateway: MagicMock,
) -> None:
"""Test setup fails when device discovery fails."""
mock_config_entry.add_to_hass(hass)
mock_gateway.discover_devices.side_effect = DaliGatewayError("Discovery failed")
assert not await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
mock_gateway.connect.assert_called_once()
mock_gateway.discover_devices.assert_called_once()
async def test_unload_entry(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
@@ -80,3 +98,40 @@ async def test_unload_entry(
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
async def test_remove_stale_devices(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_gateway: MagicMock,
mock_devices: list[MagicMock],
device_registry: dr.DeviceRegistry,
) -> None:
"""Test stale devices are removed when device list decreases."""
mock_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
devices_before = dr.async_entries_for_config_entry(
device_registry, mock_config_entry.entry_id
)
initial_count = len(devices_before)
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
mock_gateway.discover_devices.return_value = mock_devices[:2]
assert await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
devices_after = dr.async_entries_for_config_entry(
device_registry, mock_config_entry.entry_id
)
assert len(devices_after) < initial_count
gateway_device = device_registry.async_get_device(
identifiers={(DOMAIN, mock_gateway.gw_sn)}
)
assert gateway_device is not None
assert mock_config_entry.entry_id in gateway_device.config_entries

View File

@@ -53,6 +53,29 @@
'last_wind_speed': None,
})
# ---
# name: test_setup_config_entry
StateSnapshot({
'attributes': ReadOnlyDict({
'assumed_state': True,
'attribution': 'Powered by Home Assistant',
'friendly_name': 'My template',
'humidity': 50,
'precipitation_unit': <UnitOfPrecipitationDepth.MILLIMETERS: 'mm'>,
'pressure_unit': <UnitOfPressure.HPA: 'hPa'>,
'supported_features': 0,
'temperature': 20.0,
'temperature_unit': <UnitOfTemperature.CELSIUS: '°C'>,
'visibility_unit': <UnitOfLength.KILOMETERS: 'km'>,
'wind_speed_unit': <UnitOfSpeed.KILOMETERS_PER_HOUR: 'km/h'>,
}),
'context': <ANY>,
'entity_id': 'weather.my_template',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_trigger_weather_services[config0-1-template-get_forecasts]
dict({
'weather.test': dict({

View File

@@ -270,6 +270,16 @@ BINARY_SENSOR_OPTIONS = {
{"start": []},
{},
),
(
"weather",
{"condition": "{{ states('weather.one') }}"},
"sunny",
{"one": "sunny", "two": "cloudy"},
{},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
{},
),
],
)
@pytest.mark.freeze_time("2024-07-09 00:00:00+00:00")
@@ -463,6 +473,12 @@ async def test_config_flow(
{"start": []},
{"start": []},
),
(
"weather",
{"condition": "{{ states('weather.one') }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
),
],
)
async def test_config_flow_device(
@@ -752,6 +768,16 @@ async def test_config_flow_device(
{"start": []},
"state",
),
(
"weather",
{"condition": "{{ states('weather.one') }}"},
{"condition": "{{ states('weather.two') }}"},
["sunny", "cloudy"],
{"one": "sunny", "two": "cloudy"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
"condition",
),
],
)
@pytest.mark.freeze_time("2024-07-09 00:00:00+00:00")
@@ -1601,6 +1627,12 @@ async def test_option_flow_sensor_preview_config_entry_removed(
{"start": []},
{"start": []},
),
(
"weather",
{"condition": "{{ states('weather.one') }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
{"temperature": "{{ 20 }}", "humidity": "{{ 50 }}"},
),
],
)
async def test_options_flow_change_device(

View File

@@ -37,13 +37,15 @@ from homeassistant.helpers.restore_state import STORAGE_KEY as RESTORE_STATE_KEY
from homeassistant.setup import async_setup_component
from homeassistant.util import dt as dt_util
from .conftest import ConfigurationStyle
from .conftest import ConfigurationStyle, async_get_flow_preview_state
from tests.common import (
MockConfigEntry,
assert_setup_component,
async_mock_restore_state_shutdown_restart,
mock_restore_cache_with_extra_data,
)
from tests.typing import WebSocketGenerator
ATTR_FORECAST = "forecast"
@@ -122,6 +124,27 @@ async def setup_weather(
)
@pytest.fixture
async def setup_weather_single_attribute(
hass: HomeAssistant,
count: int,
style: ConfigurationStyle,
attribute: str,
attribute_template: str,
weather_config: dict[str, Any],
) -> None:
"""Do setup of weather integration."""
extra = {attribute: attribute_template}
if style == ConfigurationStyle.MODERN:
await async_setup_modern_format(
hass, count, {"name": TEST_OBJECT_ID, **weather_config, **extra}
)
if style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass, count, {"name": TEST_OBJECT_ID, **weather_config, **extra}
)
@pytest.mark.parametrize(("count", "domain"), [(1, WEATHER_DOMAIN)])
@pytest.mark.parametrize(
"config",
@@ -1129,3 +1152,58 @@ async def test_templated_optional_config(
state = hass.states.get(TEST_WEATHER)
assert state.attributes[attribute] == expected
async def test_setup_config_entry(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
) -> None:
"""Tests creating a weather from a config entry."""
hass.states.async_set(
"weather.test_state",
"sunny",
{},
)
template_config_entry = MockConfigEntry(
data={},
domain=template.DOMAIN,
options={
"name": "My template",
"condition": "{{ states('sensor.test_sensor') }}",
"humidity": "{{ 50 }}",
"temperature": "{{ 20 }}",
"template_type": WEATHER_DOMAIN,
},
title="My template",
)
template_config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(template_config_entry.entry_id)
await hass.async_block_till_done()
state = hass.states.get("weather.my_template")
assert state is not None
assert state == snapshot
async def test_flow_preview(
hass: HomeAssistant,
hass_ws_client: WebSocketGenerator,
) -> None:
"""Test the config flow preview."""
state = await async_get_flow_preview_state(
hass,
hass_ws_client,
WEATHER_DOMAIN,
{
"name": "My template",
"condition": "{{ 'sunny' }}",
"humidity": "{{ 50 }}",
"temperature": "{{ 20 }}",
},
)
assert state["state"] == "sunny"

View File

@@ -95,7 +95,7 @@ async def test_select_setup_light(
await init_entry(hass, ufp, [light])
assert_entity_counts(hass, Platform.SELECT, 2, 2)
expected_values = ("On Motion - When Dark", "Not Paired")
expected_values = ("motion_dark", "Not Paired")
for index, description in enumerate(LIGHT_SELECTS):
unique_id, entity_id = await ids_from_device_description(
@@ -153,11 +153,11 @@ async def test_select_setup_camera_all(
assert_entity_counts(hass, Platform.SELECT, 5, 5)
expected_values = (
"Always",
"Auto",
"always",
"auto",
"Default Message (Welcome)",
"None",
"Always Off",
"none",
"off",
)
for index, description in enumerate(CAMERA_SELECTS):
@@ -186,7 +186,7 @@ async def test_select_setup_camera_none(
await init_entry(hass, ufp, [camera])
assert_entity_counts(hass, Platform.SELECT, 2, 2)
expected_values = ("Always", "Auto", "Default Message (Welcome)")
expected_values = ("always", "auto", "Default Message (Welcome)")
for index, description in enumerate(CAMERA_SELECTS):
if index == 2:
@@ -403,7 +403,7 @@ async def test_select_set_option_camera_recording(
await hass.services.async_call(
"select",
"select_option",
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Never"},
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "never"},
blocking=True,
)
@@ -428,7 +428,7 @@ async def test_select_set_option_camera_ir(
await hass.services.async_call(
"select",
"select_option",
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "Always Enable"},
{ATTR_ENTITY_ID: entity_id, ATTR_OPTION: "on"},
blocking=True,
)

View File

@@ -2,16 +2,22 @@
These tests verify that setup retries (ConfigEntryNotReady) are triggered
when scene or node loading fails.
They also verify that unloading the integration properly disconnects.
"""
from __future__ import annotations
from unittest.mock import patch
import pytest
from pyvlx.exception import PyVLXException
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from tests.common import AsyncMock, ConfigEntry
from tests.common import AsyncMock, ConfigEntry, MockConfigEntry
async def test_setup_retry_on_nodes_failure(
@@ -53,3 +59,44 @@ async def test_setup_retry_on_oserror_during_scenes(
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
mock_pyvlx.load_scenes.assert_awaited_once()
mock_pyvlx.load_nodes.assert_not_called()
@pytest.fixture
def platform() -> Platform:
"""Fixture to specify platform to test."""
return Platform.COVER
@pytest.mark.usefixtures("setup_integration")
async def test_unload_calls_disconnect(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pyvlx
) -> None:
"""Test that unloading the config entry disconnects from the gateway."""
# Unload the entry
await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify disconnect was called
mock_pyvlx.disconnect.assert_awaited_once()
@pytest.mark.usefixtures("setup_integration")
async def test_unload_does_not_disconnect_if_platform_unload_fails(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_pyvlx
) -> None:
"""Test that disconnect is not called if platform unload fails."""
# Mock platform unload to fail
with patch(
"homeassistant.config_entries.ConfigEntries.async_unload_platforms",
return_value=False,
):
result = await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
# Verify unload failed
assert result is False
# Verify disconnect was NOT called since platform unload failed
mock_pyvlx.disconnect.assert_not_awaited()