mirror of
https://github.com/home-assistant/core.git
synced 2026-01-16 20:46:56 +01:00
Compare commits
1 Commits
adjust_ent
...
fix_smartt
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20bafe99ae |
8
.github/workflows/ci.yaml
vendored
8
.github/workflows/ci.yaml
vendored
@@ -247,11 +247,17 @@ jobs:
|
||||
&& github.event.inputs.audit-licenses-only != 'true'
|
||||
steps:
|
||||
- *checkout
|
||||
- name: Register problem matchers
|
||||
- name: Register yamllint problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/yamllint.json"
|
||||
- name: Register check-json problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/check-json.json"
|
||||
- name: Register check executables problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/check-executables-have-shebangs.json"
|
||||
- name: Register codespell problem matcher
|
||||
run: |
|
||||
echo "::add-matcher::.github/workflows/matchers/codespell.json"
|
||||
- name: Run prek
|
||||
uses: j178/prek-action@9d6a3097e0c1865ecce00cfb89fe80f2ee91b547 # v1.0.12
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
"owner": "check-executables-have-shebangs",
|
||||
"pattern": [
|
||||
{
|
||||
"regexp": "^(.+):\\s(marked executable but has no \\(or invalid\\) shebang!.*)$",
|
||||
"regexp": "^(.+):\\s(.+)$",
|
||||
"file": 1,
|
||||
"message": 2
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
"""Provides conditions for alarm control panels."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.condition import (
|
||||
Condition,
|
||||
EntityStateConditionBase,
|
||||
make_entity_state_condition,
|
||||
)
|
||||
from homeassistant.helpers.entity import get_supported_features
|
||||
|
||||
from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelState
|
||||
|
||||
|
||||
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
|
||||
"""Test if an entity supports the specified features."""
|
||||
try:
|
||||
return bool(get_supported_features(hass, entity_id) & features)
|
||||
except HomeAssistantError:
|
||||
return False
|
||||
|
||||
|
||||
class EntityStateRequiredFeaturesCondition(EntityStateConditionBase):
|
||||
"""State condition."""
|
||||
|
||||
_required_features: int
|
||||
|
||||
def entity_filter(self, entities: set[str]) -> set[str]:
|
||||
"""Filter entities of this domain with the required features."""
|
||||
entities = super().entity_filter(entities)
|
||||
return {
|
||||
entity_id
|
||||
for entity_id in entities
|
||||
if supports_feature(self._hass, entity_id, self._required_features)
|
||||
}
|
||||
|
||||
|
||||
def make_entity_state_required_features_condition(
|
||||
domain: str, to_state: str, required_features: int
|
||||
) -> type[EntityStateRequiredFeaturesCondition]:
|
||||
"""Create an entity state condition class with required feature filtering."""
|
||||
|
||||
class CustomCondition(EntityStateRequiredFeaturesCondition):
|
||||
"""Condition for entity state changes."""
|
||||
|
||||
_domain = domain
|
||||
_states = {to_state}
|
||||
_required_features = required_features
|
||||
|
||||
return CustomCondition
|
||||
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_armed": make_entity_state_condition(
|
||||
DOMAIN,
|
||||
{
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
},
|
||||
),
|
||||
"is_armed_away": make_entity_state_required_features_condition(
|
||||
DOMAIN,
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelEntityFeature.ARM_AWAY,
|
||||
),
|
||||
"is_armed_home": make_entity_state_required_features_condition(
|
||||
DOMAIN,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelEntityFeature.ARM_HOME,
|
||||
),
|
||||
"is_armed_night": make_entity_state_required_features_condition(
|
||||
DOMAIN,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelEntityFeature.ARM_NIGHT,
|
||||
),
|
||||
"is_armed_vacation": make_entity_state_required_features_condition(
|
||||
DOMAIN,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
AlarmControlPanelEntityFeature.ARM_VACATION,
|
||||
),
|
||||
"is_disarmed": make_entity_state_condition(DOMAIN, AlarmControlPanelState.DISARMED),
|
||||
"is_triggered": make_entity_state_condition(
|
||||
DOMAIN, AlarmControlPanelState.TRIGGERED
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the alarm control panel conditions."""
|
||||
return CONDITIONS
|
||||
@@ -1,52 +0,0 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
fields: &condition_common_fields
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_armed: *condition_common
|
||||
|
||||
is_armed_away:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
|
||||
is_armed_home:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_HOME
|
||||
|
||||
is_armed_night:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
|
||||
is_armed_vacation:
|
||||
fields: *condition_common_fields
|
||||
target:
|
||||
entity:
|
||||
domain: alarm_control_panel
|
||||
supported_features:
|
||||
- alarm_control_panel.AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
|
||||
is_disarmed: *condition_common
|
||||
|
||||
is_triggered: *condition_common
|
||||
@@ -1,27 +1,4 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_armed": {
|
||||
"condition": "mdi:shield"
|
||||
},
|
||||
"is_armed_away": {
|
||||
"condition": "mdi:shield-lock"
|
||||
},
|
||||
"is_armed_home": {
|
||||
"condition": "mdi:shield-home"
|
||||
},
|
||||
"is_armed_night": {
|
||||
"condition": "mdi:shield-moon"
|
||||
},
|
||||
"is_armed_vacation": {
|
||||
"condition": "mdi:shield-airplane"
|
||||
},
|
||||
"is_disarmed": {
|
||||
"condition": "mdi:shield-off"
|
||||
},
|
||||
"is_triggered": {
|
||||
"condition": "mdi:bell-ring"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:shield",
|
||||
|
||||
@@ -1,82 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted alarms.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted alarms to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_armed": {
|
||||
"description": "Tests if one or more alarms are armed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is armed"
|
||||
},
|
||||
"is_armed_away": {
|
||||
"description": "Tests if one or more alarms are armed in away mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is armed away"
|
||||
},
|
||||
"is_armed_home": {
|
||||
"description": "Tests if one or more alarms are armed in home mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is armed home"
|
||||
},
|
||||
"is_armed_night": {
|
||||
"description": "Tests if one or more alarms are armed in night mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is armed night"
|
||||
},
|
||||
"is_armed_vacation": {
|
||||
"description": "Tests if one or more alarms are armed in vacation mode.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is armed vacation"
|
||||
},
|
||||
"is_disarmed": {
|
||||
"description": "Tests if one or more alarms are disarmed.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is disarmed"
|
||||
},
|
||||
"is_triggered": {
|
||||
"description": "Tests if one or more alarms are triggered.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::alarm_control_panel::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::alarm_control_panel::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Alarm is triggered"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
"action_type": {
|
||||
"arm_away": "Arm {entity_name} away",
|
||||
@@ -150,12 +76,6 @@
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
@@ -14,7 +14,7 @@ from .const import DOMAIN, AlarmControlPanelEntityFeature, AlarmControlPanelStat
|
||||
|
||||
|
||||
def supports_feature(hass: HomeAssistant, entity_id: str, features: int) -> bool:
|
||||
"""Test if an entity supports the specified features."""
|
||||
"""Get the device class of an entity or UNDEFINED if not found."""
|
||||
try:
|
||||
return bool(get_supported_features(hass, entity_id) & features)
|
||||
except HomeAssistantError:
|
||||
@@ -39,7 +39,7 @@ class EntityStateTriggerRequiredFeatures(EntityTargetStateTriggerBase):
|
||||
def make_entity_state_trigger_required_features(
|
||||
domain: str, to_state: str, required_features: int
|
||||
) -> type[EntityTargetStateTriggerBase]:
|
||||
"""Create an entity state trigger class with required feature filtering."""
|
||||
"""Create an entity state trigger class."""
|
||||
|
||||
class CustomTrigger(EntityStateTriggerRequiredFeatures):
|
||||
"""Trigger for entity state changes."""
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
"""Provides conditions for assist satellites."""
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.condition import Condition, make_entity_state_condition
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import AssistSatelliteState
|
||||
|
||||
CONDITIONS: dict[str, type[Condition]] = {
|
||||
"is_idle": make_entity_state_condition(DOMAIN, AssistSatelliteState.IDLE),
|
||||
"is_listening": make_entity_state_condition(DOMAIN, AssistSatelliteState.LISTENING),
|
||||
"is_processing": make_entity_state_condition(
|
||||
DOMAIN, AssistSatelliteState.PROCESSING
|
||||
),
|
||||
"is_responding": make_entity_state_condition(
|
||||
DOMAIN, AssistSatelliteState.RESPONDING
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
async def async_get_conditions(hass: HomeAssistant) -> dict[str, type[Condition]]:
|
||||
"""Return the assist satellite conditions."""
|
||||
return CONDITIONS
|
||||
@@ -1,19 +0,0 @@
|
||||
.condition_common: &condition_common
|
||||
target:
|
||||
entity:
|
||||
domain: assist_satellite
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_idle: *condition_common
|
||||
is_listening: *condition_common
|
||||
is_processing: *condition_common
|
||||
is_responding: *condition_common
|
||||
@@ -1,18 +1,4 @@
|
||||
{
|
||||
"conditions": {
|
||||
"is_idle": {
|
||||
"condition": "mdi:chat-sleep"
|
||||
},
|
||||
"is_listening": {
|
||||
"condition": "mdi:chat-question"
|
||||
},
|
||||
"is_processing": {
|
||||
"condition": "mdi:chat-processing"
|
||||
},
|
||||
"is_responding": {
|
||||
"condition": "mdi:chat-alert"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:account-voice"
|
||||
|
||||
@@ -1,52 +1,8 @@
|
||||
{
|
||||
"common": {
|
||||
"condition_behavior_description": "How the state should match on the targeted Assist satellites.",
|
||||
"condition_behavior_name": "Behavior",
|
||||
"trigger_behavior_description": "The behavior of the targeted Assist satellites to trigger on.",
|
||||
"trigger_behavior_name": "Behavior"
|
||||
},
|
||||
"conditions": {
|
||||
"is_idle": {
|
||||
"description": "Tests if one or more Assist satellites are idle.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Satellite is idle"
|
||||
},
|
||||
"is_listening": {
|
||||
"description": "Tests if one or more Assist satellites are listening.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Satellite is listening"
|
||||
},
|
||||
"is_processing": {
|
||||
"description": "Tests if one or more Assist satellites are processing.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Satellite is processing"
|
||||
},
|
||||
"is_responding": {
|
||||
"description": "Tests if one or more Assist satellites are responding.",
|
||||
"fields": {
|
||||
"behavior": {
|
||||
"description": "[%key:component::assist_satellite::common::condition_behavior_description%]",
|
||||
"name": "[%key:component::assist_satellite::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Satellite is responding"
|
||||
}
|
||||
},
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"name": "Assist satellite",
|
||||
@@ -65,12 +21,6 @@
|
||||
"sentences": "Sentences"
|
||||
}
|
||||
},
|
||||
"condition_behavior": {
|
||||
"options": {
|
||||
"all": "All",
|
||||
"any": "Any"
|
||||
}
|
||||
},
|
||||
"trigger_behavior": {
|
||||
"options": {
|
||||
"any": "Any",
|
||||
|
||||
@@ -123,8 +123,6 @@ SERVICE_TRIGGER = "trigger"
|
||||
NEW_TRIGGERS_CONDITIONS_FEATURE_FLAG = "new_triggers_conditions"
|
||||
|
||||
_EXPERIMENTAL_CONDITION_PLATFORMS = {
|
||||
"alarm_control_panel",
|
||||
"assist_satellite",
|
||||
"fan",
|
||||
"light",
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
"""BleBox sensor entities."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
import blebox_uniapi.sensor
|
||||
|
||||
from homeassistant.components.sensor import (
|
||||
@@ -148,7 +146,7 @@ class BleBoxSensorEntity(BleBoxEntity[blebox_uniapi.sensor.BaseSensor], SensorEn
|
||||
return self._feature.native_value
|
||||
|
||||
@property
|
||||
def last_reset(self) -> datetime | None:
|
||||
def last_reset(self):
|
||||
"""Return the time when the sensor was last reset, if implemented."""
|
||||
native_implementation = getattr(self._feature, "last_reset", None)
|
||||
|
||||
|
||||
@@ -64,7 +64,6 @@ def _ws_with_blueprint_domain(
|
||||
return with_domain_blueprints
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "blueprint/list",
|
||||
@@ -98,7 +97,6 @@ async def ws_list_blueprints(
|
||||
connection.send_result(msg["id"], results)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "blueprint/import",
|
||||
@@ -152,7 +150,6 @@ async def ws_import_blueprint(
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "blueprint/save",
|
||||
@@ -209,7 +206,6 @@ async def ws_save_blueprint(
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "blueprint/delete",
|
||||
@@ -237,7 +233,6 @@ async def ws_delete_blueprint(
|
||||
)
|
||||
|
||||
|
||||
@websocket_api.require_admin
|
||||
@websocket_api.websocket_command(
|
||||
{
|
||||
vol.Required("type"): "blueprint/substitute",
|
||||
|
||||
@@ -49,11 +49,11 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the Concord232 alarm control panel platform."""
|
||||
name: str = config[CONF_NAME]
|
||||
code: str | None = config.get(CONF_CODE)
|
||||
mode: str = config[CONF_MODE]
|
||||
host: str = config[CONF_HOST]
|
||||
port: int = config[CONF_PORT]
|
||||
name = config[CONF_NAME]
|
||||
code = config.get(CONF_CODE)
|
||||
mode = config[CONF_MODE]
|
||||
host = config[CONF_HOST]
|
||||
port = config[CONF_PORT]
|
||||
|
||||
url = f"http://{host}:{port}"
|
||||
|
||||
@@ -72,7 +72,7 @@ class Concord232Alarm(AlarmControlPanelEntity):
|
||||
| AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
)
|
||||
|
||||
def __init__(self, url: str, name: str, code: str | None, mode: str) -> None:
|
||||
def __init__(self, url, name, code, mode):
|
||||
"""Initialize the Concord232 alarm panel."""
|
||||
|
||||
self._attr_name = name
|
||||
@@ -125,7 +125,7 @@ class Concord232Alarm(AlarmControlPanelEntity):
|
||||
return
|
||||
self._alarm.arm("away")
|
||||
|
||||
def _validate_code(self, code: str | None, state: AlarmControlPanelState) -> bool:
|
||||
def _validate_code(self, code, state):
|
||||
"""Validate given code."""
|
||||
if self._code is None:
|
||||
return True
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from concord232 import client as concord232_client
|
||||
import requests
|
||||
@@ -30,7 +29,8 @@ CONF_ZONE_TYPES = "zone_types"
|
||||
|
||||
DEFAULT_HOST = "localhost"
|
||||
DEFAULT_NAME = "Alarm"
|
||||
DEFAULT_PORT = 5007
|
||||
DEFAULT_PORT = "5007"
|
||||
DEFAULT_SSL = False
|
||||
|
||||
SCAN_INTERVAL = datetime.timedelta(seconds=10)
|
||||
|
||||
@@ -56,10 +56,10 @@ def setup_platform(
|
||||
) -> None:
|
||||
"""Set up the Concord232 binary sensor platform."""
|
||||
|
||||
host: str = config[CONF_HOST]
|
||||
port: int = config[CONF_PORT]
|
||||
exclude: list[int] = config[CONF_EXCLUDE_ZONES]
|
||||
zone_types: dict[int, BinarySensorDeviceClass] = config[CONF_ZONE_TYPES]
|
||||
host = config[CONF_HOST]
|
||||
port = config[CONF_PORT]
|
||||
exclude = config[CONF_EXCLUDE_ZONES]
|
||||
zone_types = config[CONF_ZONE_TYPES]
|
||||
sensors = []
|
||||
|
||||
try:
|
||||
@@ -84,6 +84,7 @@ def setup_platform(
|
||||
if zone["number"] not in exclude:
|
||||
sensors.append(
|
||||
Concord232ZoneSensor(
|
||||
hass,
|
||||
client,
|
||||
zone,
|
||||
zone_types.get(zone["number"], get_opening_type(zone)),
|
||||
@@ -109,25 +110,26 @@ def get_opening_type(zone):
|
||||
class Concord232ZoneSensor(BinarySensorEntity):
|
||||
"""Representation of a Concord232 zone as a sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
client: concord232_client.Client,
|
||||
zone: dict[str, Any],
|
||||
zone_type: BinarySensorDeviceClass,
|
||||
) -> None:
|
||||
def __init__(self, hass, client, zone, zone_type):
|
||||
"""Initialize the Concord232 binary sensor."""
|
||||
self._hass = hass
|
||||
self._client = client
|
||||
self._zone = zone
|
||||
self._number = zone["number"]
|
||||
self._attr_device_class = zone_type
|
||||
self._zone_type = zone_type
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
def device_class(self) -> BinarySensorDeviceClass:
|
||||
"""Return the class of this sensor, from DEVICE_CLASSES."""
|
||||
return self._zone_type
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the binary sensor."""
|
||||
return self._zone["name"]
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self):
|
||||
"""Return true if the binary sensor is on."""
|
||||
# True means "faulted" or "open" or "abnormal state"
|
||||
return bool(self._zone["state"] != "Normal")
|
||||
@@ -143,5 +145,5 @@ class Concord232ZoneSensor(BinarySensorEntity):
|
||||
|
||||
if hasattr(self._client, "zones"):
|
||||
self._zone = next(
|
||||
x for x in self._client.zones if x["number"] == self._number
|
||||
(x for x in self._client.zones if x["number"] == self._number), None
|
||||
)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Support for Digital Ocean."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
import digitalocean
|
||||
@@ -13,12 +12,27 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import Throttle
|
||||
|
||||
from .const import DATA_DIGITAL_OCEAN, DOMAIN, MIN_TIME_BETWEEN_UPDATES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
ATTR_CREATED_AT = "created_at"
|
||||
ATTR_DROPLET_ID = "droplet_id"
|
||||
ATTR_DROPLET_NAME = "droplet_name"
|
||||
ATTR_FEATURES = "features"
|
||||
ATTR_IPV4_ADDRESS = "ipv4_address"
|
||||
ATTR_IPV6_ADDRESS = "ipv6_address"
|
||||
ATTR_MEMORY = "memory"
|
||||
ATTR_REGION = "region"
|
||||
ATTR_VCPUS = "vcpus"
|
||||
|
||||
ATTRIBUTION = "Data provided by Digital Ocean"
|
||||
|
||||
CONF_DROPLETS = "droplets"
|
||||
|
||||
DATA_DIGITAL_OCEAN = "data_do"
|
||||
DIGITAL_OCEAN_PLATFORMS = [Platform.SWITCH, Platform.BINARY_SENSOR]
|
||||
DOMAIN = "digital_ocean"
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
|
||||
|
||||
CONFIG_SCHEMA = vol.Schema(
|
||||
{DOMAIN: vol.Schema({vol.Required(CONF_ACCESS_TOKEN): cv.string})},
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -17,7 +16,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import (
|
||||
from . import (
|
||||
ATTR_CREATED_AT,
|
||||
ATTR_DROPLET_ID,
|
||||
ATTR_DROPLET_NAME,
|
||||
@@ -66,7 +65,6 @@ class DigitalOceanBinarySensor(BinarySensorEntity):
|
||||
"""Representation of a Digital Ocean droplet sensor."""
|
||||
|
||||
_attr_attribution = ATTRIBUTION
|
||||
_attr_device_class = BinarySensorDeviceClass.MOVING
|
||||
|
||||
def __init__(self, do, droplet_id):
|
||||
"""Initialize a new Digital Ocean sensor."""
|
||||
@@ -81,12 +79,17 @@ class DigitalOceanBinarySensor(BinarySensorEntity):
|
||||
return self.data.name
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self):
|
||||
"""Return true if the binary sensor is on."""
|
||||
return self.data.status == "active"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
def device_class(self) -> BinarySensorDeviceClass:
|
||||
"""Return the class of this sensor."""
|
||||
return BinarySensorDeviceClass.MOVING
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes of the Digital Ocean droplet."""
|
||||
return {
|
||||
ATTR_CREATED_AT: self.data.created_at,
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
"""Support for Digital Ocean."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import DigitalOcean
|
||||
|
||||
ATTR_CREATED_AT = "created_at"
|
||||
ATTR_DROPLET_ID = "droplet_id"
|
||||
ATTR_DROPLET_NAME = "droplet_name"
|
||||
ATTR_FEATURES = "features"
|
||||
ATTR_IPV4_ADDRESS = "ipv4_address"
|
||||
ATTR_IPV6_ADDRESS = "ipv6_address"
|
||||
ATTR_MEMORY = "memory"
|
||||
ATTR_REGION = "region"
|
||||
ATTR_VCPUS = "vcpus"
|
||||
|
||||
ATTRIBUTION = "Data provided by Digital Ocean"
|
||||
|
||||
CONF_DROPLETS = "droplets"
|
||||
|
||||
DOMAIN = "digital_ocean"
|
||||
DATA_DIGITAL_OCEAN: HassKey[DigitalOcean] = HassKey(DOMAIN)
|
||||
|
||||
MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60)
|
||||
@@ -16,7 +16,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from .const import (
|
||||
from . import (
|
||||
ATTR_CREATED_AT,
|
||||
ATTR_DROPLET_ID,
|
||||
ATTR_DROPLET_NAME,
|
||||
@@ -80,12 +80,12 @@ class DigitalOceanSwitch(SwitchEntity):
|
||||
return self.data.name
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self):
|
||||
"""Return true if switch is on."""
|
||||
return self.data.status == "active"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes of the Digital Ocean droplet."""
|
||||
return {
|
||||
ATTR_CREATED_AT: self.data.created_at,
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""Support for Ebusd daemon for communication with eBUS heating systems."""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
import ebusdpy
|
||||
import voluptuous as vol
|
||||
@@ -18,7 +17,7 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.discovery import load_platform
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from .const import DOMAIN, EBUSD_DATA, SENSOR_TYPES
|
||||
from .const import DOMAIN, SENSOR_TYPES
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -29,9 +28,9 @@ CACHE_TTL = 900
|
||||
SERVICE_EBUSD_WRITE = "ebusd_write"
|
||||
|
||||
|
||||
def verify_ebusd_config(config: ConfigType) -> ConfigType:
|
||||
def verify_ebusd_config(config):
|
||||
"""Verify eBusd config."""
|
||||
circuit: str = config[CONF_CIRCUIT]
|
||||
circuit = config[CONF_CIRCUIT]
|
||||
for condition in config[CONF_MONITORED_CONDITIONS]:
|
||||
if condition not in SENSOR_TYPES[circuit]:
|
||||
raise vol.Invalid(f"Condition '{condition}' not in '{circuit}'.")
|
||||
@@ -60,17 +59,17 @@ CONFIG_SCHEMA = vol.Schema(
|
||||
def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the eBusd component."""
|
||||
_LOGGER.debug("Integration setup started")
|
||||
conf: ConfigType = config[DOMAIN]
|
||||
name: str = conf[CONF_NAME]
|
||||
circuit: str = conf[CONF_CIRCUIT]
|
||||
monitored_conditions: list[str] = conf[CONF_MONITORED_CONDITIONS]
|
||||
server_address: tuple[str, int] = (conf[CONF_HOST], conf[CONF_PORT])
|
||||
conf = config[DOMAIN]
|
||||
name = conf[CONF_NAME]
|
||||
circuit = conf[CONF_CIRCUIT]
|
||||
monitored_conditions = conf.get(CONF_MONITORED_CONDITIONS)
|
||||
server_address = (conf.get(CONF_HOST), conf.get(CONF_PORT))
|
||||
|
||||
try:
|
||||
ebusdpy.init(server_address)
|
||||
except (TimeoutError, OSError):
|
||||
return False
|
||||
hass.data[EBUSD_DATA] = EbusdData(server_address, circuit)
|
||||
hass.data[DOMAIN] = EbusdData(server_address, circuit)
|
||||
sensor_config = {
|
||||
CONF_MONITORED_CONDITIONS: monitored_conditions,
|
||||
"client_name": name,
|
||||
@@ -78,7 +77,7 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
}
|
||||
load_platform(hass, Platform.SENSOR, DOMAIN, sensor_config, config)
|
||||
|
||||
hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[EBUSD_DATA].write)
|
||||
hass.services.register(DOMAIN, SERVICE_EBUSD_WRITE, hass.data[DOMAIN].write)
|
||||
|
||||
_LOGGER.debug("Ebusd integration setup completed")
|
||||
return True
|
||||
@@ -87,13 +86,13 @@ def setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
class EbusdData:
|
||||
"""Get the latest data from Ebusd."""
|
||||
|
||||
def __init__(self, address: tuple[str, int], circuit: str) -> None:
|
||||
def __init__(self, address, circuit):
|
||||
"""Initialize the data object."""
|
||||
self._circuit = circuit
|
||||
self._address = address
|
||||
self.value: dict[str, Any] = {}
|
||||
self.value = {}
|
||||
|
||||
def update(self, name: str, stype: int) -> None:
|
||||
def update(self, name, stype):
|
||||
"""Call the Ebusd API to update the data."""
|
||||
try:
|
||||
_LOGGER.debug("Opening socket to ebusd %s", name)
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"""Constants for ebus component."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass
|
||||
from homeassistant.const import (
|
||||
PERCENTAGE,
|
||||
@@ -12,283 +8,277 @@ from homeassistant.const import (
|
||||
UnitOfTemperature,
|
||||
UnitOfTime,
|
||||
)
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from . import EbusdData
|
||||
|
||||
DOMAIN = "ebusd"
|
||||
EBUSD_DATA: HassKey[EbusdData] = HassKey(DOMAIN)
|
||||
|
||||
# SensorTypes from ebusdpy module :
|
||||
# 0='decimal', 1='time-schedule', 2='switch', 3='string', 4='value;status'
|
||||
|
||||
type SensorSpecs = tuple[str, str | None, str | None, int, SensorDeviceClass | None]
|
||||
SENSOR_TYPES: dict[str, dict[str, SensorSpecs]] = {
|
||||
SENSOR_TYPES = {
|
||||
"700": {
|
||||
"ActualFlowTemperatureDesired": (
|
||||
"ActualFlowTemperatureDesired": [
|
||||
"Hc1ActualFlowTempDesired",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"MaxFlowTemperatureDesired": (
|
||||
],
|
||||
"MaxFlowTemperatureDesired": [
|
||||
"Hc1MaxFlowTempDesired",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"MinFlowTemperatureDesired": (
|
||||
],
|
||||
"MinFlowTemperatureDesired": [
|
||||
"Hc1MinFlowTempDesired",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"PumpStatus": ("Hc1PumpStatus", None, "mdi:toggle-switch", 2, None),
|
||||
"HCSummerTemperatureLimit": (
|
||||
],
|
||||
"PumpStatus": ["Hc1PumpStatus", None, "mdi:toggle-switch", 2, None],
|
||||
"HCSummerTemperatureLimit": [
|
||||
"Hc1SummerTempLimit",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
"mdi:weather-sunny",
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"HolidayTemperature": (
|
||||
],
|
||||
"HolidayTemperature": [
|
||||
"HolidayTemp",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"HWTemperatureDesired": (
|
||||
],
|
||||
"HWTemperatureDesired": [
|
||||
"HwcTempDesired",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"HWActualTemperature": (
|
||||
],
|
||||
"HWActualTemperature": [
|
||||
"HwcStorageTemp",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"HWTimerMonday": ("hwcTimer.Monday", None, "mdi:timer-outline", 1, None),
|
||||
"HWTimerTuesday": ("hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None),
|
||||
"HWTimerWednesday": ("hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None),
|
||||
"HWTimerThursday": ("hwcTimer.Thursday", None, "mdi:timer-outline", 1, None),
|
||||
"HWTimerFriday": ("hwcTimer.Friday", None, "mdi:timer-outline", 1, None),
|
||||
"HWTimerSaturday": ("hwcTimer.Saturday", None, "mdi:timer-outline", 1, None),
|
||||
"HWTimerSunday": ("hwcTimer.Sunday", None, "mdi:timer-outline", 1, None),
|
||||
"HWOperativeMode": ("HwcOpMode", None, "mdi:math-compass", 3, None),
|
||||
"WaterPressure": (
|
||||
],
|
||||
"HWTimerMonday": ["hwcTimer.Monday", None, "mdi:timer-outline", 1, None],
|
||||
"HWTimerTuesday": ["hwcTimer.Tuesday", None, "mdi:timer-outline", 1, None],
|
||||
"HWTimerWednesday": ["hwcTimer.Wednesday", None, "mdi:timer-outline", 1, None],
|
||||
"HWTimerThursday": ["hwcTimer.Thursday", None, "mdi:timer-outline", 1, None],
|
||||
"HWTimerFriday": ["hwcTimer.Friday", None, "mdi:timer-outline", 1, None],
|
||||
"HWTimerSaturday": ["hwcTimer.Saturday", None, "mdi:timer-outline", 1, None],
|
||||
"HWTimerSunday": ["hwcTimer.Sunday", None, "mdi:timer-outline", 1, None],
|
||||
"HWOperativeMode": ["HwcOpMode", None, "mdi:math-compass", 3, None],
|
||||
"WaterPressure": [
|
||||
"WaterPressure",
|
||||
UnitOfPressure.BAR,
|
||||
"mdi:water-pump",
|
||||
0,
|
||||
SensorDeviceClass.PRESSURE,
|
||||
),
|
||||
"Zone1RoomZoneMapping": ("z1RoomZoneMapping", None, "mdi:label", 0, None),
|
||||
"Zone1NightTemperature": (
|
||||
],
|
||||
"Zone1RoomZoneMapping": ["z1RoomZoneMapping", None, "mdi:label", 0, None],
|
||||
"Zone1NightTemperature": [
|
||||
"z1NightTemp",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
"mdi:weather-night",
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"Zone1DayTemperature": (
|
||||
],
|
||||
"Zone1DayTemperature": [
|
||||
"z1DayTemp",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
"mdi:weather-sunny",
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"Zone1HolidayTemperature": (
|
||||
],
|
||||
"Zone1HolidayTemperature": [
|
||||
"z1HolidayTemp",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"Zone1RoomTemperature": (
|
||||
],
|
||||
"Zone1RoomTemperature": [
|
||||
"z1RoomTemp",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"Zone1ActualRoomTemperatureDesired": (
|
||||
],
|
||||
"Zone1ActualRoomTemperatureDesired": [
|
||||
"z1ActualRoomTempDesired",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"Zone1TimerMonday": ("z1Timer.Monday", None, "mdi:timer-outline", 1, None),
|
||||
"Zone1TimerTuesday": ("z1Timer.Tuesday", None, "mdi:timer-outline", 1, None),
|
||||
"Zone1TimerWednesday": (
|
||||
],
|
||||
"Zone1TimerMonday": ["z1Timer.Monday", None, "mdi:timer-outline", 1, None],
|
||||
"Zone1TimerTuesday": ["z1Timer.Tuesday", None, "mdi:timer-outline", 1, None],
|
||||
"Zone1TimerWednesday": [
|
||||
"z1Timer.Wednesday",
|
||||
None,
|
||||
"mdi:timer-outline",
|
||||
1,
|
||||
None,
|
||||
),
|
||||
"Zone1TimerThursday": ("z1Timer.Thursday", None, "mdi:timer-outline", 1, None),
|
||||
"Zone1TimerFriday": ("z1Timer.Friday", None, "mdi:timer-outline", 1, None),
|
||||
"Zone1TimerSaturday": ("z1Timer.Saturday", None, "mdi:timer-outline", 1, None),
|
||||
"Zone1TimerSunday": ("z1Timer.Sunday", None, "mdi:timer-outline", 1, None),
|
||||
"Zone1OperativeMode": ("z1OpMode", None, "mdi:math-compass", 3, None),
|
||||
"ContinuosHeating": (
|
||||
],
|
||||
"Zone1TimerThursday": ["z1Timer.Thursday", None, "mdi:timer-outline", 1, None],
|
||||
"Zone1TimerFriday": ["z1Timer.Friday", None, "mdi:timer-outline", 1, None],
|
||||
"Zone1TimerSaturday": ["z1Timer.Saturday", None, "mdi:timer-outline", 1, None],
|
||||
"Zone1TimerSunday": ["z1Timer.Sunday", None, "mdi:timer-outline", 1, None],
|
||||
"Zone1OperativeMode": ["z1OpMode", None, "mdi:math-compass", 3, None],
|
||||
"ContinuosHeating": [
|
||||
"ContinuosHeating",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
"mdi:weather-snowy",
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"PowerEnergyConsumptionLastMonth": (
|
||||
],
|
||||
"PowerEnergyConsumptionLastMonth": [
|
||||
"PrEnergySumHcLastMonth",
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
"mdi:flash",
|
||||
0,
|
||||
SensorDeviceClass.ENERGY,
|
||||
),
|
||||
"PowerEnergyConsumptionThisMonth": (
|
||||
],
|
||||
"PowerEnergyConsumptionThisMonth": [
|
||||
"PrEnergySumHcThisMonth",
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
"mdi:flash",
|
||||
0,
|
||||
SensorDeviceClass.ENERGY,
|
||||
),
|
||||
],
|
||||
},
|
||||
"ehp": {
|
||||
"HWTemperature": (
|
||||
"HWTemperature": [
|
||||
"HwcTemp",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
4,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"OutsideTemp": (
|
||||
],
|
||||
"OutsideTemp": [
|
||||
"OutsideTemp",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
4,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
],
|
||||
},
|
||||
"bai": {
|
||||
"HotWaterTemperature": (
|
||||
"HotWaterTemperature": [
|
||||
"HwcTemp",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
4,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"StorageTemperature": (
|
||||
],
|
||||
"StorageTemperature": [
|
||||
"StorageTemp",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
4,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"DesiredStorageTemperature": (
|
||||
],
|
||||
"DesiredStorageTemperature": [
|
||||
"StorageTempDesired",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"OutdoorsTemperature": (
|
||||
],
|
||||
"OutdoorsTemperature": [
|
||||
"OutdoorstempSensor",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
4,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"WaterPressure": (
|
||||
],
|
||||
"WaterPressure": [
|
||||
"WaterPressure",
|
||||
UnitOfPressure.BAR,
|
||||
"mdi:pipe",
|
||||
4,
|
||||
SensorDeviceClass.PRESSURE,
|
||||
),
|
||||
"AverageIgnitionTime": (
|
||||
],
|
||||
"AverageIgnitionTime": [
|
||||
"averageIgnitiontime",
|
||||
UnitOfTime.SECONDS,
|
||||
"mdi:av-timer",
|
||||
0,
|
||||
SensorDeviceClass.DURATION,
|
||||
),
|
||||
"MaximumIgnitionTime": (
|
||||
],
|
||||
"MaximumIgnitionTime": [
|
||||
"maxIgnitiontime",
|
||||
UnitOfTime.SECONDS,
|
||||
"mdi:av-timer",
|
||||
0,
|
||||
SensorDeviceClass.DURATION,
|
||||
),
|
||||
"MinimumIgnitionTime": (
|
||||
],
|
||||
"MinimumIgnitionTime": [
|
||||
"minIgnitiontime",
|
||||
UnitOfTime.SECONDS,
|
||||
"mdi:av-timer",
|
||||
0,
|
||||
SensorDeviceClass.DURATION,
|
||||
),
|
||||
"ReturnTemperature": (
|
||||
],
|
||||
"ReturnTemperature": [
|
||||
"ReturnTemp",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
4,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"CentralHeatingPump": ("WP", None, "mdi:toggle-switch", 2, None),
|
||||
"HeatingSwitch": ("HeatingSwitch", None, "mdi:toggle-switch", 2, None),
|
||||
"DesiredFlowTemperature": (
|
||||
],
|
||||
"CentralHeatingPump": ["WP", None, "mdi:toggle-switch", 2, None],
|
||||
"HeatingSwitch": ["HeatingSwitch", None, "mdi:toggle-switch", 2, None],
|
||||
"DesiredFlowTemperature": [
|
||||
"FlowTempDesired",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
0,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"FlowTemperature": (
|
||||
],
|
||||
"FlowTemperature": [
|
||||
"FlowTemp",
|
||||
UnitOfTemperature.CELSIUS,
|
||||
None,
|
||||
4,
|
||||
SensorDeviceClass.TEMPERATURE,
|
||||
),
|
||||
"Flame": ("Flame", None, "mdi:toggle-switch", 2, None),
|
||||
"PowerEnergyConsumptionHeatingCircuit": (
|
||||
],
|
||||
"Flame": ["Flame", None, "mdi:toggle-switch", 2, None],
|
||||
"PowerEnergyConsumptionHeatingCircuit": [
|
||||
"PrEnergySumHc1",
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
"mdi:flash",
|
||||
0,
|
||||
SensorDeviceClass.ENERGY,
|
||||
),
|
||||
"PowerEnergyConsumptionHotWaterCircuit": (
|
||||
],
|
||||
"PowerEnergyConsumptionHotWaterCircuit": [
|
||||
"PrEnergySumHwc1",
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
"mdi:flash",
|
||||
0,
|
||||
SensorDeviceClass.ENERGY,
|
||||
),
|
||||
"RoomThermostat": ("DCRoomthermostat", None, "mdi:toggle-switch", 2, None),
|
||||
"HeatingPartLoad": (
|
||||
],
|
||||
"RoomThermostat": ["DCRoomthermostat", None, "mdi:toggle-switch", 2, None],
|
||||
"HeatingPartLoad": [
|
||||
"PartloadHcKW",
|
||||
UnitOfEnergy.KILO_WATT_HOUR,
|
||||
"mdi:flash",
|
||||
0,
|
||||
SensorDeviceClass.ENERGY,
|
||||
),
|
||||
"StateNumber": ("StateNumber", None, "mdi:fire", 3, None),
|
||||
"ModulationPercentage": (
|
||||
],
|
||||
"StateNumber": ["StateNumber", None, "mdi:fire", 3, None],
|
||||
"ModulationPercentage": [
|
||||
"ModulationTempDesired",
|
||||
PERCENTAGE,
|
||||
"mdi:percent",
|
||||
0,
|
||||
None,
|
||||
),
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any, cast
|
||||
|
||||
from homeassistant.components.sensor import SensorDeviceClass, SensorEntity
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -12,8 +11,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import Throttle, dt as dt_util
|
||||
|
||||
from . import EbusdData
|
||||
from .const import EBUSD_DATA, SensorSpecs
|
||||
from .const import DOMAIN
|
||||
|
||||
TIME_FRAME1_BEGIN = "time_frame1_begin"
|
||||
TIME_FRAME1_END = "time_frame1_end"
|
||||
@@ -35,9 +33,9 @@ def setup_platform(
|
||||
"""Set up the Ebus sensor."""
|
||||
if not discovery_info:
|
||||
return
|
||||
ebusd_api = hass.data[EBUSD_DATA]
|
||||
monitored_conditions: list[str] = discovery_info["monitored_conditions"]
|
||||
name: str = discovery_info["client_name"]
|
||||
ebusd_api = hass.data[DOMAIN]
|
||||
monitored_conditions = discovery_info["monitored_conditions"]
|
||||
name = discovery_info["client_name"]
|
||||
|
||||
add_entities(
|
||||
(
|
||||
@@ -51,8 +49,9 @@ def setup_platform(
|
||||
class EbusdSensor(SensorEntity):
|
||||
"""Ebusd component sensor methods definition."""
|
||||
|
||||
def __init__(self, data: EbusdData, sensor: SensorSpecs, name: str) -> None:
|
||||
def __init__(self, data, sensor, name):
|
||||
"""Initialize the sensor."""
|
||||
self._state = None
|
||||
self._client_name = name
|
||||
(
|
||||
self._name,
|
||||
@@ -64,15 +63,20 @@ class EbusdSensor(SensorEntity):
|
||||
self.data = data
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return f"{self._client_name} {self._name}"
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any] | None:
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the device state attributes."""
|
||||
if self._type == 1 and (native_value := self.native_value) is not None:
|
||||
schedule: dict[str, str | None] = {
|
||||
if self._type == 1 and self._state is not None:
|
||||
schedule = {
|
||||
TIME_FRAME1_BEGIN: None,
|
||||
TIME_FRAME1_END: None,
|
||||
TIME_FRAME2_BEGIN: None,
|
||||
@@ -80,7 +84,7 @@ class EbusdSensor(SensorEntity):
|
||||
TIME_FRAME3_BEGIN: None,
|
||||
TIME_FRAME3_END: None,
|
||||
}
|
||||
time_frame = cast(str, native_value).split(";")
|
||||
time_frame = self._state.split(";")
|
||||
for index, item in enumerate(sorted(schedule.items())):
|
||||
if index < len(time_frame):
|
||||
parsed = datetime.datetime.strptime(time_frame[index], "%H:%M")
|
||||
@@ -97,12 +101,12 @@ class EbusdSensor(SensorEntity):
|
||||
return self._device_class
|
||||
|
||||
@property
|
||||
def icon(self) -> str | None:
|
||||
def icon(self):
|
||||
"""Icon to use in the frontend, if any."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
def native_unit_of_measurement(self):
|
||||
"""Return the unit of measurement."""
|
||||
return self._unit_of_measurement
|
||||
|
||||
@@ -114,6 +118,6 @@ class EbusdSensor(SensorEntity):
|
||||
if self._name not in self.data.value:
|
||||
return
|
||||
|
||||
self._attr_native_value = self.data.value[self._name]
|
||||
self._state = self.data.value[self._name]
|
||||
except RuntimeError:
|
||||
_LOGGER.debug("EbusdData.update exception")
|
||||
|
||||
@@ -18,7 +18,6 @@ from homeassistant.const import (
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import config_validation as cv, discovery
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -36,7 +35,7 @@ DEFAULT_REPORT_SERVER_PORT = 52010
|
||||
DEFAULT_VERSION = "GATE-01"
|
||||
DOMAIN = "egardia"
|
||||
|
||||
EGARDIA_DEVICE: HassKey[egardiadevice.EgardiaDevice] = HassKey(DOMAIN)
|
||||
EGARDIA_DEVICE = "egardiadevice"
|
||||
EGARDIA_NAME = "egardianame"
|
||||
EGARDIA_REPORT_SERVER_CODES = "egardia_rs_codes"
|
||||
EGARDIA_REPORT_SERVER_ENABLED = "egardia_rs_enabled"
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from pythonegardia.egardiadevice import EgardiaDevice
|
||||
import requests
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
@@ -12,7 +11,6 @@ from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntityFeature,
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
@@ -49,10 +47,10 @@ def setup_platform(
|
||||
if discovery_info is None:
|
||||
return
|
||||
device = EgardiaAlarm(
|
||||
discovery_info[CONF_NAME],
|
||||
discovery_info["name"],
|
||||
hass.data[EGARDIA_DEVICE],
|
||||
discovery_info[CONF_REPORT_SERVER_ENABLED],
|
||||
discovery_info[CONF_REPORT_SERVER_CODES],
|
||||
discovery_info.get(CONF_REPORT_SERVER_CODES),
|
||||
discovery_info[CONF_REPORT_SERVER_PORT],
|
||||
)
|
||||
|
||||
@@ -69,13 +67,8 @@ class EgardiaAlarm(AlarmControlPanelEntity):
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
egardiasystem: EgardiaDevice,
|
||||
rs_enabled: bool,
|
||||
rs_codes: dict[str, list[str]],
|
||||
rs_port: int,
|
||||
) -> None:
|
||||
self, name, egardiasystem, rs_enabled=False, rs_codes=None, rs_port=52010
|
||||
):
|
||||
"""Initialize the Egardia alarm."""
|
||||
self._attr_name = name
|
||||
self._egardiasystem = egardiasystem
|
||||
@@ -92,7 +85,9 @@ class EgardiaAlarm(AlarmControlPanelEntity):
|
||||
@property
|
||||
def should_poll(self) -> bool:
|
||||
"""Poll if no report server is enabled."""
|
||||
return not self._rs_enabled
|
||||
if not self._rs_enabled:
|
||||
return True
|
||||
return False
|
||||
|
||||
def handle_status_event(self, event):
|
||||
"""Handle the Egardia system status event."""
|
||||
|
||||
@@ -2,12 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pythonegardia.egardiadevice import EgardiaDevice
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
@@ -52,20 +51,30 @@ async def async_setup_platform(
|
||||
class EgardiaBinarySensor(BinarySensorEntity):
|
||||
"""Represents a sensor based on an Egardia sensor (IR, Door Contact)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
sensor_id: str,
|
||||
name: str,
|
||||
egardia_system: EgardiaDevice,
|
||||
device_class: BinarySensorDeviceClass | None,
|
||||
) -> None:
|
||||
def __init__(self, sensor_id, name, egardia_system, device_class):
|
||||
"""Initialize the sensor device."""
|
||||
self._id = sensor_id
|
||||
self._attr_name = name
|
||||
self._attr_device_class = device_class
|
||||
self._name = name
|
||||
self._state = None
|
||||
self._device_class = device_class
|
||||
self._egardia_system = egardia_system
|
||||
|
||||
def update(self) -> None:
|
||||
"""Update the status."""
|
||||
egardia_input = self._egardia_system.getsensorstate(self._id)
|
||||
self._attr_is_on = bool(egardia_input)
|
||||
self._state = STATE_ON if egardia_input else STATE_OFF
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def is_on(self):
|
||||
"""Whether the device is switched on."""
|
||||
return self._state == STATE_ON
|
||||
|
||||
@property
|
||||
def device_class(self) -> BinarySensorDeviceClass | None:
|
||||
"""Return the device class."""
|
||||
return self._device_class
|
||||
|
||||
@@ -18,13 +18,12 @@ from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.discovery import async_load_platform
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DOMAIN = "envisalink"
|
||||
|
||||
DATA_EVL: HassKey[EnvisalinkAlarmPanel] = HassKey(DOMAIN)
|
||||
DATA_EVL = "envisalink"
|
||||
|
||||
CONF_EVL_KEEPALIVE = "keepalive_interval"
|
||||
CONF_EVL_PORT = "port"
|
||||
|
||||
@@ -3,9 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyenvisalink import EnvisalinkAlarmPanel
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
@@ -24,7 +22,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from . import (
|
||||
CONF_PANIC,
|
||||
CONF_PARTITIONNAME,
|
||||
CONF_PARTITIONS,
|
||||
DATA_EVL,
|
||||
DOMAIN,
|
||||
PARTITION_SCHEMA,
|
||||
@@ -54,14 +51,15 @@ async def async_setup_platform(
|
||||
"""Perform the setup for Envisalink alarm panels."""
|
||||
if not discovery_info:
|
||||
return
|
||||
configured_partitions: dict[int, dict[str, Any]] = discovery_info[CONF_PARTITIONS]
|
||||
code: str | None = discovery_info[CONF_CODE]
|
||||
panic_type: str = discovery_info[CONF_PANIC]
|
||||
configured_partitions = discovery_info["partitions"]
|
||||
code = discovery_info[CONF_CODE]
|
||||
panic_type = discovery_info[CONF_PANIC]
|
||||
|
||||
entities = []
|
||||
for part_num, part_config in configured_partitions.items():
|
||||
entity_config_data = PARTITION_SCHEMA(part_config)
|
||||
for part_num in configured_partitions:
|
||||
entity_config_data = PARTITION_SCHEMA(configured_partitions[part_num])
|
||||
entity = EnvisalinkAlarm(
|
||||
hass,
|
||||
part_num,
|
||||
entity_config_data[CONF_PARTITIONNAME],
|
||||
code,
|
||||
@@ -105,14 +103,8 @@ class EnvisalinkAlarm(EnvisalinkEntity, AlarmControlPanelEntity):
|
||||
)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
partition_number: int,
|
||||
alarm_name: str,
|
||||
code: str | None,
|
||||
panic_type: str,
|
||||
info: dict[str, Any],
|
||||
controller: EnvisalinkAlarmPanel,
|
||||
) -> None:
|
||||
self, hass, partition_number, alarm_name, code, panic_type, info, controller
|
||||
):
|
||||
"""Initialize the alarm panel."""
|
||||
self._partition_number = partition_number
|
||||
self._panic_type = panic_type
|
||||
|
||||
@@ -4,9 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyenvisalink import EnvisalinkAlarmPanel
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
@@ -19,14 +16,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from . import (
|
||||
CONF_ZONENAME,
|
||||
CONF_ZONES,
|
||||
CONF_ZONETYPE,
|
||||
DATA_EVL,
|
||||
SIGNAL_ZONE_UPDATE,
|
||||
ZONE_SCHEMA,
|
||||
)
|
||||
from . import CONF_ZONENAME, CONF_ZONETYPE, DATA_EVL, SIGNAL_ZONE_UPDATE, ZONE_SCHEMA
|
||||
from .entity import EnvisalinkEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -41,12 +31,13 @@ async def async_setup_platform(
|
||||
"""Set up the Envisalink binary sensor entities."""
|
||||
if not discovery_info:
|
||||
return
|
||||
configured_zones: dict[int, dict[str, Any]] = discovery_info[CONF_ZONES]
|
||||
configured_zones = discovery_info["zones"]
|
||||
|
||||
entities = []
|
||||
for zone_num, zone_data in configured_zones.items():
|
||||
entity_config_data = ZONE_SCHEMA(zone_data)
|
||||
for zone_num in configured_zones:
|
||||
entity_config_data = ZONE_SCHEMA(configured_zones[zone_num])
|
||||
entity = EnvisalinkBinarySensor(
|
||||
hass,
|
||||
zone_num,
|
||||
entity_config_data[CONF_ZONENAME],
|
||||
entity_config_data[CONF_ZONETYPE],
|
||||
@@ -61,16 +52,9 @@ async def async_setup_platform(
|
||||
class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
|
||||
"""Representation of an Envisalink binary sensor."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
zone_number: int,
|
||||
zone_name: str,
|
||||
zone_type: BinarySensorDeviceClass,
|
||||
info: dict[str, Any],
|
||||
controller: EnvisalinkAlarmPanel,
|
||||
) -> None:
|
||||
def __init__(self, hass, zone_number, zone_name, zone_type, info, controller):
|
||||
"""Initialize the binary_sensor."""
|
||||
self._attr_device_class = zone_type
|
||||
self._zone_type = zone_type
|
||||
self._zone_number = zone_number
|
||||
|
||||
_LOGGER.debug("Setting up zone: %s", zone_name)
|
||||
@@ -85,9 +69,9 @@ class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
attr: dict[str, Any] = {}
|
||||
attr = {}
|
||||
|
||||
# The Envisalink library returns a "last_fault" value that's the
|
||||
# number of seconds since the last fault, up to a maximum of 327680
|
||||
@@ -120,6 +104,11 @@ class EnvisalinkBinarySensor(EnvisalinkEntity, BinarySensorEntity):
|
||||
"""Return true if sensor is on."""
|
||||
return self._info["status"]["open"]
|
||||
|
||||
@property
|
||||
def device_class(self) -> BinarySensorDeviceClass:
|
||||
"""Return the class of this sensor, from DEVICE_CLASSES."""
|
||||
return self._zone_type
|
||||
|
||||
@callback
|
||||
def async_update_callback(self, zone):
|
||||
"""Update the zone's state, if needed."""
|
||||
|
||||
@@ -1,9 +1,5 @@
|
||||
"""Support for Envisalink devices."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from pyenvisalink import EnvisalinkAlarmPanel
|
||||
|
||||
from homeassistant.helpers.entity import Entity
|
||||
|
||||
|
||||
@@ -12,10 +8,13 @@ class EnvisalinkEntity(Entity):
|
||||
|
||||
_attr_should_poll = False
|
||||
|
||||
def __init__(
|
||||
self, name: str, info: dict[str, Any], controller: EnvisalinkAlarmPanel
|
||||
) -> None:
|
||||
def __init__(self, name, info, controller):
|
||||
"""Initialize the device."""
|
||||
self._controller = controller
|
||||
self._info = info
|
||||
self._attr_name = name
|
||||
self._name = name
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._name
|
||||
|
||||
@@ -3,9 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyenvisalink import EnvisalinkAlarmPanel
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
@@ -15,7 +12,6 @@ from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import (
|
||||
CONF_PARTITIONNAME,
|
||||
CONF_PARTITIONS,
|
||||
DATA_EVL,
|
||||
PARTITION_SCHEMA,
|
||||
SIGNAL_KEYPAD_UPDATE,
|
||||
@@ -35,12 +31,13 @@ async def async_setup_platform(
|
||||
"""Perform the setup for Envisalink sensor entities."""
|
||||
if not discovery_info:
|
||||
return
|
||||
configured_partitions: dict[int, dict[str, Any]] = discovery_info[CONF_PARTITIONS]
|
||||
configured_partitions = discovery_info["partitions"]
|
||||
|
||||
entities = []
|
||||
for part_num, part_config in configured_partitions.items():
|
||||
entity_config_data = PARTITION_SCHEMA(part_config)
|
||||
for part_num in configured_partitions:
|
||||
entity_config_data = PARTITION_SCHEMA(configured_partitions[part_num])
|
||||
entity = EnvisalinkSensor(
|
||||
hass,
|
||||
entity_config_data[CONF_PARTITIONNAME],
|
||||
part_num,
|
||||
hass.data[DATA_EVL].alarm_state["partition"][part_num],
|
||||
@@ -55,16 +52,9 @@ async def async_setup_platform(
|
||||
class EnvisalinkSensor(EnvisalinkEntity, SensorEntity):
|
||||
"""Representation of an Envisalink keypad."""
|
||||
|
||||
_attr_icon = "mdi:alarm"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
partition_name: str,
|
||||
partition_number: int,
|
||||
info: dict[str, Any],
|
||||
controller: EnvisalinkAlarmPanel,
|
||||
) -> None:
|
||||
def __init__(self, hass, partition_name, partition_number, info, controller):
|
||||
"""Initialize the sensor."""
|
||||
self._icon = "mdi:alarm"
|
||||
self._partition_number = partition_number
|
||||
|
||||
_LOGGER.debug("Setting up sensor for partition: %s", partition_name)
|
||||
@@ -83,6 +73,11 @@ class EnvisalinkSensor(EnvisalinkEntity, SensorEntity):
|
||||
)
|
||||
)
|
||||
|
||||
@property
|
||||
def icon(self):
|
||||
"""Return the icon if any."""
|
||||
return self._icon
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the overall state."""
|
||||
|
||||
@@ -5,21 +5,13 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyenvisalink import EnvisalinkAlarmPanel
|
||||
|
||||
from homeassistant.components.switch import SwitchEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||
|
||||
from . import (
|
||||
CONF_ZONENAME,
|
||||
CONF_ZONES,
|
||||
DATA_EVL,
|
||||
SIGNAL_ZONE_BYPASS_UPDATE,
|
||||
ZONE_SCHEMA,
|
||||
)
|
||||
from . import CONF_ZONENAME, DATA_EVL, SIGNAL_ZONE_BYPASS_UPDATE, ZONE_SCHEMA
|
||||
from .entity import EnvisalinkEntity
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -34,15 +26,16 @@ async def async_setup_platform(
|
||||
"""Set up the Envisalink switch entities."""
|
||||
if not discovery_info:
|
||||
return
|
||||
configured_zones: dict[int, dict[str, Any]] = discovery_info[CONF_ZONES]
|
||||
configured_zones = discovery_info["zones"]
|
||||
|
||||
entities = []
|
||||
for zone_num, zone_data in configured_zones.items():
|
||||
entity_config_data = ZONE_SCHEMA(zone_data)
|
||||
for zone_num in configured_zones:
|
||||
entity_config_data = ZONE_SCHEMA(configured_zones[zone_num])
|
||||
zone_name = f"{entity_config_data[CONF_ZONENAME]}_bypass"
|
||||
_LOGGER.debug("Setting up zone_bypass switch: %s", zone_name)
|
||||
|
||||
entity = EnvisalinkSwitch(
|
||||
hass,
|
||||
zone_num,
|
||||
zone_name,
|
||||
hass.data[DATA_EVL].alarm_state["zone"][zone_num],
|
||||
@@ -56,13 +49,7 @@ async def async_setup_platform(
|
||||
class EnvisalinkSwitch(EnvisalinkEntity, SwitchEntity):
|
||||
"""Representation of an Envisalink switch."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
zone_number: int,
|
||||
zone_name: str,
|
||||
info: dict[str, Any],
|
||||
controller: EnvisalinkAlarmPanel,
|
||||
) -> None:
|
||||
def __init__(self, hass, zone_number, zone_name, info, controller):
|
||||
"""Initialize the switch."""
|
||||
self._zone_number = zone_number
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ from enum import StrEnum
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "essent"
|
||||
UPDATE_INTERVAL: Final = timedelta(hours=1)
|
||||
UPDATE_INTERVAL: Final = timedelta(hours=12)
|
||||
ATTRIBUTION: Final = "Data provided by Essent"
|
||||
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
"name": "[%key:component::fan::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Fan is off"
|
||||
"name": "If a fan is off"
|
||||
},
|
||||
"is_on": {
|
||||
"description": "Tests if one or more fans are on.",
|
||||
@@ -24,7 +24,7 @@
|
||||
"name": "[%key:component::fan::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Fan is on"
|
||||
"name": "If a fan is on"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
|
||||
@@ -59,7 +59,7 @@ class HMBinarySensor(HMDevice, BinarySensorEntity):
|
||||
"""Representation of a binary HomeMatic device."""
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self):
|
||||
"""Return true if switch is on."""
|
||||
if not self.available:
|
||||
return False
|
||||
@@ -73,7 +73,7 @@ class HMBinarySensor(HMDevice, BinarySensorEntity):
|
||||
return BinarySensorDeviceClass.MOTION
|
||||
return SENSOR_TYPES_CLASS.get(self._hmdevice.__class__.__name__)
|
||||
|
||||
def _init_data_struct(self) -> None:
|
||||
def _init_data_struct(self):
|
||||
"""Generate the data dictionary (self._data) from metadata."""
|
||||
# Add state to data struct
|
||||
if self._state:
|
||||
@@ -86,11 +86,11 @@ class HMBatterySensor(HMDevice, BinarySensorEntity):
|
||||
_attr_device_class = BinarySensorDeviceClass.BATTERY
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self):
|
||||
"""Return True if battery is low."""
|
||||
return bool(self._hm_get_state())
|
||||
|
||||
def _init_data_struct(self) -> None:
|
||||
def _init_data_struct(self):
|
||||
"""Generate the data dictionary (self._data) from metadata."""
|
||||
# Add state to data struct
|
||||
if self._state:
|
||||
|
||||
@@ -178,7 +178,7 @@ class HMThermostat(HMDevice, ClimateEntity):
|
||||
# Homematic
|
||||
return self._data.get("CONTROL_MODE")
|
||||
|
||||
def _init_data_struct(self) -> None:
|
||||
def _init_data_struct(self):
|
||||
"""Generate a data dict (self._data) from the Homematic metadata."""
|
||||
self._state = next(iter(self._hmdevice.WRITENODE.keys()))
|
||||
self._data[self._state] = None
|
||||
|
||||
@@ -78,7 +78,7 @@ class HMCover(HMDevice, CoverEntity):
|
||||
"""Stop the device if in motion."""
|
||||
self._hmdevice.stop(self._channel)
|
||||
|
||||
def _init_data_struct(self) -> None:
|
||||
def _init_data_struct(self):
|
||||
"""Generate a data dictionary (self._data) from metadata."""
|
||||
self._state = "LEVEL"
|
||||
self._data.update({self._state: None})
|
||||
@@ -138,7 +138,7 @@ class HMGarage(HMCover):
|
||||
"""Return whether the cover is closed."""
|
||||
return self._hmdevice.is_closed(self._hm_get_state())
|
||||
|
||||
def _init_data_struct(self) -> None:
|
||||
def _init_data_struct(self):
|
||||
"""Generate a data dictionary (self._data) from metadata."""
|
||||
self._state = "DOOR_STATE"
|
||||
self._data.update({self._state: None})
|
||||
|
||||
@@ -204,7 +204,7 @@ class HMDevice(Entity):
|
||||
self._init_data_struct()
|
||||
|
||||
@abstractmethod
|
||||
def _init_data_struct(self) -> None:
|
||||
def _init_data_struct(self):
|
||||
"""Generate a data dictionary from the HomeMatic device metadata."""
|
||||
|
||||
|
||||
|
||||
@@ -51,7 +51,7 @@ class HMLight(HMDevice, LightEntity):
|
||||
_attr_max_color_temp_kelvin = 6500 # 153 Mireds
|
||||
|
||||
@property
|
||||
def brightness(self) -> int | None:
|
||||
def brightness(self):
|
||||
"""Return the brightness of this light between 0..255."""
|
||||
# Is dimmer?
|
||||
if self._state == "LEVEL":
|
||||
@@ -59,7 +59,7 @@ class HMLight(HMDevice, LightEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self):
|
||||
"""Return true if light is on."""
|
||||
try:
|
||||
return self._hm_get_state() > 0
|
||||
@@ -98,7 +98,7 @@ class HMLight(HMDevice, LightEntity):
|
||||
return features
|
||||
|
||||
@property
|
||||
def hs_color(self) -> tuple[float, float] | None:
|
||||
def hs_color(self):
|
||||
"""Return the hue and saturation color value [float, float]."""
|
||||
if ColorMode.HS not in self.supported_color_modes:
|
||||
return None
|
||||
@@ -116,14 +116,14 @@ class HMLight(HMDevice, LightEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def effect_list(self) -> list[str] | None:
|
||||
def effect_list(self):
|
||||
"""Return the list of supported effects."""
|
||||
if not self.supported_features & LightEntityFeature.EFFECT:
|
||||
return None
|
||||
return self._hmdevice.get_effect_list()
|
||||
|
||||
@property
|
||||
def effect(self) -> str | None:
|
||||
def effect(self):
|
||||
"""Return the current color change program of the light."""
|
||||
if not self.supported_features & LightEntityFeature.EFFECT:
|
||||
return None
|
||||
@@ -166,7 +166,7 @@ class HMLight(HMDevice, LightEntity):
|
||||
|
||||
self._hmdevice.off(self._channel)
|
||||
|
||||
def _init_data_struct(self) -> None:
|
||||
def _init_data_struct(self):
|
||||
"""Generate a data dict (self._data) from the Homematic metadata."""
|
||||
# Use LEVEL
|
||||
self._state = "LEVEL"
|
||||
|
||||
@@ -48,7 +48,7 @@ class HMLock(HMDevice, LockEntity):
|
||||
"""Open the door latch."""
|
||||
self._hmdevice.open()
|
||||
|
||||
def _init_data_struct(self) -> None:
|
||||
def _init_data_struct(self):
|
||||
"""Generate the data dictionary (self._data) from metadata."""
|
||||
self._state = "STATE"
|
||||
self._data.update({self._state: None})
|
||||
|
||||
@@ -339,7 +339,7 @@ class HMSensor(HMDevice, SensorEntity):
|
||||
# No cast, return original value
|
||||
return self._hm_get_state()
|
||||
|
||||
def _init_data_struct(self) -> None:
|
||||
def _init_data_struct(self):
|
||||
"""Generate a data dictionary (self._data) from metadata."""
|
||||
if self._state:
|
||||
self._data.update({self._state: None})
|
||||
|
||||
@@ -35,7 +35,7 @@ class HMSwitch(HMDevice, SwitchEntity):
|
||||
"""Representation of a HomeMatic switch."""
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool:
|
||||
def is_on(self):
|
||||
"""Return True if switch is on."""
|
||||
try:
|
||||
return self._hm_get_state() > 0
|
||||
@@ -43,7 +43,7 @@ class HMSwitch(HMDevice, SwitchEntity):
|
||||
return False
|
||||
|
||||
@property
|
||||
def today_energy_kwh(self) -> float | None:
|
||||
def today_energy_kwh(self):
|
||||
"""Return the current power usage in kWh."""
|
||||
if "ENERGY_COUNTER" in self._data:
|
||||
try:
|
||||
@@ -61,7 +61,7 @@ class HMSwitch(HMDevice, SwitchEntity):
|
||||
"""Turn the switch off."""
|
||||
self._hmdevice.off(self._channel)
|
||||
|
||||
def _init_data_struct(self) -> None:
|
||||
def _init_data_struct(self):
|
||||
"""Generate the data dictionary (self._data) from metadata."""
|
||||
self._state = "STATE"
|
||||
self._data.update({self._state: None})
|
||||
|
||||
@@ -181,7 +181,7 @@ class GenericHueSensor(GenericHueDevice, entity.Entity): # pylint: disable=hass
|
||||
)
|
||||
|
||||
@property
|
||||
def state_class(self) -> SensorStateClass:
|
||||
def state_class(self):
|
||||
"""Return the state class of this entity, from STATE_CLASSES, if any."""
|
||||
return SensorStateClass.MEASUREMENT
|
||||
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"requirements": [
|
||||
"xknx==3.14.0",
|
||||
"xknxproject==3.8.2",
|
||||
"knx-frontend==2026.1.15.112308"
|
||||
"knx-frontend==2025.12.30.151231"
|
||||
],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -1,4 +1,18 @@
|
||||
.condition_common: &condition_common
|
||||
is_off:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
fields:
|
||||
behavior:
|
||||
required: true
|
||||
default: any
|
||||
selector:
|
||||
select:
|
||||
translation_key: condition_behavior
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
is_on:
|
||||
target:
|
||||
entity:
|
||||
domain: light
|
||||
@@ -12,6 +26,3 @@
|
||||
options:
|
||||
- all
|
||||
- any
|
||||
|
||||
is_off: *condition_common
|
||||
is_on: *condition_common
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
"name": "[%key:component::light::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Light is off"
|
||||
"name": "If a light is off"
|
||||
},
|
||||
"is_on": {
|
||||
"description": "Tests if one or more lights are on.",
|
||||
@@ -59,7 +59,7 @@
|
||||
"name": "[%key:component::light::common::condition_behavior_name%]"
|
||||
}
|
||||
},
|
||||
"name": "Light is on"
|
||||
"name": "If a light is on"
|
||||
}
|
||||
},
|
||||
"device_automation": {
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from mficlient.client import FailedToLogin, MFiClient, Port as MFiPort
|
||||
from mficlient.client import FailedToLogin, MFiClient
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -12,7 +12,6 @@ from homeassistant.components.sensor import (
|
||||
PLATFORM_SCHEMA as SENSOR_PLATFORM_SCHEMA,
|
||||
SensorDeviceClass,
|
||||
SensorEntity,
|
||||
StateType,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_HOST,
|
||||
@@ -65,29 +64,24 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up mFi sensors."""
|
||||
host: str = config[CONF_HOST]
|
||||
username: str = config[CONF_USERNAME]
|
||||
password: str = config[CONF_PASSWORD]
|
||||
use_tls: bool = config[CONF_SSL]
|
||||
verify_tls: bool = config[CONF_VERIFY_SSL]
|
||||
host = config.get(CONF_HOST)
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
use_tls = config.get(CONF_SSL)
|
||||
verify_tls = config.get(CONF_VERIFY_SSL)
|
||||
default_port = 6443 if use_tls else 6080
|
||||
network_port: int = config.get(CONF_PORT, default_port)
|
||||
port = int(config.get(CONF_PORT, default_port))
|
||||
|
||||
try:
|
||||
client = MFiClient(
|
||||
host,
|
||||
username,
|
||||
password,
|
||||
port=network_port,
|
||||
use_tls=use_tls,
|
||||
verify=verify_tls,
|
||||
host, username, password, port=port, use_tls=use_tls, verify=verify_tls
|
||||
)
|
||||
except (FailedToLogin, requests.exceptions.ConnectionError) as ex:
|
||||
_LOGGER.error("Unable to connect to mFi: %s", str(ex))
|
||||
return
|
||||
|
||||
add_entities(
|
||||
MfiSensor(port)
|
||||
MfiSensor(port, hass)
|
||||
for device in client.get_devices()
|
||||
for port in device.ports.values()
|
||||
if port.model in SENSOR_MODELS
|
||||
@@ -97,17 +91,18 @@ def setup_platform(
|
||||
class MfiSensor(SensorEntity):
|
||||
"""Representation of a mFi sensor."""
|
||||
|
||||
def __init__(self, port: MFiPort) -> None:
|
||||
def __init__(self, port, hass):
|
||||
"""Initialize the sensor."""
|
||||
self._port = port
|
||||
self._hass = hass
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._port.label
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType:
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
try:
|
||||
tag = self._port.tag
|
||||
@@ -134,7 +129,7 @@ class MfiSensor(SensorEntity):
|
||||
return None
|
||||
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
def native_unit_of_measurement(self):
|
||||
"""Return the unit of measurement of this entity, if any."""
|
||||
try:
|
||||
tag = self._port.tag
|
||||
|
||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from mficlient.client import FailedToLogin, MFiClient, Port as MFiPort
|
||||
from mficlient.client import FailedToLogin, MFiClient
|
||||
import requests
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -51,23 +51,18 @@ def setup_platform(
|
||||
add_entities: AddEntitiesCallback,
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up mFi switches."""
|
||||
host: str = config[CONF_HOST]
|
||||
username: str = config[CONF_USERNAME]
|
||||
password: str = config[CONF_PASSWORD]
|
||||
use_tls: bool = config[CONF_SSL]
|
||||
verify_tls: bool = config[CONF_VERIFY_SSL]
|
||||
"""Set up mFi sensors."""
|
||||
host = config.get(CONF_HOST)
|
||||
username = config.get(CONF_USERNAME)
|
||||
password = config.get(CONF_PASSWORD)
|
||||
use_tls = config[CONF_SSL]
|
||||
verify_tls = config.get(CONF_VERIFY_SSL)
|
||||
default_port = 6443 if use_tls else 6080
|
||||
network_port: int = config.get(CONF_PORT, default_port)
|
||||
port = int(config.get(CONF_PORT, default_port))
|
||||
|
||||
try:
|
||||
client = MFiClient(
|
||||
host,
|
||||
username,
|
||||
password,
|
||||
port=network_port,
|
||||
use_tls=use_tls,
|
||||
verify=verify_tls,
|
||||
host, username, password, port=port, use_tls=use_tls, verify=verify_tls
|
||||
)
|
||||
except (FailedToLogin, requests.exceptions.ConnectionError) as ex:
|
||||
_LOGGER.error("Unable to connect to mFi: %s", str(ex))
|
||||
@@ -84,23 +79,23 @@ def setup_platform(
|
||||
class MfiSwitch(SwitchEntity):
|
||||
"""Representation of an mFi switch-able device."""
|
||||
|
||||
def __init__(self, port: MFiPort) -> None:
|
||||
def __init__(self, port):
|
||||
"""Initialize the mFi device."""
|
||||
self._port = port
|
||||
self._target_state: bool | None = None
|
||||
self._target_state = None
|
||||
|
||||
@property
|
||||
def unique_id(self) -> str:
|
||||
def unique_id(self):
|
||||
"""Return the unique ID of the device."""
|
||||
return self._port.ident
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
def name(self):
|
||||
"""Return the name of the device."""
|
||||
return self._port.label
|
||||
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
def is_on(self):
|
||||
"""Return true if the device is on."""
|
||||
return self._port.output
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ class NinaConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
self._all_region_codes_sorted = swap_key_value(
|
||||
await nina.get_all_regional_codes()
|
||||
await nina.getAllRegionalCodes()
|
||||
)
|
||||
except ApiError:
|
||||
return self.async_abort(reason="no_fetch")
|
||||
@@ -221,7 +221,7 @@ class OptionsFlowHandler(OptionsFlowWithReload):
|
||||
|
||||
try:
|
||||
self._all_region_codes_sorted = swap_key_value(
|
||||
await nina.get_all_regional_codes()
|
||||
await nina.getAllRegionalCodes()
|
||||
)
|
||||
except ApiError:
|
||||
return self.async_abort(reason="no_fetch")
|
||||
|
||||
@@ -66,7 +66,7 @@ class NINADataUpdateCoordinator(
|
||||
|
||||
regions: dict[str, str] = config_entry.data[CONF_REGIONS]
|
||||
for region in regions:
|
||||
self._nina.add_region(region)
|
||||
self._nina.addRegion(region)
|
||||
|
||||
super().__init__(
|
||||
hass,
|
||||
@@ -151,7 +151,7 @@ class NINADataUpdateCoordinator(
|
||||
raw_warn.sent or "",
|
||||
raw_warn.start or "",
|
||||
raw_warn.expires or "",
|
||||
raw_warn.is_valid,
|
||||
raw_warn.isValid(),
|
||||
)
|
||||
warnings_for_regions.append(warning_data)
|
||||
|
||||
|
||||
@@ -8,6 +8,6 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pynina"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pynina==1.0.2"],
|
||||
"requirements": ["pynina==0.3.6"],
|
||||
"single_config_entry": true
|
||||
}
|
||||
|
||||
@@ -2,10 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
from operator import itemgetter
|
||||
from typing import Any
|
||||
|
||||
import oasatelematics
|
||||
import voluptuous as vol
|
||||
@@ -56,9 +55,9 @@ def setup_platform(
|
||||
discovery_info: DiscoveryInfoType | None = None,
|
||||
) -> None:
|
||||
"""Set up the OASA Telematics sensor."""
|
||||
name: str = config[CONF_NAME]
|
||||
stop_id: str = config[CONF_STOP_ID]
|
||||
route_id: str = config[CONF_ROUTE_ID]
|
||||
name = config[CONF_NAME]
|
||||
stop_id = config[CONF_STOP_ID]
|
||||
route_id = config.get(CONF_ROUTE_ID)
|
||||
|
||||
data = OASATelematicsData(stop_id, route_id)
|
||||
|
||||
@@ -69,31 +68,42 @@ class OASATelematicsSensor(SensorEntity):
|
||||
"""Implementation of the OASA Telematics sensor."""
|
||||
|
||||
_attr_attribution = "Data retrieved from telematics.oasa.gr"
|
||||
_attr_device_class = SensorDeviceClass.TIMESTAMP
|
||||
_attr_icon = "mdi:bus"
|
||||
|
||||
def __init__(
|
||||
self, data: OASATelematicsData, stop_id: str, route_id: str, name: str
|
||||
) -> None:
|
||||
def __init__(self, data, stop_id, route_id, name):
|
||||
"""Initialize the sensor."""
|
||||
self.data = data
|
||||
self._attr_name = name
|
||||
self._name = name
|
||||
self._stop_id = stop_id
|
||||
self._route_id = route_id
|
||||
self._name_data: dict[str, Any] | None = None
|
||||
self._times: list[dict[str, Any]] | None = None
|
||||
self._name_data = self._times = self._state = None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
def name(self):
|
||||
"""Return the name of the sensor."""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def device_class(self) -> SensorDeviceClass:
|
||||
"""Return the class of this sensor."""
|
||||
return SensorDeviceClass.TIMESTAMP
|
||||
|
||||
@property
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._state
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
params = {}
|
||||
if self._times is not None:
|
||||
next_arrival_data = self._times[0]
|
||||
if ATTR_NEXT_ARRIVAL in next_arrival_data:
|
||||
next_arrival: datetime = next_arrival_data[ATTR_NEXT_ARRIVAL]
|
||||
next_arrival = next_arrival_data[ATTR_NEXT_ARRIVAL]
|
||||
params.update({ATTR_NEXT_ARRIVAL: next_arrival.isoformat()})
|
||||
if len(self._times) > 1:
|
||||
second_next_arrival_time: datetime = self._times[1][ATTR_NEXT_ARRIVAL]
|
||||
second_next_arrival_time = self._times[1][ATTR_NEXT_ARRIVAL]
|
||||
if second_next_arrival_time is not None:
|
||||
second_arrival = second_next_arrival_time
|
||||
params.update(
|
||||
@@ -105,13 +115,12 @@ class OASATelematicsSensor(SensorEntity):
|
||||
ATTR_STOP_ID: self._stop_id,
|
||||
}
|
||||
)
|
||||
if self._name_data is not None:
|
||||
params.update(
|
||||
{
|
||||
ATTR_ROUTE_NAME: self._name_data[ATTR_ROUTE_NAME],
|
||||
ATTR_STOP_NAME: self._name_data[ATTR_STOP_NAME],
|
||||
}
|
||||
)
|
||||
params.update(
|
||||
{
|
||||
ATTR_ROUTE_NAME: self._name_data[ATTR_ROUTE_NAME],
|
||||
ATTR_STOP_NAME: self._name_data[ATTR_STOP_NAME],
|
||||
}
|
||||
)
|
||||
return {k: v for k, v in params.items() if v}
|
||||
|
||||
def update(self) -> None:
|
||||
@@ -121,7 +130,7 @@ class OASATelematicsSensor(SensorEntity):
|
||||
self._name_data = self.data.name_data
|
||||
next_arrival_data = self._times[0]
|
||||
if ATTR_NEXT_ARRIVAL in next_arrival_data:
|
||||
self._attr_native_value = next_arrival_data[ATTR_NEXT_ARRIVAL]
|
||||
self._state = next_arrival_data[ATTR_NEXT_ARRIVAL]
|
||||
|
||||
|
||||
class OASATelematicsData:
|
||||
|
||||
@@ -10,5 +10,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["onedrive_personal_sdk"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["onedrive-personal-sdk==0.1.0"]
|
||||
"requirements": ["onedrive-personal-sdk==0.0.17"]
|
||||
}
|
||||
|
||||
@@ -112,49 +112,45 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
if user_input is None:
|
||||
return self.async_show_form(
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA
|
||||
)
|
||||
|
||||
errors: dict[str, str] = {}
|
||||
|
||||
if user_input is not None:
|
||||
self._async_abort_entries_match(user_input)
|
||||
try:
|
||||
await validate_input(self.hass, user_input)
|
||||
except openai.APIConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except openai.AuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="ChatGPT",
|
||||
data=user_input,
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": "conversation",
|
||||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"subentry_type": "ai_task_data",
|
||||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
self._async_abort_entries_match(user_input)
|
||||
try:
|
||||
await validate_input(self.hass, user_input)
|
||||
except openai.APIConnectionError:
|
||||
errors["base"] = "cannot_connect"
|
||||
except openai.AuthenticationError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except Exception:
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title="ChatGPT",
|
||||
data=user_input,
|
||||
subentries=[
|
||||
{
|
||||
"subentry_type": "conversation",
|
||||
"data": RECOMMENDED_CONVERSATION_OPTIONS,
|
||||
"title": DEFAULT_CONVERSATION_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
{
|
||||
"subentry_type": "ai_task_data",
|
||||
"data": RECOMMENDED_AI_TASK_OPTIONS,
|
||||
"title": DEFAULT_AI_TASK_NAME,
|
||||
"unique_id": None,
|
||||
},
|
||||
],
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=self.add_suggested_values_to_schema(
|
||||
STEP_USER_DATA_SCHEMA, user_input
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"instructions_url": "https://www.home-assistant.io/integrations/openai_conversation/#generate-an-api-key",
|
||||
},
|
||||
step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
||||
@@ -12,11 +12,7 @@
|
||||
"user": {
|
||||
"data": {
|
||||
"api_key": "[%key:common::config_flow::data::api_key%]"
|
||||
},
|
||||
"data_description": {
|
||||
"api_key": "Your OpenAI API key."
|
||||
},
|
||||
"description": "Set up OpenAI Conversation integration by providing your OpenAI API key. Instructions to obtain an API key can be found [here]({instructions_url})."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["opower"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["opower==0.16.3"]
|
||||
"requirements": ["opower==0.16.2"]
|
||||
}
|
||||
|
||||
265
homeassistant/components/smartthings/audio.py
Normal file
265
homeassistant/components/smartthings/audio.py
Normal file
@@ -0,0 +1,265 @@
|
||||
"""Audio helper for SmartThings audio notifications."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import contextlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from aiohttp import hdrs, web
|
||||
|
||||
from homeassistant.components import ffmpeg
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.network import NoURLAvailableError, get_url
|
||||
|
||||
from .const import DOMAIN
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PCM_SAMPLE_RATE = 24000
|
||||
PCM_SAMPLE_WIDTH = 2
|
||||
PCM_CHANNELS = 1
|
||||
PCM_MIME = "audio/L16"
|
||||
PCM_EXTENSION = ".pcm"
|
||||
WARNING_DURATION_SECONDS = 40
|
||||
FFMPEG_MAX_DURATION_SECONDS = 10 * 60
|
||||
TRANSCODE_TIMEOUT_SECONDS = WARNING_DURATION_SECONDS + 10
|
||||
_TRUNCATION_EPSILON = 1 / PCM_SAMPLE_RATE
|
||||
ENTRY_TTL = timedelta(minutes=5)
|
||||
MAX_STORED_ENTRIES = 4 # Limit the number of cached notifications.
|
||||
|
||||
PCM_FRAME_BYTES = PCM_SAMPLE_WIDTH * PCM_CHANNELS
|
||||
|
||||
DATA_AUDIO_MANAGER = "audio_manager"
|
||||
|
||||
|
||||
class SmartThingsAudioError(HomeAssistantError):
|
||||
"""Error raised when SmartThings audio preparation fails."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class _AudioEntry:
|
||||
"""Stored PCM audio entry."""
|
||||
|
||||
pcm: bytes
|
||||
created: float
|
||||
expires: float
|
||||
|
||||
|
||||
class SmartThingsAudioManager(HomeAssistantView):
|
||||
"""Manage PCM proxy URLs for SmartThings audio notifications."""
|
||||
|
||||
url = "/api/smartthings/audio/{token}"
|
||||
name = "api:smartthings:audio"
|
||||
requires_auth = False
|
||||
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the manager."""
|
||||
self.hass = hass
|
||||
self._entries: dict[str, _AudioEntry] = {}
|
||||
self._cleanup_handle: asyncio.TimerHandle | None = None
|
||||
|
||||
async def async_prepare_notification(self, source_url: str) -> str:
|
||||
"""Generate an externally accessible PCM URL for SmartThings."""
|
||||
pcm, duration, truncated = await self._transcode_to_pcm(source_url)
|
||||
if not pcm:
|
||||
raise SmartThingsAudioError("Converted audio is empty")
|
||||
|
||||
if truncated:
|
||||
_LOGGER.warning(
|
||||
"SmartThings audio notification truncated to %s seconds (output length %.1fs); longer sources may be cut off",
|
||||
FFMPEG_MAX_DURATION_SECONDS,
|
||||
duration,
|
||||
)
|
||||
elif duration > WARNING_DURATION_SECONDS:
|
||||
_LOGGER.warning(
|
||||
"SmartThings audio notification is %.1fs; playback over %s seconds may be cut off",
|
||||
duration,
|
||||
WARNING_DURATION_SECONDS,
|
||||
)
|
||||
|
||||
token = secrets.token_urlsafe(
|
||||
16
|
||||
) # Shorter tokens avoid playback issues in some devices.
|
||||
now = self.hass.loop.time()
|
||||
entry = _AudioEntry(
|
||||
pcm=pcm,
|
||||
created=now,
|
||||
expires=now + ENTRY_TTL.total_seconds(),
|
||||
)
|
||||
|
||||
self._cleanup(now)
|
||||
while token in self._entries:
|
||||
token = secrets.token_urlsafe(16)
|
||||
self._entries[token] = entry
|
||||
while len(self._entries) > MAX_STORED_ENTRIES:
|
||||
dropped_token = next(iter(self._entries))
|
||||
self._entries.pop(dropped_token, None)
|
||||
_LOGGER.debug(
|
||||
"Dropped oldest SmartThings audio token %s to cap cache",
|
||||
dropped_token,
|
||||
)
|
||||
self._schedule_cleanup()
|
||||
|
||||
path = f"/api/smartthings/audio/{token}{PCM_EXTENSION}"
|
||||
try:
|
||||
base_url = get_url(
|
||||
self.hass,
|
||||
allow_internal=True,
|
||||
allow_external=True,
|
||||
allow_cloud=True,
|
||||
prefer_external=False, # Prevent NAT loopback failures; may break non-local access for devices outside the LAN.
|
||||
prefer_cloud=True,
|
||||
)
|
||||
except NoURLAvailableError as err:
|
||||
self._entries.pop(token, None)
|
||||
self._schedule_cleanup()
|
||||
raise SmartThingsAudioError(
|
||||
"SmartThings audio notifications require an accessible Home Assistant URL"
|
||||
) from err
|
||||
|
||||
return f"{base_url}{path}"
|
||||
|
||||
async def get(self, request: web.Request, token: str) -> web.StreamResponse:
|
||||
"""Serve a PCM audio response."""
|
||||
token = token.removesuffix(PCM_EXTENSION)
|
||||
|
||||
now = self.hass.loop.time()
|
||||
self._cleanup(now)
|
||||
self._schedule_cleanup()
|
||||
entry = self._entries.get(token)
|
||||
|
||||
if entry is None:
|
||||
raise web.HTTPNotFound
|
||||
|
||||
_LOGGER.debug("Serving SmartThings audio token=%s to %s", token, request.remote)
|
||||
|
||||
response = web.Response(body=entry.pcm, content_type=PCM_MIME)
|
||||
response.headers[hdrs.CACHE_CONTROL] = "no-store"
|
||||
response.headers[hdrs.ACCEPT_RANGES] = "none"
|
||||
response.headers[hdrs.CONTENT_DISPOSITION] = (
|
||||
f'inline; filename="{token}{PCM_EXTENSION}"'
|
||||
)
|
||||
return response
|
||||
|
||||
async def _transcode_to_pcm(self, source_url: str) -> tuple[bytes, float, bool]:
|
||||
"""Use ffmpeg to convert the source media to 24kHz mono PCM."""
|
||||
manager = ffmpeg.get_ffmpeg_manager(self.hass)
|
||||
command = [
|
||||
manager.binary,
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-nostdin",
|
||||
"-i",
|
||||
source_url,
|
||||
"-ac",
|
||||
str(PCM_CHANNELS),
|
||||
"-ar",
|
||||
str(PCM_SAMPLE_RATE),
|
||||
"-c:a",
|
||||
"pcm_s16le",
|
||||
"-t",
|
||||
str(FFMPEG_MAX_DURATION_SECONDS),
|
||||
"-f",
|
||||
"s16le",
|
||||
"pipe:1",
|
||||
]
|
||||
|
||||
try:
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*command,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
except FileNotFoundError as err:
|
||||
raise SmartThingsAudioError(
|
||||
"FFmpeg is required for SmartThings audio notifications"
|
||||
) from err
|
||||
|
||||
try:
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=TRANSCODE_TIMEOUT_SECONDS
|
||||
)
|
||||
except TimeoutError:
|
||||
_LOGGER.warning(
|
||||
"FFmpeg timed out after %s seconds while converting SmartThings audio from %s",
|
||||
TRANSCODE_TIMEOUT_SECONDS,
|
||||
source_url,
|
||||
)
|
||||
with contextlib.suppress(ProcessLookupError):
|
||||
process.kill()
|
||||
stdout, stderr = await process.communicate()
|
||||
|
||||
if process.returncode != 0:
|
||||
message = stderr.decode().strip() or "unknown error"
|
||||
_LOGGER.error(
|
||||
"FFmpeg failed to convert SmartThings audio from %s: %s",
|
||||
source_url,
|
||||
message,
|
||||
)
|
||||
raise SmartThingsAudioError(
|
||||
"Unable to convert audio to PCM for SmartThings"
|
||||
)
|
||||
|
||||
if not stdout:
|
||||
return b"", 0.0, False
|
||||
|
||||
frame_count, remainder = divmod(len(stdout), PCM_FRAME_BYTES)
|
||||
if remainder:
|
||||
_LOGGER.debug(
|
||||
"SmartThings audio conversion produced misaligned PCM: dropping %s extra byte(s)",
|
||||
remainder,
|
||||
)
|
||||
stdout = stdout[: len(stdout) - remainder]
|
||||
frame_count = len(stdout) // PCM_FRAME_BYTES
|
||||
|
||||
if frame_count == 0:
|
||||
return b"", 0.0, False
|
||||
|
||||
duration = frame_count / PCM_SAMPLE_RATE
|
||||
truncated = duration >= (FFMPEG_MAX_DURATION_SECONDS - _TRUNCATION_EPSILON)
|
||||
return stdout, duration, truncated
|
||||
|
||||
@callback
|
||||
def _schedule_cleanup(self) -> None:
|
||||
"""Schedule the next cleanup based on entry expiry."""
|
||||
if self._cleanup_handle is not None:
|
||||
self._cleanup_handle.cancel()
|
||||
self._cleanup_handle = None
|
||||
if not self._entries:
|
||||
return
|
||||
next_expiry = min(entry.expires for entry in self._entries.values())
|
||||
delay = max(0.0, next_expiry - self.hass.loop.time())
|
||||
self._cleanup_handle = self.hass.loop.call_later(delay, self._cleanup_callback)
|
||||
|
||||
@callback
|
||||
def _cleanup_callback(self) -> None:
|
||||
"""Run a cleanup pass."""
|
||||
self._cleanup_handle = None
|
||||
now = self.hass.loop.time()
|
||||
self._cleanup(now)
|
||||
self._schedule_cleanup()
|
||||
|
||||
def _cleanup(self, now: float) -> None:
|
||||
"""Remove expired entries."""
|
||||
expired = [
|
||||
token for token, entry in self._entries.items() if entry.expires <= now
|
||||
]
|
||||
for token in expired:
|
||||
self._entries.pop(token, None)
|
||||
|
||||
|
||||
async def async_get_audio_manager(hass: HomeAssistant) -> SmartThingsAudioManager:
|
||||
"""Return the shared SmartThings audio manager."""
|
||||
domain_data = hass.data.setdefault(DOMAIN, {})
|
||||
if (manager := domain_data.get(DATA_AUDIO_MANAGER)) is None:
|
||||
manager = SmartThingsAudioManager(hass)
|
||||
hass.http.register_view(manager)
|
||||
domain_data[DATA_AUDIO_MANAGER] = manager
|
||||
return manager
|
||||
@@ -3,7 +3,7 @@
|
||||
"name": "SmartThings",
|
||||
"codeowners": ["@joostlek"],
|
||||
"config_flow": true,
|
||||
"dependencies": ["application_credentials"],
|
||||
"dependencies": ["application_credentials", "http", "ffmpeg"],
|
||||
"dhcp": [
|
||||
{
|
||||
"hostname": "st*",
|
||||
|
||||
@@ -6,17 +6,22 @@ from typing import Any
|
||||
|
||||
from pysmartthings import Attribute, Capability, Category, Command, SmartThings
|
||||
|
||||
from homeassistant.components import media_source
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
MediaType,
|
||||
RepeatMode,
|
||||
async_process_play_media_url,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import FullDevice, SmartThingsConfigEntry
|
||||
from .audio import SmartThingsAudioError, async_get_audio_manager
|
||||
from .const import MAIN
|
||||
from .entity import SmartThingsEntity
|
||||
|
||||
@@ -84,6 +89,7 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity):
|
||||
Capability.AUDIO_MUTE,
|
||||
Capability.AUDIO_TRACK_DATA,
|
||||
Capability.AUDIO_VOLUME,
|
||||
Capability.AUDIO_NOTIFICATION,
|
||||
Capability.MEDIA_INPUT_SOURCE,
|
||||
Capability.MEDIA_PLAYBACK,
|
||||
Capability.MEDIA_PLAYBACK_REPEAT,
|
||||
@@ -128,6 +134,8 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity):
|
||||
flags |= MediaPlayerEntityFeature.SHUFFLE_SET
|
||||
if self.supports_capability(Capability.MEDIA_PLAYBACK_REPEAT):
|
||||
flags |= MediaPlayerEntityFeature.REPEAT_SET
|
||||
if self.supports_capability(Capability.AUDIO_NOTIFICATION):
|
||||
flags |= MediaPlayerEntityFeature.PLAY_MEDIA
|
||||
return flags
|
||||
|
||||
async def async_turn_off(self, **kwargs: Any) -> None:
|
||||
@@ -233,6 +241,40 @@ class SmartThingsMediaPlayer(SmartThingsEntity, MediaPlayerEntity):
|
||||
argument=HA_REPEAT_MODE_TO_SMARTTHINGS[repeat],
|
||||
)
|
||||
|
||||
async def async_play_media(
|
||||
self, media_type: MediaType | str, media_id: str, **kwargs: Any
|
||||
) -> None:
|
||||
"""Play media using SmartThings audio notifications."""
|
||||
if not self.supports_capability(Capability.AUDIO_NOTIFICATION):
|
||||
raise HomeAssistantError("Device does not support audio notifications")
|
||||
|
||||
if media_type not in (MediaType.MUSIC,):
|
||||
raise HomeAssistantError(
|
||||
"Unsupported media type for SmartThings audio notification"
|
||||
)
|
||||
|
||||
if media_source.is_media_source_id(media_id):
|
||||
play_item = await media_source.async_resolve_media(
|
||||
self.hass, media_id, self.entity_id
|
||||
)
|
||||
media_id = async_process_play_media_url(self.hass, play_item.url)
|
||||
else:
|
||||
media_id = async_process_play_media_url(self.hass, media_id)
|
||||
|
||||
audio_manager = await async_get_audio_manager(self.hass)
|
||||
try:
|
||||
proxy_url = await audio_manager.async_prepare_notification(media_id)
|
||||
except SmartThingsAudioError as err:
|
||||
raise HomeAssistantError(str(err)) from err
|
||||
|
||||
command = Command("playTrackAndResume")
|
||||
|
||||
await self.execute_device_command(
|
||||
Capability.AUDIO_NOTIFICATION,
|
||||
command,
|
||||
argument=[proxy_url],
|
||||
)
|
||||
|
||||
@property
|
||||
def media_title(self) -> str | None:
|
||||
"""Title of current playing media."""
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sunricher_dali",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "silver",
|
||||
"requirements": ["PySrDaliGateway==0.19.3"]
|
||||
"requirements": ["PySrDaliGateway==0.18.0"]
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
from functools import partial
|
||||
from typing import Final
|
||||
|
||||
from aiohttp import ClientError, ClientResponseError
|
||||
from aiohttp import ClientResponseError
|
||||
from aiohttp.client_exceptions import ClientError
|
||||
from tesla_fleet_api.const import Scope
|
||||
from tesla_fleet_api.exceptions import (
|
||||
Forbidden,
|
||||
@@ -27,7 +27,6 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.config_entry_oauth2_flow import (
|
||||
ImplementationUnavailableError,
|
||||
OAuth2Session,
|
||||
async_get_config_entry_implementation,
|
||||
)
|
||||
@@ -76,48 +75,28 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
async def _get_access_token(oauth_session: OAuth2Session) -> str:
|
||||
"""Get a valid access token, refreshing if necessary."""
|
||||
LOGGER.debug(
|
||||
"Token valid: %s, expires_at: %s",
|
||||
oauth_session.valid_token,
|
||||
oauth_session.token.get("expires_at"),
|
||||
)
|
||||
try:
|
||||
await oauth_session.async_ensure_token_valid()
|
||||
except ClientResponseError as err:
|
||||
if err.status == 401:
|
||||
raise ConfigEntryAuthFailed from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except (KeyError, TypeError) as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="token_data_malformed",
|
||||
) from err
|
||||
except ClientError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
return oauth_session.token[CONF_ACCESS_TOKEN]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -> bool:
|
||||
"""Set up Teslemetry config."""
|
||||
|
||||
session = async_get_clientsession(hass)
|
||||
|
||||
try:
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
except ImplementationUnavailableError as err:
|
||||
raise ConfigEntryAuthFailed(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="oauth_implementation_not_available",
|
||||
) from err
|
||||
implementation = await async_get_config_entry_implementation(hass, entry)
|
||||
oauth_session = OAuth2Session(hass, entry, implementation)
|
||||
|
||||
async def _get_access_token() -> str:
|
||||
try:
|
||||
await oauth_session.async_ensure_token_valid()
|
||||
except ClientResponseError as e:
|
||||
if e.status == 401:
|
||||
raise ConfigEntryAuthFailed from e
|
||||
raise ConfigEntryNotReady from e
|
||||
token: str = oauth_session.token[CONF_ACCESS_TOKEN]
|
||||
return token
|
||||
|
||||
# Create API connection
|
||||
access_token = partial(_get_access_token, oauth_session)
|
||||
teslemetry = Teslemetry(
|
||||
session=session,
|
||||
access_token=access_token,
|
||||
access_token=_get_access_token,
|
||||
)
|
||||
try:
|
||||
calls = await asyncio.gather(
|
||||
@@ -175,7 +154,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: TeslemetryConfigEntry) -
|
||||
if not stream:
|
||||
stream = TeslemetryStream(
|
||||
session,
|
||||
access_token,
|
||||
_get_access_token,
|
||||
server=f"{region.lower()}.teslemetry.com",
|
||||
parse_timestamp=True,
|
||||
manual=True,
|
||||
|
||||
@@ -997,6 +997,7 @@
|
||||
"total_grid_energy_exported": {
|
||||
"name": "Grid exported"
|
||||
},
|
||||
|
||||
"total_home_usage": {
|
||||
"name": "Home usage"
|
||||
},
|
||||
@@ -1126,9 +1127,6 @@
|
||||
"no_vehicle_data_for_device": {
|
||||
"message": "No vehicle data for device ID: {device_id}"
|
||||
},
|
||||
"oauth_implementation_not_available": {
|
||||
"message": "OAuth implementation not available, try reauthenticating"
|
||||
},
|
||||
"set_scheduled_charging_time": {
|
||||
"message": "Scheduled charging time is required when enabling"
|
||||
},
|
||||
@@ -1138,9 +1136,6 @@
|
||||
"set_scheduled_departure_preconditioning": {
|
||||
"message": "Preconditioning departure time is required when enabling"
|
||||
},
|
||||
"token_data_malformed": {
|
||||
"message": "Token data malformed, try reauthenticating"
|
||||
},
|
||||
"wake_up_failed": {
|
||||
"message": "Failed to wake up vehicle: {message}"
|
||||
},
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import collections
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Self
|
||||
|
||||
@@ -141,22 +140,6 @@ class _SwingModeWrapper(DeviceWrapper):
|
||||
return commands
|
||||
|
||||
|
||||
def _filter_hvac_mode_mappings(tuya_range: list[str]) -> dict[str, HVACMode | None]:
|
||||
"""Filter TUYA_HVAC_TO_HA modes that are not in the range.
|
||||
|
||||
If multiple Tuya modes map to the same HA mode, set the mapping to None to avoid
|
||||
ambiguity when converting back from HA to Tuya modes.
|
||||
"""
|
||||
modes_in_range = {
|
||||
tuya_mode: TUYA_HVAC_TO_HA.get(tuya_mode) for tuya_mode in tuya_range
|
||||
}
|
||||
modes_occurrences = collections.Counter(modes_in_range.values())
|
||||
for key, value in modes_in_range.items():
|
||||
if value is not None and modes_occurrences[value] > 1:
|
||||
modes_in_range[key] = None
|
||||
return modes_in_range
|
||||
|
||||
|
||||
class _HvacModeWrapper(DPCodeEnumWrapper):
|
||||
"""Wrapper for managing climate HVACMode."""
|
||||
|
||||
@@ -165,9 +148,10 @@ class _HvacModeWrapper(DPCodeEnumWrapper):
|
||||
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
|
||||
"""Init _HvacModeWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
self._mappings = _filter_hvac_mode_mappings(type_information.range)
|
||||
self.options = [
|
||||
ha_mode for ha_mode in self._mappings.values() if ha_mode is not None
|
||||
TUYA_HVAC_TO_HA[tuya_mode]
|
||||
for tuya_mode in type_information.range
|
||||
if tuya_mode in TUYA_HVAC_TO_HA
|
||||
]
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> HVACMode | None:
|
||||
@@ -182,7 +166,7 @@ class _HvacModeWrapper(DPCodeEnumWrapper):
|
||||
"""Convert value to raw value."""
|
||||
return next(
|
||||
tuya_mode
|
||||
for tuya_mode, ha_mode in self._mappings.items()
|
||||
for tuya_mode, ha_mode in TUYA_HVAC_TO_HA.items()
|
||||
if ha_mode == value
|
||||
)
|
||||
|
||||
@@ -195,9 +179,10 @@ class _PresetWrapper(DPCodeEnumWrapper):
|
||||
def __init__(self, dpcode: str, type_information: EnumTypeInformation) -> None:
|
||||
"""Init _PresetWrapper."""
|
||||
super().__init__(dpcode, type_information)
|
||||
mappings = _filter_hvac_mode_mappings(type_information.range)
|
||||
self.options = [
|
||||
tuya_mode for tuya_mode, ha_mode in mappings.items() if ha_mode is None
|
||||
tuya_mode
|
||||
for tuya_mode in type_information.range
|
||||
if tuya_mode not in TUYA_HVAC_TO_HA
|
||||
]
|
||||
|
||||
def read_device_status(self, device: CustomerDevice) -> str | None:
|
||||
|
||||
@@ -5,11 +5,9 @@ from __future__ import annotations
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
|
||||
from uiprotect.data import (
|
||||
Camera,
|
||||
Chime,
|
||||
Doorlock,
|
||||
Light,
|
||||
ModelType,
|
||||
@@ -32,8 +30,6 @@ from .entity import (
|
||||
)
|
||||
from .utils import async_ufp_instance_command
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
@@ -249,51 +245,6 @@ _MODEL_DESCRIPTIONS: dict[ModelType, Sequence[ProtectEntityDescription]] = {
|
||||
}
|
||||
|
||||
|
||||
def _async_chime_ring_volume_entities(
|
||||
data: ProtectData,
|
||||
chime: Chime,
|
||||
) -> list[ChimeRingVolumeNumber]:
|
||||
"""Generate ring volume entities for each paired camera on a chime."""
|
||||
entities: list[ChimeRingVolumeNumber] = []
|
||||
|
||||
if not chime.is_adopted_by_us:
|
||||
return entities
|
||||
|
||||
auth_user = data.api.bootstrap.auth_user
|
||||
if not chime.can_write(auth_user):
|
||||
return entities
|
||||
|
||||
for ring_setting in chime.ring_settings:
|
||||
camera = data.api.bootstrap.cameras.get(ring_setting.camera_id)
|
||||
if camera is None:
|
||||
_LOGGER.debug(
|
||||
"Camera %s not found for chime %s ring volume",
|
||||
ring_setting.camera_id,
|
||||
chime.display_name,
|
||||
)
|
||||
continue
|
||||
entities.append(ChimeRingVolumeNumber(data, chime, camera))
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
def _async_all_chime_ring_volume_entities(
|
||||
data: ProtectData,
|
||||
chime: Chime | None = None,
|
||||
) -> list[ChimeRingVolumeNumber]:
|
||||
"""Generate all ring volume entities for chimes."""
|
||||
entities: list[ChimeRingVolumeNumber] = []
|
||||
|
||||
if chime is not None:
|
||||
return _async_chime_ring_volume_entities(data, chime)
|
||||
|
||||
for device in data.get_by_types({ModelType.CHIME}):
|
||||
if isinstance(device, Chime):
|
||||
entities.extend(_async_chime_ring_volume_entities(data, device))
|
||||
|
||||
return entities
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: UFPConfigEntry,
|
||||
@@ -304,26 +255,23 @@ async def async_setup_entry(
|
||||
|
||||
@callback
|
||||
def _add_new_device(device: ProtectAdoptableDeviceModel) -> None:
|
||||
entities = async_all_device_entities(
|
||||
async_add_entities(
|
||||
async_all_device_entities(
|
||||
data,
|
||||
ProtectNumbers,
|
||||
model_descriptions=_MODEL_DESCRIPTIONS,
|
||||
ufp_device=device,
|
||||
)
|
||||
)
|
||||
|
||||
data.async_subscribe_adopt(_add_new_device)
|
||||
async_add_entities(
|
||||
async_all_device_entities(
|
||||
data,
|
||||
ProtectNumbers,
|
||||
model_descriptions=_MODEL_DESCRIPTIONS,
|
||||
ufp_device=device,
|
||||
)
|
||||
# Add ring volume entities for chimes
|
||||
if isinstance(device, Chime):
|
||||
entities += _async_all_chime_ring_volume_entities(data, device)
|
||||
async_add_entities(entities)
|
||||
|
||||
data.async_subscribe_adopt(_add_new_device)
|
||||
entities = async_all_device_entities(
|
||||
data,
|
||||
ProtectNumbers,
|
||||
model_descriptions=_MODEL_DESCRIPTIONS,
|
||||
)
|
||||
# Add ring volume entities for all chimes
|
||||
entities += _async_all_chime_ring_volume_entities(data)
|
||||
async_add_entities(entities)
|
||||
|
||||
|
||||
class ProtectNumbers(ProtectDeviceEntity, NumberEntity):
|
||||
@@ -354,62 +302,3 @@ class ProtectNumbers(ProtectDeviceEntity, NumberEntity):
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set new value."""
|
||||
await self.entity_description.ufp_set(self.device, value)
|
||||
|
||||
|
||||
class ChimeRingVolumeNumber(ProtectDeviceEntity, NumberEntity):
|
||||
"""A UniFi Protect Number Entity for ring volume per camera on a chime."""
|
||||
|
||||
device: Chime
|
||||
_state_attrs = ("_attr_available", "_attr_native_value")
|
||||
_attr_native_max_value: float = 100
|
||||
_attr_native_min_value: float = 0
|
||||
_attr_native_step: float = 1
|
||||
_attr_native_unit_of_measurement = PERCENTAGE
|
||||
_attr_entity_category = EntityCategory.CONFIG
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
data: ProtectData,
|
||||
chime: Chime,
|
||||
camera: Camera,
|
||||
) -> None:
|
||||
"""Initialize the ring volume number entity."""
|
||||
self._camera_id = camera.id
|
||||
# Use chime MAC and camera ID for unique ID
|
||||
super().__init__(data, chime)
|
||||
self._attr_unique_id = f"{chime.mac}_ring_volume_{camera.id}"
|
||||
self._attr_translation_key = "chime_ring_volume"
|
||||
self._attr_translation_placeholders = {"camera_name": camera.display_name}
|
||||
# BaseProtectEntity sets _attr_name = None when no description is passed,
|
||||
# which prevents translation_key from being used. Delete to enable translations.
|
||||
del self._attr_name
|
||||
|
||||
@callback
|
||||
def _async_update_device_from_protect(self, device: ProtectDeviceType) -> None:
|
||||
"""Update entity from protect device."""
|
||||
super()._async_update_device_from_protect(device)
|
||||
self._attr_native_value = self._get_ring_volume()
|
||||
|
||||
def _get_ring_volume(self) -> int | None:
|
||||
"""Get the ring volume for this camera from the chime's ring settings."""
|
||||
for ring_setting in self.device.ring_settings:
|
||||
if ring_setting.camera_id == self._camera_id:
|
||||
return ring_setting.volume
|
||||
return None
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
"""Return if entity is available."""
|
||||
# Entity is unavailable if the camera is no longer paired with the chime
|
||||
return super().available and self._get_ring_volume() is not None
|
||||
|
||||
@async_ufp_instance_command
|
||||
async def async_set_native_value(self, value: float) -> None:
|
||||
"""Set new ring volume value."""
|
||||
camera = self.data.api.bootstrap.cameras.get(self._camera_id)
|
||||
if camera is None:
|
||||
_LOGGER.warning(
|
||||
"Cannot set ring volume: camera %s not found", self._camera_id
|
||||
)
|
||||
return
|
||||
await self.device.set_volume_for_camera_public(camera, int(value))
|
||||
|
||||
@@ -323,9 +323,6 @@
|
||||
"chime_duration": {
|
||||
"name": "Chime duration"
|
||||
},
|
||||
"chime_ring_volume": {
|
||||
"name": "Ring volume ({camera_name})"
|
||||
},
|
||||
"doorbell_ring_volume": {
|
||||
"name": "Doorbell ring volume"
|
||||
},
|
||||
|
||||
@@ -9,8 +9,8 @@ import voluptuous as vol
|
||||
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
|
||||
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_NAME, CONF_UNIQUE_ID, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME, CONF_UNIQUE_ID, Platform
|
||||
from homeassistant.core import HomeAssistant, split_entity_id
|
||||
from homeassistant.helpers import (
|
||||
config_validation as cv,
|
||||
discovery,
|
||||
@@ -20,6 +20,7 @@ from homeassistant.helpers.device import (
|
||||
async_entity_id_to_device_id,
|
||||
async_remove_stale_devices_links_keep_entity_device,
|
||||
)
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
from homeassistant.helpers.helper_integration import (
|
||||
async_handle_source_entity_changes,
|
||||
async_remove_helper_config_entry_from_source_device,
|
||||
@@ -43,8 +44,9 @@ from .const import (
|
||||
DATA_UTILITY,
|
||||
DOMAIN,
|
||||
METER_TYPES,
|
||||
SERVICE_RESET,
|
||||
SIGNAL_RESET_METER,
|
||||
)
|
||||
from .services import async_setup_services
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -118,7 +120,27 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up an Utility Meter."""
|
||||
hass.data[DATA_UTILITY] = {}
|
||||
|
||||
async_setup_services(hass)
|
||||
async def async_reset_meters(service_call):
|
||||
"""Reset all sensors of a meter."""
|
||||
meters = service_call.data["entity_id"]
|
||||
|
||||
for meter in meters:
|
||||
_LOGGER.debug("resetting meter %s", meter)
|
||||
domain, entity = split_entity_id(meter)
|
||||
# backward compatibility up to 2022.07:
|
||||
if domain == DOMAIN:
|
||||
async_dispatcher_send(
|
||||
hass, SIGNAL_RESET_METER, f"{SELECT_DOMAIN}.{entity}"
|
||||
)
|
||||
else:
|
||||
async_dispatcher_send(hass, SIGNAL_RESET_METER, meter)
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RESET,
|
||||
async_reset_meters,
|
||||
vol.Schema({ATTR_ENTITY_ID: vol.All(cv.ensure_list, [cv.entity_id])}),
|
||||
)
|
||||
|
||||
if DOMAIN not in config:
|
||||
return True
|
||||
|
||||
@@ -700,7 +700,7 @@ class UtilityMeterSensor(RestoreSensor):
|
||||
return None
|
||||
|
||||
@property
|
||||
def state_class(self) -> SensorStateClass:
|
||||
def state_class(self):
|
||||
"""Return the device class of the sensor."""
|
||||
return (
|
||||
SensorStateClass.TOTAL
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
"""Support for tracking consumption over given periods of time."""
|
||||
|
||||
import logging
|
||||
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.components.select import DOMAIN as SELECT_DOMAIN
|
||||
from homeassistant.const import ATTR_ENTITY_ID
|
||||
from homeassistant.core import HomeAssistant, ServiceCall, callback, split_entity_id
|
||||
from homeassistant.helpers import config_validation as cv
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
from .const import DOMAIN, SERVICE_RESET, SIGNAL_RESET_METER
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def async_reset_meters(service_call: ServiceCall) -> None:
|
||||
"""Reset all sensors of a meter."""
|
||||
meters = service_call.data["entity_id"]
|
||||
|
||||
for meter in meters:
|
||||
_LOGGER.debug("resetting meter %s", meter)
|
||||
domain, entity = split_entity_id(meter)
|
||||
# backward compatibility up to 2022.07:
|
||||
if domain == DOMAIN:
|
||||
async_dispatcher_send(
|
||||
service_call.hass, SIGNAL_RESET_METER, f"{SELECT_DOMAIN}.{entity}"
|
||||
)
|
||||
else:
|
||||
async_dispatcher_send(service_call.hass, SIGNAL_RESET_METER, meter)
|
||||
|
||||
|
||||
@callback
|
||||
def async_setup_services(hass: HomeAssistant) -> None:
|
||||
"""Set up the services."""
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN,
|
||||
SERVICE_RESET,
|
||||
async_reset_meters,
|
||||
vol.Schema({ATTR_ENTITY_ID: vol.All(cv.ensure_list, [cv.entity_id])}),
|
||||
)
|
||||
@@ -700,7 +700,6 @@ _ENTITY_MATCH: list[TypeHintMatch] = [
|
||||
TypeHintMatch(
|
||||
function_name="device_class",
|
||||
return_type=["str", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="unit_of_measurement",
|
||||
@@ -2519,12 +2518,10 @@ _INHERITANCE_MATCH: dict[str, list[ClassTypeHintMatch]] = {
|
||||
TypeHintMatch(
|
||||
function_name="state_class",
|
||||
return_type=["SensorStateClass", "str", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="last_reset",
|
||||
return_type=["datetime", None],
|
||||
mandatory=True,
|
||||
),
|
||||
TypeHintMatch(
|
||||
function_name="native_value",
|
||||
|
||||
10
requirements_all.txt
generated
10
requirements_all.txt
generated
@@ -80,7 +80,7 @@ PyQRCode==1.2.1
|
||||
PyRMVtransport==0.3.3
|
||||
|
||||
# homeassistant.components.sunricher_dali
|
||||
PySrDaliGateway==0.19.3
|
||||
PySrDaliGateway==0.18.0
|
||||
|
||||
# homeassistant.components.switchbot
|
||||
PySwitchbot==0.76.0
|
||||
@@ -1351,7 +1351,7 @@ kiwiki-client==0.1.1
|
||||
knocki==0.4.2
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2026.1.15.112308
|
||||
knx-frontend==2025.12.30.151231
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
@@ -1646,7 +1646,7 @@ omnilogic==0.4.5
|
||||
ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
onedrive-personal-sdk==0.1.0
|
||||
onedrive-personal-sdk==0.0.17
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==4.0.4
|
||||
@@ -1683,7 +1683,7 @@ openwrt-luci-rpc==1.1.17
|
||||
openwrt-ubus-rpc==0.0.2
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.16.3
|
||||
opower==0.16.2
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.0.2
|
||||
@@ -2234,7 +2234,7 @@ pynetgear==0.10.10
|
||||
pynetio==0.1.9.1
|
||||
|
||||
# homeassistant.components.nina
|
||||
pynina==1.0.2
|
||||
pynina==0.3.6
|
||||
|
||||
# homeassistant.components.nintendo_parental_controls
|
||||
pynintendoauth==1.0.2
|
||||
|
||||
10
requirements_test_all.txt
generated
10
requirements_test_all.txt
generated
@@ -80,7 +80,7 @@ PyQRCode==1.2.1
|
||||
PyRMVtransport==0.3.3
|
||||
|
||||
# homeassistant.components.sunricher_dali
|
||||
PySrDaliGateway==0.19.3
|
||||
PySrDaliGateway==0.18.0
|
||||
|
||||
# homeassistant.components.switchbot
|
||||
PySwitchbot==0.76.0
|
||||
@@ -1185,7 +1185,7 @@ kegtron-ble==1.0.2
|
||||
knocki==0.4.2
|
||||
|
||||
# homeassistant.components.knx
|
||||
knx-frontend==2026.1.15.112308
|
||||
knx-frontend==2025.12.30.151231
|
||||
|
||||
# homeassistant.components.konnected
|
||||
konnected==1.2.0
|
||||
@@ -1429,7 +1429,7 @@ omnilogic==0.4.5
|
||||
ondilo==0.5.0
|
||||
|
||||
# homeassistant.components.onedrive
|
||||
onedrive-personal-sdk==0.1.0
|
||||
onedrive-personal-sdk==0.0.17
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==4.0.4
|
||||
@@ -1457,7 +1457,7 @@ openrgb-python==0.3.6
|
||||
openwebifpy==4.3.1
|
||||
|
||||
# homeassistant.components.opower
|
||||
opower==0.16.3
|
||||
opower==0.16.2
|
||||
|
||||
# homeassistant.components.oralb
|
||||
oralb-ble==1.0.2
|
||||
@@ -1887,7 +1887,7 @@ pynecil==4.2.1
|
||||
pynetgear==0.10.10
|
||||
|
||||
# homeassistant.components.nina
|
||||
pynina==1.0.2
|
||||
pynina==0.3.6
|
||||
|
||||
# homeassistant.components.nintendo_parental_controls
|
||||
pynintendoauth==1.0.2
|
||||
|
||||
@@ -254,21 +254,13 @@ def parametrize_condition_states(
|
||||
state_with_attributes(other_state, False, True)
|
||||
for other_state in other_states
|
||||
),
|
||||
),
|
||||
(
|
||||
state_with_attributes(target_state, True, True)
|
||||
for target_state in target_states
|
||||
),
|
||||
)
|
||||
),
|
||||
),
|
||||
# Test each target state individually to isolate condition_true expectations
|
||||
*(
|
||||
(
|
||||
condition,
|
||||
condition_options,
|
||||
[
|
||||
state_with_attributes(other_states[0], False, True),
|
||||
state_with_attributes(target_state, True, True),
|
||||
],
|
||||
)
|
||||
for target_state in target_states
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,275 +0,0 @@
|
||||
"""Test alarm_control_panel conditions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.alarm_control_panel import (
|
||||
AlarmControlPanelEntityFeature,
|
||||
AlarmControlPanelState,
|
||||
)
|
||||
from homeassistant.const import ATTR_SUPPORTED_FEATURES
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
other_states,
|
||||
parametrize_condition_states,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
|
||||
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
|
||||
"""Stub copying the blueprints to the config folder."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_alarm_control_panels(hass: HomeAssistant) -> list[str]:
|
||||
"""Create multiple alarm_control_panel entities associated with different targets."""
|
||||
return (await target_entities(hass, "alarm_control_panel"))["included"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"condition",
|
||||
[
|
||||
"alarm_control_panel.is_armed",
|
||||
"alarm_control_panel.is_armed_away",
|
||||
"alarm_control_panel.is_armed_home",
|
||||
"alarm_control_panel.is_armed_night",
|
||||
"alarm_control_panel.is_armed_vacation",
|
||||
"alarm_control_panel.is_disarmed",
|
||||
"alarm_control_panel.is_triggered",
|
||||
],
|
||||
)
|
||||
async def test_alarm_control_panel_conditions_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
|
||||
) -> None:
|
||||
"""Test the alarm_control_panel conditions are gated by the labs flag."""
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("alarm_control_panel"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_armed",
|
||||
target_states=[
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
],
|
||||
other_states=[
|
||||
AlarmControlPanelState.ARMING,
|
||||
AlarmControlPanelState.DISARMED,
|
||||
AlarmControlPanelState.DISARMING,
|
||||
AlarmControlPanelState.PENDING,
|
||||
AlarmControlPanelState.TRIGGERED,
|
||||
],
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_armed_away",
|
||||
target_states=[AlarmControlPanelState.ARMED_AWAY],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
|
||||
additional_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
},
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_armed_home",
|
||||
target_states=[AlarmControlPanelState.ARMED_HOME],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_HOME),
|
||||
additional_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
|
||||
},
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_armed_night",
|
||||
target_states=[AlarmControlPanelState.ARMED_NIGHT],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
|
||||
additional_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
},
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_armed_vacation",
|
||||
target_states=[AlarmControlPanelState.ARMED_VACATION],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
|
||||
additional_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
},
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_disarmed",
|
||||
target_states=[AlarmControlPanelState.DISARMED],
|
||||
other_states=other_states(AlarmControlPanelState.DISARMED),
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_triggered",
|
||||
target_states=[AlarmControlPanelState.TRIGGERED],
|
||||
other_states=other_states(AlarmControlPanelState.TRIGGERED),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_alarm_control_panel_state_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_alarm_control_panels: list[str],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the alarm_control_panel state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_alarm_control_panels) - {entity_id}
|
||||
|
||||
# Set all alarm_control_panels, including the tested alarm_control_panel, to the initial state
|
||||
for eid in target_alarm_control_panels:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other alarm_control_panels also passes the condition
|
||||
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 condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("alarm_control_panel"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_armed",
|
||||
target_states=[
|
||||
AlarmControlPanelState.ARMED_AWAY,
|
||||
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
|
||||
AlarmControlPanelState.ARMED_HOME,
|
||||
AlarmControlPanelState.ARMED_NIGHT,
|
||||
AlarmControlPanelState.ARMED_VACATION,
|
||||
],
|
||||
other_states=[
|
||||
AlarmControlPanelState.ARMING,
|
||||
AlarmControlPanelState.DISARMED,
|
||||
AlarmControlPanelState.DISARMING,
|
||||
AlarmControlPanelState.PENDING,
|
||||
AlarmControlPanelState.TRIGGERED,
|
||||
],
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_armed_away",
|
||||
target_states=[AlarmControlPanelState.ARMED_AWAY],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_AWAY),
|
||||
additional_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_AWAY
|
||||
},
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_armed_home",
|
||||
target_states=[AlarmControlPanelState.ARMED_HOME],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_HOME),
|
||||
additional_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_HOME
|
||||
},
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_armed_night",
|
||||
target_states=[AlarmControlPanelState.ARMED_NIGHT],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_NIGHT),
|
||||
additional_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_NIGHT
|
||||
},
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_armed_vacation",
|
||||
target_states=[AlarmControlPanelState.ARMED_VACATION],
|
||||
other_states=other_states(AlarmControlPanelState.ARMED_VACATION),
|
||||
additional_attributes={
|
||||
ATTR_SUPPORTED_FEATURES: AlarmControlPanelEntityFeature.ARM_VACATION
|
||||
},
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_disarmed",
|
||||
target_states=[AlarmControlPanelState.DISARMED],
|
||||
other_states=other_states(AlarmControlPanelState.DISARMED),
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="alarm_control_panel.is_triggered",
|
||||
target_states=[AlarmControlPanelState.TRIGGERED],
|
||||
other_states=other_states(AlarmControlPanelState.TRIGGERED),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_alarm_control_panel_state_condition_behavior_all(
|
||||
hass: HomeAssistant,
|
||||
target_alarm_control_panels: list[str],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the alarm_control_panel state condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_alarm_control_panels) - {entity_id}
|
||||
|
||||
# Set all alarm_control_panels, including the tested alarm_control_panel, to the initial state
|
||||
for eid in target_alarm_control_panels:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
# The condition passes if all entities are either in a target state or invalid
|
||||
assert condition(hass) == (
|
||||
(not state["state_valid"])
|
||||
or (state["condition_true"] and entities_in_target == 1)
|
||||
)
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The condition passes if all entities are either in a target state or invalid
|
||||
assert condition(hass) == (
|
||||
(not state["state_valid"]) or state["condition_true"]
|
||||
)
|
||||
@@ -1,190 +0,0 @@
|
||||
"""Test assist satellite conditions."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.assist_satellite.entity import AssistSatelliteState
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from tests.components import (
|
||||
ConditionStateDescription,
|
||||
assert_condition_gated_by_labs_flag,
|
||||
create_target_condition,
|
||||
other_states,
|
||||
parametrize_condition_states,
|
||||
parametrize_target_entities,
|
||||
set_or_remove_state,
|
||||
target_entities,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True, name="stub_blueprint_populate")
|
||||
def stub_blueprint_populate_autouse(stub_blueprint_populate: None) -> None:
|
||||
"""Stub copying the blueprints to the config folder."""
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def target_assist_satellites(hass: HomeAssistant) -> list[str]:
|
||||
"""Create multiple assist satellite entities associated with different targets."""
|
||||
return (await target_entities(hass, "assist_satellite"))["included"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"condition",
|
||||
[
|
||||
"assist_satellite.is_idle",
|
||||
"assist_satellite.is_listening",
|
||||
"assist_satellite.is_processing",
|
||||
"assist_satellite.is_responding",
|
||||
],
|
||||
)
|
||||
async def test_assist_satellite_conditions_gated_by_labs_flag(
|
||||
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, condition: str
|
||||
) -> None:
|
||||
"""Test the assist satellite conditions are gated by the labs flag."""
|
||||
await assert_condition_gated_by_labs_flag(hass, caplog, condition)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("assist_satellite"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states(
|
||||
condition="assist_satellite.is_idle",
|
||||
target_states=[AssistSatelliteState.IDLE],
|
||||
other_states=other_states(AssistSatelliteState.IDLE),
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="assist_satellite.is_listening",
|
||||
target_states=[AssistSatelliteState.LISTENING],
|
||||
other_states=other_states(AssistSatelliteState.LISTENING),
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="assist_satellite.is_processing",
|
||||
target_states=[AssistSatelliteState.PROCESSING],
|
||||
other_states=other_states(AssistSatelliteState.PROCESSING),
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="assist_satellite.is_responding",
|
||||
target_states=[AssistSatelliteState.RESPONDING],
|
||||
other_states=other_states(AssistSatelliteState.RESPONDING),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_assist_satellite_state_condition_behavior_any(
|
||||
hass: HomeAssistant,
|
||||
target_assist_satellites: list[str],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the assist satellite state condition with the 'any' behavior."""
|
||||
other_entity_ids = set(target_assist_satellites) - {entity_id}
|
||||
|
||||
# Set all assist satellites, including the tested one, to the initial state
|
||||
for eid in target_assist_satellites:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="any",
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
assert condition(hass) == state["condition_true"]
|
||||
|
||||
# Check if changing other assist satellites also passes the condition
|
||||
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 condition(hass) == state["condition_true"]
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("enable_labs_preview_features")
|
||||
@pytest.mark.parametrize(
|
||||
("condition_target_config", "entity_id", "entities_in_target"),
|
||||
parametrize_target_entities("assist_satellite"),
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
("condition", "condition_options", "states"),
|
||||
[
|
||||
*parametrize_condition_states(
|
||||
condition="assist_satellite.is_idle",
|
||||
target_states=[AssistSatelliteState.IDLE],
|
||||
other_states=other_states(AssistSatelliteState.IDLE),
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="assist_satellite.is_listening",
|
||||
target_states=[AssistSatelliteState.LISTENING],
|
||||
other_states=other_states(AssistSatelliteState.LISTENING),
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="assist_satellite.is_processing",
|
||||
target_states=[AssistSatelliteState.PROCESSING],
|
||||
other_states=other_states(AssistSatelliteState.PROCESSING),
|
||||
),
|
||||
*parametrize_condition_states(
|
||||
condition="assist_satellite.is_responding",
|
||||
target_states=[AssistSatelliteState.RESPONDING],
|
||||
other_states=other_states(AssistSatelliteState.RESPONDING),
|
||||
),
|
||||
],
|
||||
)
|
||||
async def test_assist_satellite_state_condition_behavior_all(
|
||||
hass: HomeAssistant,
|
||||
target_assist_satellites: list[str],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
entities_in_target: int,
|
||||
condition: str,
|
||||
condition_options: dict[str, Any],
|
||||
states: list[ConditionStateDescription],
|
||||
) -> None:
|
||||
"""Test the assist satellite state condition with the 'all' behavior."""
|
||||
other_entity_ids = set(target_assist_satellites) - {entity_id}
|
||||
|
||||
# Set all assist satellites, including the tested one, to the initial state
|
||||
for eid in target_assist_satellites:
|
||||
set_or_remove_state(hass, eid, states[0]["included"])
|
||||
await hass.async_block_till_done()
|
||||
|
||||
condition = await create_target_condition(
|
||||
hass,
|
||||
condition=condition,
|
||||
target=condition_target_config,
|
||||
behavior="all",
|
||||
)
|
||||
|
||||
for state in states:
|
||||
included_state = state["included"]
|
||||
|
||||
set_or_remove_state(hass, entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
# The condition passes if all entities are either in a target state or invalid
|
||||
assert condition(hass) == (
|
||||
(not state["state_valid"])
|
||||
or (state["condition_true"] and entities_in_target == 1)
|
||||
)
|
||||
|
||||
for other_entity_id in other_entity_ids:
|
||||
set_or_remove_state(hass, other_entity_id, included_state)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
# The condition passes if all entities are either in a target state or invalid
|
||||
assert condition(hass) == (
|
||||
(not state["state_valid"]) or state["condition_true"]
|
||||
)
|
||||
@@ -11,7 +11,6 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util.yaml import UndefinedSubstitution, parse_yaml
|
||||
|
||||
from tests.common import MockUser
|
||||
from tests.test_util.aiohttp import AiohttpClientMocker
|
||||
from tests.typing import WebSocketGenerator
|
||||
|
||||
@@ -104,51 +103,6 @@ async def test_list_blueprints_non_existing_domain(
|
||||
assert blueprints == {}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"message",
|
||||
[
|
||||
{"type": "blueprint/list", "domain": "automation"},
|
||||
{"type": "blueprint/import", "url": "https://example.com/blueprint.yaml"},
|
||||
{
|
||||
"type": "blueprint/save",
|
||||
"path": "test_save",
|
||||
"yaml": "raw_data",
|
||||
"domain": "automation",
|
||||
},
|
||||
{
|
||||
"type": "blueprint/delete",
|
||||
"path": "test_delete",
|
||||
"domain": "automation",
|
||||
},
|
||||
{
|
||||
"type": "blueprint/substitute",
|
||||
"domain": "automation",
|
||||
"path": "test_event_service.yaml",
|
||||
"input": {
|
||||
"trigger_event": "test_event",
|
||||
"service_to_call": "test.automation",
|
||||
"a_number": 5,
|
||||
},
|
||||
},
|
||||
],
|
||||
)
|
||||
async def test_blueprint_ws_command_requires_admin(
|
||||
hass: HomeAssistant,
|
||||
hass_ws_client: WebSocketGenerator,
|
||||
hass_admin_user: MockUser,
|
||||
message: dict[str, Any],
|
||||
) -> None:
|
||||
"""Test that blueprint websocket commands require admin."""
|
||||
hass_admin_user.groups = [] # Remove admin privileges
|
||||
client = await hass_ws_client(hass)
|
||||
await client.send_json_auto_id(message)
|
||||
|
||||
msg = await client.receive_json()
|
||||
|
||||
assert not msg["success"]
|
||||
assert msg["error"]["code"] == "unauthorized"
|
||||
|
||||
|
||||
async def test_import_blueprint(
|
||||
hass: HomeAssistant,
|
||||
aioclient_mock: AiohttpClientMocker,
|
||||
|
||||
@@ -74,5 +74,5 @@ async def test_sensor_updates_on_hour_tick(
|
||||
|
||||
assert (
|
||||
hass.states.get("sensor.essent_current_electricity_market_price").state
|
||||
== "0.24535"
|
||||
== "0.10417"
|
||||
)
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Any
|
||||
import pytest
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
|
||||
from tests.components import (
|
||||
ConditionStateDescription,
|
||||
@@ -137,6 +137,7 @@ async def test_light_state_condition_behavior_any(
|
||||
)
|
||||
async def test_light_state_condition_behavior_all(
|
||||
hass: HomeAssistant,
|
||||
service_calls: list[ServiceCall],
|
||||
target_lights: list[str],
|
||||
condition_target_config: dict,
|
||||
entity_id: str,
|
||||
|
||||
@@ -129,7 +129,6 @@ async def integration_fixture(
|
||||
"oven",
|
||||
"pressure_sensor",
|
||||
"pump",
|
||||
"resideo_x2s_thermostat",
|
||||
"room_airconditioner",
|
||||
"secuyou_smart_lock",
|
||||
"silabs_dishwasher",
|
||||
|
||||
@@ -1,190 +0,0 @@
|
||||
{
|
||||
"node_id": 4,
|
||||
"date_commissioned": "2026-01-04T01:49:35.244151",
|
||||
"last_interview": "2026-01-04T03:11:54.520702",
|
||||
"interview_version": 6,
|
||||
"available": true,
|
||||
"is_bridge": false,
|
||||
"attributes": {
|
||||
"0/29/0": [
|
||||
{
|
||||
"0": 22,
|
||||
"1": 1
|
||||
}
|
||||
],
|
||||
"0/29/1": [29, 31, 40, 48, 51, 60, 62, 63],
|
||||
"0/29/2": [],
|
||||
"0/29/3": [1],
|
||||
"0/29/65532": 0,
|
||||
"0/29/65533": 2,
|
||||
"0/29/65528": [],
|
||||
"0/29/65529": [],
|
||||
"0/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/31/0": [
|
||||
{
|
||||
"1": 5,
|
||||
"2": 2,
|
||||
"3": [112233],
|
||||
"4": null,
|
||||
"254": 1
|
||||
}
|
||||
],
|
||||
"0/31/2": 4,
|
||||
"0/31/3": 3,
|
||||
"0/31/4": 4,
|
||||
"0/31/65532": 0,
|
||||
"0/31/65533": 1,
|
||||
"0/31/65528": [],
|
||||
"0/31/65529": [],
|
||||
"0/31/65531": [0, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/40/0": 17,
|
||||
"0/40/1": "Resideo",
|
||||
"0/40/2": 4890,
|
||||
"0/40/3": "X2S Smart Thermostat",
|
||||
"0/40/4": 4096,
|
||||
"0/40/5": "",
|
||||
"0/40/6": "**REDACTED**",
|
||||
"0/40/7": 2,
|
||||
"0/40/8": "X2S_STAT_NPS_002",
|
||||
"0/40/9": 1,
|
||||
"0/40/10": "2.0.0.0",
|
||||
"0/40/15": "**REDACTED**",
|
||||
"0/40/19": {
|
||||
"0": 3,
|
||||
"1": 3
|
||||
},
|
||||
"0/40/21": 16973824,
|
||||
"0/40/22": 1,
|
||||
"0/40/65532": 0,
|
||||
"0/40/65533": 3,
|
||||
"0/40/65528": [],
|
||||
"0/40/65529": [],
|
||||
"0/40/65531": [
|
||||
0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 15, 19, 21, 22, 65528, 65529, 65531,
|
||||
65532, 65533
|
||||
],
|
||||
"0/48/0": 0,
|
||||
"0/48/1": {
|
||||
"0": 60,
|
||||
"1": 900
|
||||
},
|
||||
"0/48/2": 0,
|
||||
"0/48/3": 2,
|
||||
"0/48/4": true,
|
||||
"0/48/65532": 0,
|
||||
"0/48/65533": 1,
|
||||
"0/48/65528": [1, 3, 5],
|
||||
"0/48/65529": [0, 2, 4],
|
||||
"0/48/65531": [0, 1, 2, 3, 4, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/51/0": [
|
||||
{
|
||||
"0": "r0",
|
||||
"1": true,
|
||||
"2": null,
|
||||
"3": null,
|
||||
"4": "XPzhnNpQ",
|
||||
"5": ["wKgJoQ=="],
|
||||
"6": ["/oAAAAAAAABe/OH//pzaUA=="],
|
||||
"7": 1
|
||||
}
|
||||
],
|
||||
"0/51/1": 2,
|
||||
"0/51/2": 5105,
|
||||
"0/51/8": false,
|
||||
"0/51/65532": 0,
|
||||
"0/51/65533": 2,
|
||||
"0/51/65528": [2],
|
||||
"0/51/65529": [0, 1],
|
||||
"0/51/65531": [0, 1, 2, 8, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/60/0": 0,
|
||||
"0/60/1": null,
|
||||
"0/60/2": null,
|
||||
"0/60/65532": 0,
|
||||
"0/60/65533": 1,
|
||||
"0/60/65528": [],
|
||||
"0/60/65529": [0, 2],
|
||||
"0/60/65531": [0, 1, 2, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/62/0": [
|
||||
{
|
||||
"1": "FTABAQEkAgE3AyQTAhgmBIAigScmBYAlTTo3BiQVAiQRBBgkBwEkCAEwCUEEKl96Nj13XjcDn1kF4aoFwMWb9leBXP2Urts/tvLi1DF1UZkPEBrfZ5YYqd5tps3ELof6pBX91oACxfbnYF7UyzcKNQEoARgkAgE2AwQCBAEYMAQUY8nv41nGGNtJapsJ0+8/6EAnt9owBRRnrnI3xp/0zhgwIJN0RMbKS99orRgwC0BsruLcJINuIyVVZHD5AlYCuha4XhnLxtIjyYCXIIHGNuu39D6u/j94efSHPrOvVjAHXY56+z5KJguTzlTBOC5tGA==",
|
||||
"2": "FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQTAhgkBwEkCAEwCUEEOoay7Kv2Hog5xV7knNJLl+Ywx5Sr/jrp6/PV5XF57NXm4UJfgdb6Ja7rZ+965UjigpYh+JVAVvCRK1xNgkikiDcKNQEpARgkAmAwBBRnrnI3xp/0zhgwIJN0RMbKS99orTAFFEHaAQy9nUPjHiRv7FIwcIp50v+EGDALQF5JHY0EJKgFC63BM4uO0mrkHpeTCSDpUEEz7IsvkdxAgUToWftgJSC3B7gqDelohC4uqReJpmeQ64F5XqYtB3AY",
|
||||
"254": 1
|
||||
}
|
||||
],
|
||||
"0/62/1": [
|
||||
{
|
||||
"1": "BGkTBQSFwwkc5WoOUncXmIahsjWs9bKfHyZRWpArIFMjhyjNKqURWvFS8xbVXTFf+UlFmJF2JnlMX4WgKjXkOLo=",
|
||||
"2": 4939,
|
||||
"3": 2,
|
||||
"4": 4,
|
||||
"5": "Home",
|
||||
"254": 1
|
||||
}
|
||||
],
|
||||
"0/62/2": 5,
|
||||
"0/62/3": 1,
|
||||
"0/62/4": [
|
||||
"FTABAQEkAgE3AyQUARgmBIAigScmBYAlTTo3BiQUARgkBwEkCAEwCUEEaRMFBIXDCRzlag5SdxeYhqGyNaz1sp8fJlFakCsgUyOHKM0qpRFa8VLzFtVdMV/5SUWYkXYmeUxfhaAqNeQ4ujcKNQEpARgkAmAwBBRB2gEMvZ1D4x4kb+xSMHCKedL/hDAFFEHaAQy9nUPjHiRv7FIwcIp50v+EGDALQE/fBBea6WzXom6INogGzGdop0w7g8j4dcIo6v8Id2k+sttWqeL5we7dDJonx/m2MgVsQTKCeVhtN/nzT4stvmEY"
|
||||
],
|
||||
"0/62/5": 1,
|
||||
"0/62/65532": 0,
|
||||
"0/62/65533": 1,
|
||||
"0/62/65528": [1, 3, 5, 8],
|
||||
"0/62/65529": [0, 2, 4, 6, 7, 9, 10, 11],
|
||||
"0/62/65531": [0, 1, 2, 3, 4, 5, 65528, 65529, 65531, 65532, 65533],
|
||||
"0/63/0": [],
|
||||
"0/63/1": [],
|
||||
"0/63/2": 4,
|
||||
"0/63/3": 3,
|
||||
"0/63/65532": 0,
|
||||
"0/63/65533": 2,
|
||||
"0/63/65528": [2, 5],
|
||||
"0/63/65529": [0, 1, 3, 4],
|
||||
"0/63/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/3/0": 0,
|
||||
"1/3/1": 4,
|
||||
"1/3/65532": 0,
|
||||
"1/3/65533": 4,
|
||||
"1/3/65528": [],
|
||||
"1/3/65529": [0],
|
||||
"1/3/65531": [0, 1, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/4/0": 128,
|
||||
"1/4/65532": 1,
|
||||
"1/4/65533": 4,
|
||||
"1/4/65528": [0, 1, 2, 3],
|
||||
"1/4/65529": [0, 1, 2, 3, 4, 5],
|
||||
"1/4/65531": [0, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/29/0": [
|
||||
{
|
||||
"0": 769,
|
||||
"1": 1
|
||||
}
|
||||
],
|
||||
"1/29/1": [3, 4, 29, 513],
|
||||
"1/29/2": [],
|
||||
"1/29/3": [],
|
||||
"1/29/65532": 0,
|
||||
"1/29/65533": 2,
|
||||
"1/29/65528": [],
|
||||
"1/29/65529": [],
|
||||
"1/29/65531": [0, 1, 2, 3, 65528, 65529, 65531, 65532, 65533],
|
||||
"1/513/0": 2055,
|
||||
"1/513/3": 443,
|
||||
"1/513/4": 3221,
|
||||
"1/513/5": 1000,
|
||||
"1/513/6": 3721,
|
||||
"1/513/17": 2666,
|
||||
"1/513/18": 2166,
|
||||
"1/513/25": 0,
|
||||
"1/513/27": 4,
|
||||
"1/513/28": 0,
|
||||
"1/513/65532": 3,
|
||||
"1/513/65533": 6,
|
||||
"1/513/65528": [],
|
||||
"1/513/65529": [0],
|
||||
"1/513/65531": [
|
||||
0, 3, 4, 5, 6, 17, 18, 25, 27, 28, 65528, 65529, 65531, 65532, 65533
|
||||
]
|
||||
},
|
||||
"attribute_subscriptions": []
|
||||
}
|
||||
@@ -3087,56 +3087,6 @@
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_buttons[resideo_x2s_thermostat][button.x2s_smart_thermostat_identify-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': None,
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'button',
|
||||
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
|
||||
'entity_id': 'button.x2s_smart_thermostat_identify',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Identify',
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Identify',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-IdentifyButton-3-1',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_buttons[resideo_x2s_thermostat][button.x2s_smart_thermostat_identify-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'identify',
|
||||
'friendly_name': 'X2S Smart Thermostat Identify',
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'button.x2s_smart_thermostat_identify',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'unknown',
|
||||
})
|
||||
# ---
|
||||
# name: test_buttons[secuyou_smart_lock][button.secuyou_smart_lock_identify-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -467,75 +467,6 @@
|
||||
'state': 'heat_cool',
|
||||
})
|
||||
# ---
|
||||
# name: test_climates[resideo_x2s_thermostat][climate.x2s_smart_thermostat-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
<HVACMode.COOL: 'cool'>,
|
||||
<HVACMode.FAN_ONLY: 'fan_only'>,
|
||||
]),
|
||||
'max_temp': 32.2,
|
||||
'min_temp': 4.4,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'climate',
|
||||
'entity_category': None,
|
||||
'entity_id': 'climate.x2s_smart_thermostat',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': None,
|
||||
'original_icon': None,
|
||||
'original_name': None,
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <ClimateEntityFeature: 385>,
|
||||
'translation_key': None,
|
||||
'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-MatterThermostat-513-0',
|
||||
'unit_of_measurement': None,
|
||||
})
|
||||
# ---
|
||||
# name: test_climates[resideo_x2s_thermostat][climate.x2s_smart_thermostat-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 20.6,
|
||||
'friendly_name': 'X2S Smart Thermostat',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
<HVACMode.COOL: 'cool'>,
|
||||
<HVACMode.FAN_ONLY: 'fan_only'>,
|
||||
]),
|
||||
'max_temp': 32.2,
|
||||
'min_temp': 4.4,
|
||||
'supported_features': <ClimateEntityFeature: 385>,
|
||||
'temperature': 21.7,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.x2s_smart_thermostat',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'off',
|
||||
})
|
||||
# ---
|
||||
# name: test_climates[room_airconditioner][climate.room_airconditioner-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -9748,63 +9748,6 @@
|
||||
'state': '60.0',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[resideo_x2s_thermostat][sensor.x2s_smart_thermostat_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
}),
|
||||
'area_id': None,
|
||||
'capabilities': dict({
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
'config_subentry_id': <ANY>,
|
||||
'device_class': None,
|
||||
'device_id': <ANY>,
|
||||
'disabled_by': None,
|
||||
'domain': 'sensor',
|
||||
'entity_category': None,
|
||||
'entity_id': 'sensor.x2s_smart_thermostat_temperature',
|
||||
'has_entity_name': True,
|
||||
'hidden_by': None,
|
||||
'icon': None,
|
||||
'id': <ANY>,
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': 'Temperature',
|
||||
'options': dict({
|
||||
'sensor': dict({
|
||||
'suggested_display_precision': 1,
|
||||
}),
|
||||
}),
|
||||
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
|
||||
'original_icon': None,
|
||||
'original_name': 'Temperature',
|
||||
'platform': 'matter',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': 0,
|
||||
'translation_key': None,
|
||||
'unique_id': '00000000000004D2-0000000000000004-MatterNodeDevice-1-ThermostatLocalTemperature-513-0',
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[resideo_x2s_thermostat][sensor.x2s_smart_thermostat_temperature-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'temperature',
|
||||
'friendly_name': 'X2S Smart Thermostat Temperature',
|
||||
'state_class': <SensorStateClass.MEASUREMENT: 'measurement'>,
|
||||
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'sensor.x2s_smart_thermostat_temperature',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': '20.55',
|
||||
})
|
||||
# ---
|
||||
# name: test_sensors[room_airconditioner][sensor.room_airconditioner_temperature-entry]
|
||||
EntityRegistryEntrySnapshot({
|
||||
'aliases': set({
|
||||
|
||||
@@ -34,7 +34,7 @@ async def test_setup_missing_config(hass: HomeAssistant) -> None:
|
||||
"""Test setup with missing configuration."""
|
||||
with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client:
|
||||
config = {"sensor": {"platform": "mfi"}}
|
||||
assert await async_setup_component(hass, COMPONENT.DOMAIN, config)
|
||||
assert await async_setup_component(hass, "sensor", config)
|
||||
assert not mock_client.called
|
||||
|
||||
|
||||
@@ -42,14 +42,14 @@ async def test_setup_failed_login(hass: HomeAssistant) -> None:
|
||||
"""Test setup with login failure."""
|
||||
with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client:
|
||||
mock_client.side_effect = FailedToLogin
|
||||
assert not PLATFORM.setup_platform(hass, GOOD_CONFIG[COMPONENT.DOMAIN], None)
|
||||
assert not PLATFORM.setup_platform(hass, GOOD_CONFIG, None)
|
||||
|
||||
|
||||
async def test_setup_failed_connect(hass: HomeAssistant) -> None:
|
||||
"""Test setup with connection failure."""
|
||||
with mock.patch("homeassistant.components.mfi.sensor.MFiClient") as mock_client:
|
||||
mock_client.side_effect = requests.exceptions.ConnectionError
|
||||
assert not PLATFORM.setup_platform(hass, GOOD_CONFIG[COMPONENT.DOMAIN], None)
|
||||
assert not PLATFORM.setup_platform(hass, GOOD_CONFIG, None)
|
||||
|
||||
|
||||
async def test_setup_minimum(hass: HomeAssistant) -> None:
|
||||
@@ -111,7 +111,7 @@ async def test_setup_adds_proper_devices(hass: HomeAssistant) -> None:
|
||||
await hass.async_block_till_done()
|
||||
for ident, port in ports.items():
|
||||
if ident != "bad":
|
||||
mock_sensor.assert_any_call(port)
|
||||
mock_sensor.assert_any_call(port, hass)
|
||||
assert mock.call(ports["bad"], hass) not in mock_sensor.mock_calls
|
||||
|
||||
|
||||
@@ -124,7 +124,7 @@ def port_fixture() -> mock.MagicMock:
|
||||
@pytest.fixture(name="sensor")
|
||||
def sensor_fixture(hass: HomeAssistant, port: mock.MagicMock) -> mfi.MfiSensor:
|
||||
"""Sensor fixture."""
|
||||
sensor = mfi.MfiSensor(port)
|
||||
sensor = mfi.MfiSensor(port, hass)
|
||||
sensor.hass = hass
|
||||
return sensor
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ from tests.common import (
|
||||
async def setup_platform(hass: HomeAssistant, config_entry: MockConfigEntry) -> None:
|
||||
"""Set up the NINA platform."""
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
wraps=mocked_request_function,
|
||||
):
|
||||
await hass.config_entries.async_setup(config_entry.entry_id)
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
'id': 'biw.BIWAPP-69634',
|
||||
'is_valid': False,
|
||||
'recommended_actions': '',
|
||||
'sender': None,
|
||||
'sender': '',
|
||||
'sent': '1999-08-07T10:59:00+02:00',
|
||||
'severity': 'Minor',
|
||||
'start': '',
|
||||
|
||||
@@ -49,7 +49,7 @@ def assert_dummy_entry_created(result: dict[str, Any]) -> None:
|
||||
async def test_step_user_connection_error(hass: HomeAssistant) -> None:
|
||||
"""Test starting a flow by user but no connection."""
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
side_effect=ApiError("Could not connect to Api"),
|
||||
):
|
||||
result: dict[str, Any] = await hass.config_entries.flow.async_init(
|
||||
@@ -63,7 +63,7 @@ async def test_step_user_connection_error(hass: HomeAssistant) -> None:
|
||||
async def test_step_user_unexpected_exception(hass: HomeAssistant) -> None:
|
||||
"""Test starting a flow by user but with an unexpected exception."""
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
side_effect=Exception("DUMMY"),
|
||||
):
|
||||
result: dict[str, Any] = await hass.config_entries.flow.async_init(
|
||||
@@ -77,7 +77,7 @@ async def test_step_user_unexpected_exception(hass: HomeAssistant) -> None:
|
||||
async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None:
|
||||
"""Test starting a flow by user with valid values."""
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
wraps=mocked_request_function,
|
||||
):
|
||||
result: dict[str, Any] = await hass.config_entries.flow.async_init(
|
||||
@@ -95,7 +95,7 @@ async def test_step_user(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> No
|
||||
async def test_step_user_no_selection(hass: HomeAssistant) -> None:
|
||||
"""Test starting a flow by user with no selection."""
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
wraps=mocked_request_function,
|
||||
):
|
||||
result: dict[str, Any] = await hass.config_entries.flow.async_init(
|
||||
@@ -121,7 +121,7 @@ async def test_step_user_already_configured(
|
||||
) -> None:
|
||||
"""Test starting a flow by user, but it was already configured."""
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
wraps=mocked_request_function,
|
||||
):
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -141,7 +141,7 @@ async def test_options_flow_init(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
wraps=mocked_request_function,
|
||||
),
|
||||
):
|
||||
@@ -195,7 +195,7 @@ async def test_options_flow_with_no_selection(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
wraps=mocked_request_function,
|
||||
),
|
||||
):
|
||||
@@ -263,7 +263,7 @@ async def test_options_flow_connection_error(
|
||||
await setup_platform(hass, mock_config_entry)
|
||||
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
side_effect=ApiError("Could not connect to Api"),
|
||||
):
|
||||
result = await hass.config_entries.options.async_init(
|
||||
@@ -283,7 +283,7 @@ async def test_options_flow_unexpected_exception(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
side_effect=Exception("DUMMY"),
|
||||
),
|
||||
):
|
||||
@@ -312,7 +312,7 @@ async def test_options_flow_entity_removal(
|
||||
|
||||
with (
|
||||
patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
wraps=mocked_request_function,
|
||||
),
|
||||
):
|
||||
|
||||
@@ -32,7 +32,7 @@ async def test_diagnostics(
|
||||
"""Test diagnostics."""
|
||||
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
wraps=mocked_request_function,
|
||||
):
|
||||
config_entry: MockConfigEntry = MockConfigEntry(
|
||||
|
||||
@@ -28,7 +28,7 @@ async def init_integration(hass: HomeAssistant) -> MockConfigEntry:
|
||||
"""Set up the NINA integration in Home Assistant."""
|
||||
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
wraps=mocked_request_function,
|
||||
):
|
||||
entry: MockConfigEntry = MockConfigEntry(
|
||||
@@ -54,7 +54,7 @@ async def test_config_migration_from1_1(hass: HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
wraps=mocked_request_function,
|
||||
):
|
||||
old_conf_entry.add_to_hass(hass)
|
||||
@@ -82,7 +82,7 @@ async def test_config_migration_from1_2(hass: HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
wraps=mocked_request_function,
|
||||
):
|
||||
old_conf_entry.add_to_hass(hass)
|
||||
@@ -104,7 +104,7 @@ async def test_config_migration_downgrade(hass: HomeAssistant) -> None:
|
||||
)
|
||||
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
wraps=mocked_request_function,
|
||||
):
|
||||
conf_entry.add_to_hass(hass)
|
||||
@@ -126,7 +126,7 @@ async def test_config_entry_not_ready(hass: HomeAssistant) -> None:
|
||||
async def test_sensors_connection_error(hass: HomeAssistant) -> None:
|
||||
"""Test the creation and values of the NINA sensors with no connected."""
|
||||
with patch(
|
||||
"pynina.api_client.APIClient.make_request",
|
||||
"pynina.baseApi.BaseAPI._makeRequest",
|
||||
side_effect=ApiError("Could not connect to Api"),
|
||||
):
|
||||
conf_entry: MockConfigEntry = MockConfigEntry(
|
||||
|
||||
@@ -58,7 +58,7 @@ async def test_form(hass: HomeAssistant) -> None:
|
||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||
)
|
||||
assert result["type"] is FlowResultType.FORM
|
||||
assert result["errors"] == {}
|
||||
assert result["errors"] is None
|
||||
|
||||
with (
|
||||
patch(
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <MediaPlayerEntityFeature: 23949>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 24461>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'afcf3b91-0000-1111-2222-ddff2a0a6577_main',
|
||||
'unit_of_measurement': None,
|
||||
@@ -59,7 +59,7 @@
|
||||
'HDMI2',
|
||||
'digital',
|
||||
]),
|
||||
'supported_features': <MediaPlayerEntityFeature: 23949>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 24461>,
|
||||
'volume_level': 0.01,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -101,7 +101,7 @@
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <MediaPlayerEntityFeature: 318477>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 318989>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'c9276e43-fe3c-88c3-1dcc-2eb79e292b8c_main',
|
||||
'unit_of_measurement': None,
|
||||
@@ -115,7 +115,7 @@
|
||||
'is_volume_muted': False,
|
||||
'repeat': <RepeatMode.OFF: 'off'>,
|
||||
'shuffle': False,
|
||||
'supported_features': <MediaPlayerEntityFeature: 318477>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 318989>,
|
||||
'volume_level': 0.52,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -157,7 +157,7 @@
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <MediaPlayerEntityFeature: 21517>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 22029>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'c85fced9-c474-4a47-93c2-037cc7829536_main',
|
||||
'unit_of_measurement': None,
|
||||
@@ -171,7 +171,7 @@
|
||||
'is_volume_muted': False,
|
||||
'media_artist': 'David Guetta',
|
||||
'media_title': 'Forever Young',
|
||||
'supported_features': <MediaPlayerEntityFeature: 21517>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 22029>,
|
||||
'volume_level': 0.15,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -213,7 +213,7 @@
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <MediaPlayerEntityFeature: 21901>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 22413>,
|
||||
'translation_key': None,
|
||||
'unique_id': '0d94e5db-8501-2355-eb4f-214163702cac_main',
|
||||
'unit_of_measurement': None,
|
||||
@@ -228,7 +228,7 @@
|
||||
'media_artist': '',
|
||||
'media_title': '',
|
||||
'source': 'HDMI1',
|
||||
'supported_features': <MediaPlayerEntityFeature: 21901>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 22413>,
|
||||
'volume_level': 0.17,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
@@ -270,7 +270,7 @@
|
||||
'platform': 'smartthings',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <MediaPlayerEntityFeature: 1420>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 1932>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'a75cb1e1-03fd-3c77-ca9f-d4e56c4096c6_main',
|
||||
'unit_of_measurement': None,
|
||||
@@ -281,7 +281,7 @@
|
||||
'attributes': ReadOnlyDict({
|
||||
'device_class': 'speaker',
|
||||
'friendly_name': 'Soundbar',
|
||||
'supported_features': <MediaPlayerEntityFeature: 1420>,
|
||||
'supported_features': <MediaPlayerEntityFeature: 1932>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'media_player.soundbar',
|
||||
|
||||
531
tests/components/smartthings/test_audio.py
Normal file
531
tests/components/smartthings/test_audio.py
Normal file
@@ -0,0 +1,531 @@
|
||||
"""Tests for SmartThings audio helper."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.smartthings.audio import (
|
||||
FFMPEG_MAX_DURATION_SECONDS,
|
||||
MAX_STORED_ENTRIES,
|
||||
PCM_CHANNELS,
|
||||
PCM_MIME,
|
||||
PCM_SAMPLE_RATE,
|
||||
PCM_SAMPLE_WIDTH,
|
||||
TRANSCODE_TIMEOUT_SECONDS,
|
||||
WARNING_DURATION_SECONDS,
|
||||
SmartThingsAudioError,
|
||||
async_get_audio_manager,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.network import NoURLAvailableError
|
||||
|
||||
from tests.typing import ClientSessionGenerator
|
||||
|
||||
|
||||
class _FakeProcess:
|
||||
"""Async subprocess stand-in that provides communicate."""
|
||||
|
||||
def __init__(self, stdout: bytes, stderr: bytes, returncode: int) -> None:
|
||||
self._stdout = stdout
|
||||
self._stderr = stderr
|
||||
self.returncode = returncode
|
||||
self.killed = False
|
||||
|
||||
async def communicate(self) -> tuple[bytes, bytes]:
|
||||
return self._stdout, self._stderr
|
||||
|
||||
def kill(self) -> None:
|
||||
self.killed = True
|
||||
|
||||
|
||||
def _build_pcm(
|
||||
duration_seconds: float = 1.0,
|
||||
*,
|
||||
sample_rate: int = PCM_SAMPLE_RATE,
|
||||
sample_width: int = PCM_SAMPLE_WIDTH,
|
||||
channels: int = PCM_CHANNELS,
|
||||
) -> bytes:
|
||||
"""Generate silent raw PCM bytes for testing."""
|
||||
frame_count = int(sample_rate * duration_seconds)
|
||||
return b"\x00" * frame_count * sample_width * channels
|
||||
|
||||
|
||||
async def test_prepare_notification_creates_url(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Ensure PCM proxy URLs are generated and served."""
|
||||
|
||||
hass.config.external_url = "https://example.com"
|
||||
manager = await async_get_audio_manager(hass)
|
||||
|
||||
pcm_bytes = _build_pcm()
|
||||
|
||||
with patch.object(
|
||||
manager, "_transcode_to_pcm", AsyncMock(return_value=(pcm_bytes, 1.0, False))
|
||||
):
|
||||
url = await manager.async_prepare_notification("https://example.com/source.mp3")
|
||||
|
||||
parsed = urlsplit(url)
|
||||
assert parsed.path.endswith(".pcm")
|
||||
assert not parsed.query
|
||||
|
||||
client = await hass_client_no_auth()
|
||||
response = await client.get(parsed.path)
|
||||
assert response.status == 200
|
||||
assert response.headers["Content-Type"] == PCM_MIME
|
||||
assert response.headers["Cache-Control"] == "no-store"
|
||||
body = await response.read()
|
||||
assert body == pcm_bytes
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_notification_uses_internal_url_when_external_missing(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Fallback to the internal URL if no external URL is available."""
|
||||
|
||||
hass.config.external_url = None
|
||||
hass.config.internal_url = "http://homeassistant.local:8123"
|
||||
manager = await async_get_audio_manager(hass)
|
||||
|
||||
pcm_bytes = _build_pcm()
|
||||
|
||||
with patch.object(
|
||||
manager, "_transcode_to_pcm", AsyncMock(return_value=(pcm_bytes, 1.0, False))
|
||||
):
|
||||
url = await manager.async_prepare_notification("https://example.com/source.mp3")
|
||||
|
||||
parsed = urlsplit(url)
|
||||
assert parsed.scheme == "http"
|
||||
assert parsed.netloc == "homeassistant.local:8123"
|
||||
assert parsed.path.endswith(".pcm")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_notification_requires_accessible_url(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Fail if neither external nor internal URLs are available."""
|
||||
|
||||
hass.config.external_url = None
|
||||
hass.config.internal_url = None
|
||||
manager = await async_get_audio_manager(hass)
|
||||
|
||||
pcm_bytes = _build_pcm()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
manager,
|
||||
"_transcode_to_pcm",
|
||||
AsyncMock(return_value=(pcm_bytes, 1.0, False)),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.get_url",
|
||||
side_effect=NoURLAvailableError,
|
||||
) as mock_get_url,
|
||||
pytest.raises(SmartThingsAudioError),
|
||||
):
|
||||
await manager.async_prepare_notification("https://example.com/source.mp3")
|
||||
|
||||
assert mock_get_url.called
|
||||
# Stored entry should be cleaned up after failure so subsequent requests
|
||||
# don't leak memory or serve stale audio.
|
||||
assert not manager._entries
|
||||
|
||||
|
||||
async def test_audio_view_returns_404_for_unknown_token(
|
||||
hass: HomeAssistant,
|
||||
hass_client_no_auth: ClientSessionGenerator,
|
||||
) -> None:
|
||||
"""Unknown tokens should return 404."""
|
||||
|
||||
await async_get_audio_manager(hass)
|
||||
client = await hass_client_no_auth()
|
||||
response = await client.get("/api/smartthings/audio/invalid-token.pcm")
|
||||
assert response.status == 404
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_notification_raises_when_transcode_empty(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Transcoding empty audio results in an error."""
|
||||
|
||||
hass.config.external_url = "https://example.com"
|
||||
manager = await async_get_audio_manager(hass)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
manager, "_transcode_to_pcm", AsyncMock(return_value=(b"", 0.0, False))
|
||||
),
|
||||
pytest.raises(SmartThingsAudioError, match="Converted audio is empty"),
|
||||
):
|
||||
await manager.async_prepare_notification("https://example.com/source.mp3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_notification_warns_when_duration_exceeds_max(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Warn when transcoded audio exceeds the SmartThings duration limit."""
|
||||
|
||||
hass.config.external_url = "https://example.com"
|
||||
manager = await async_get_audio_manager(hass)
|
||||
|
||||
pcm_bytes = b"pcm"
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
with patch.object(
|
||||
manager,
|
||||
"_transcode_to_pcm",
|
||||
AsyncMock(return_value=(pcm_bytes, FFMPEG_MAX_DURATION_SECONDS + 1.0, True)),
|
||||
):
|
||||
await manager.async_prepare_notification("https://example.com/source.mp3")
|
||||
|
||||
assert any("truncated" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_notification_warns_when_duration_exceeds_warning(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Warn when transcoded audio exceeds the SmartThings warning threshold."""
|
||||
|
||||
hass.config.external_url = "https://example.com"
|
||||
manager = await async_get_audio_manager(hass)
|
||||
|
||||
pcm_bytes = _build_pcm(duration_seconds=WARNING_DURATION_SECONDS + 1)
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
with patch.object(
|
||||
manager,
|
||||
"_transcode_to_pcm",
|
||||
AsyncMock(return_value=(pcm_bytes, WARNING_DURATION_SECONDS + 1.0, False)),
|
||||
):
|
||||
await manager.async_prepare_notification("https://example.com/source.mp3")
|
||||
|
||||
assert any(
|
||||
"playback over" in record.message and "truncated" not in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_notification_regenerates_token_on_collision(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Regenerate tokens when a collision is detected."""
|
||||
|
||||
hass.config.external_url = "https://example.com"
|
||||
manager = await async_get_audio_manager(hass)
|
||||
pcm_bytes = _build_pcm()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
manager,
|
||||
"_transcode_to_pcm",
|
||||
AsyncMock(return_value=(pcm_bytes, 1.0, False)),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.secrets.token_urlsafe",
|
||||
side_effect=["dup", "dup", "unique"],
|
||||
),
|
||||
):
|
||||
url1 = await manager.async_prepare_notification(
|
||||
"https://example.com/source.mp3"
|
||||
)
|
||||
url2 = await manager.async_prepare_notification(
|
||||
"https://example.com/source.mp3"
|
||||
)
|
||||
|
||||
assert urlsplit(url1).path.endswith("/dup.pcm")
|
||||
assert urlsplit(url2).path.endswith("/unique.pcm")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_notification_schedules_cleanup(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Ensure cached entries are scheduled for cleanup."""
|
||||
|
||||
hass.config.external_url = "https://example.com"
|
||||
manager = await async_get_audio_manager(hass)
|
||||
|
||||
pcm_bytes = _build_pcm()
|
||||
|
||||
with patch.object(
|
||||
manager,
|
||||
"_transcode_to_pcm",
|
||||
AsyncMock(return_value=(pcm_bytes, 1.0, False)),
|
||||
):
|
||||
await manager.async_prepare_notification("https://example.com/source.mp3")
|
||||
|
||||
assert manager._cleanup_handle is not None
|
||||
for entry in manager._entries.values():
|
||||
entry.expires = 0
|
||||
|
||||
manager._cleanup_callback()
|
||||
|
||||
assert not manager._entries
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_notification_caps_entry_count(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Ensure cached entries are capped."""
|
||||
|
||||
hass.config.external_url = "https://example.com"
|
||||
manager = await async_get_audio_manager(hass)
|
||||
|
||||
pcm_bytes = _build_pcm()
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
with patch.object(
|
||||
manager,
|
||||
"_transcode_to_pcm",
|
||||
AsyncMock(return_value=(pcm_bytes, 1.0, False)),
|
||||
):
|
||||
for _ in range(MAX_STORED_ENTRIES + 2):
|
||||
await manager.async_prepare_notification("https://example.com/source.mp3")
|
||||
|
||||
assert len(manager._entries) == MAX_STORED_ENTRIES
|
||||
assert any(
|
||||
"Dropped oldest SmartThings audio token" in record.message
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcode_to_pcm_handles_missing_ffmpeg(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Raise friendly error when ffmpeg is unavailable."""
|
||||
|
||||
manager = await async_get_audio_manager(hass)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager",
|
||||
return_value=SimpleNamespace(binary="ffmpeg"),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec",
|
||||
side_effect=FileNotFoundError,
|
||||
),
|
||||
pytest.raises(SmartThingsAudioError, match="FFmpeg is required"),
|
||||
):
|
||||
await manager._transcode_to_pcm("https://example.com/source.mp3")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcode_to_pcm_handles_process_failure(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Raise when ffmpeg reports an error."""
|
||||
|
||||
manager = await async_get_audio_manager(hass)
|
||||
caplog.set_level(logging.ERROR)
|
||||
|
||||
fake_process = _FakeProcess(stdout=b"", stderr=b"boom", returncode=1)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager",
|
||||
return_value=SimpleNamespace(binary="ffmpeg"),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=fake_process),
|
||||
),
|
||||
pytest.raises(SmartThingsAudioError, match="Unable to convert"),
|
||||
):
|
||||
await manager._transcode_to_pcm("https://example.com/source.mp3")
|
||||
|
||||
assert any("FFmpeg failed" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcode_to_pcm_times_out_and_kills_process(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Kill ffmpeg when the transcode times out."""
|
||||
|
||||
manager = await async_get_audio_manager(hass)
|
||||
fake_process = _FakeProcess(stdout=b"\x00\x00", stderr=b"", returncode=0)
|
||||
caplog.set_level(logging.WARNING)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager",
|
||||
return_value=SimpleNamespace(binary="ffmpeg"),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=fake_process),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.asyncio.wait_for",
|
||||
side_effect=TimeoutError,
|
||||
),
|
||||
):
|
||||
pcm, duration, truncated = await manager._transcode_to_pcm(
|
||||
"https://example.com/source.mp3"
|
||||
)
|
||||
|
||||
assert fake_process.killed is True
|
||||
assert pcm == b"\x00\x00"
|
||||
assert duration == pytest.approx(1 / PCM_SAMPLE_RATE)
|
||||
assert truncated is False
|
||||
assert any("FFmpeg timed out" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcode_to_pcm_returns_empty_audio(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Return empty payload when ffmpeg produced nothing."""
|
||||
|
||||
manager = await async_get_audio_manager(hass)
|
||||
fake_process = _FakeProcess(stdout=b"", stderr=b"", returncode=0)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager",
|
||||
return_value=SimpleNamespace(binary="ffmpeg"),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=fake_process),
|
||||
) as mock_exec,
|
||||
):
|
||||
pcm, duration, truncated = await manager._transcode_to_pcm(
|
||||
"https://example.com/source.mp3"
|
||||
)
|
||||
|
||||
assert pcm == b""
|
||||
assert duration == 0.0
|
||||
assert truncated is False
|
||||
mock_exec.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcode_to_pcm_enforces_duration_cap(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Ensure ffmpeg is instructed to limit duration and timeout is enforced."""
|
||||
|
||||
manager = await async_get_audio_manager(hass)
|
||||
pcm_bytes = _build_pcm(duration_seconds=FFMPEG_MAX_DURATION_SECONDS)
|
||||
fake_process = _FakeProcess(stdout=pcm_bytes, stderr=b"", returncode=0)
|
||||
|
||||
timeouts: list[float] = []
|
||||
original_wait_for = asyncio.wait_for
|
||||
|
||||
async def _wait_for(awaitable, timeout):
|
||||
timeouts.append(timeout)
|
||||
return await original_wait_for(awaitable, timeout)
|
||||
|
||||
mock_exec = AsyncMock(return_value=fake_process)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager",
|
||||
return_value=SimpleNamespace(binary="ffmpeg"),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec",
|
||||
mock_exec,
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.asyncio.wait_for",
|
||||
new=_wait_for,
|
||||
),
|
||||
):
|
||||
pcm, duration, truncated = await manager._transcode_to_pcm(
|
||||
"https://example.com/source.mp3"
|
||||
)
|
||||
|
||||
command = list(mock_exec.await_args.args)
|
||||
assert "-t" in command
|
||||
assert command[command.index("-t") + 1] == str(FFMPEG_MAX_DURATION_SECONDS)
|
||||
assert timeouts == [TRANSCODE_TIMEOUT_SECONDS]
|
||||
assert pcm == pcm_bytes
|
||||
assert duration == pytest.approx(FFMPEG_MAX_DURATION_SECONDS)
|
||||
assert truncated is True
|
||||
|
||||
|
||||
async def test_transcode_to_pcm_logs_misaligned_pcm(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Log debug output when ffmpeg output contains a partial frame."""
|
||||
|
||||
manager = await async_get_audio_manager(hass)
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
pcm_bytes = _build_pcm() + b"\xaa"
|
||||
fake_process = _FakeProcess(stdout=pcm_bytes, stderr=b"", returncode=0)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager",
|
||||
return_value=SimpleNamespace(binary="ffmpeg"),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=fake_process),
|
||||
),
|
||||
):
|
||||
pcm, duration, truncated = await manager._transcode_to_pcm(
|
||||
"https://example.com/source.mp3"
|
||||
)
|
||||
|
||||
assert pcm == _build_pcm()
|
||||
assert duration > 0
|
||||
assert truncated is False
|
||||
assert any("misaligned PCM" in record.message for record in caplog.records)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_transcode_to_pcm_drops_partial_frame_payload(
|
||||
hass: HomeAssistant,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""Drop audio entirely when ffmpeg returns fewer bytes than a full frame."""
|
||||
|
||||
manager = await async_get_audio_manager(hass)
|
||||
caplog.set_level(logging.DEBUG)
|
||||
|
||||
fake_process = _FakeProcess(stdout=b"\x00", stderr=b"", returncode=0)
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.ffmpeg.get_ffmpeg_manager",
|
||||
return_value=SimpleNamespace(binary="ffmpeg"),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.audio.asyncio.create_subprocess_exec",
|
||||
AsyncMock(return_value=fake_process),
|
||||
),
|
||||
):
|
||||
pcm, duration, truncated = await manager._transcode_to_pcm(
|
||||
"https://example.com/source.mp3"
|
||||
)
|
||||
|
||||
assert pcm == b""
|
||||
assert duration == 0.0
|
||||
assert truncated is False
|
||||
assert any("misaligned PCM" in record.message for record in caplog.records)
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Test for the SmartThings media player platform."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from pysmartthings import Attribute, Capability, Command, Status
|
||||
from pysmartthings.models import HealthStatus
|
||||
@@ -9,14 +10,19 @@ from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.media_player import (
|
||||
ATTR_INPUT_SOURCE,
|
||||
ATTR_MEDIA_CONTENT_ID,
|
||||
ATTR_MEDIA_CONTENT_TYPE,
|
||||
ATTR_MEDIA_REPEAT,
|
||||
ATTR_MEDIA_SHUFFLE,
|
||||
ATTR_MEDIA_VOLUME_LEVEL,
|
||||
ATTR_MEDIA_VOLUME_MUTED,
|
||||
DOMAIN as MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
SERVICE_SELECT_SOURCE,
|
||||
MediaType,
|
||||
RepeatMode,
|
||||
)
|
||||
from homeassistant.components.smartthings.audio import SmartThingsAudioError
|
||||
from homeassistant.components.smartthings.const import MAIN
|
||||
from homeassistant.const import (
|
||||
ATTR_ENTITY_ID,
|
||||
@@ -39,6 +45,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_registry as er
|
||||
|
||||
from . import (
|
||||
@@ -198,6 +205,176 @@ async def test_volume_down(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"])
|
||||
async def test_play_media_notification(
|
||||
hass: HomeAssistant,
|
||||
devices: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Test playing media via SmartThings audio notification."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
manager = AsyncMock()
|
||||
manager.async_prepare_notification.return_value = "https://example.com/audio.pcm"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.smartthings.media_player.async_get_audio_manager",
|
||||
AsyncMock(return_value=manager),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.media_player.async_process_play_media_url",
|
||||
return_value="https://example.com/source.mp3",
|
||||
),
|
||||
):
|
||||
await hass.services.async_call(
|
||||
MEDIA_PLAYER_DOMAIN,
|
||||
SERVICE_PLAY_MEDIA,
|
||||
{
|
||||
ATTR_ENTITY_ID: "media_player.soundbar",
|
||||
ATTR_MEDIA_CONTENT_TYPE: MediaType.MUSIC,
|
||||
ATTR_MEDIA_CONTENT_ID: "https://example.com/source.mp3",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
expected_command = Command("playTrackAndResume")
|
||||
devices.execute_device_command.assert_called_once_with(
|
||||
"afcf3b91-0000-1111-2222-ddff2a0a6577",
|
||||
Capability.AUDIO_NOTIFICATION,
|
||||
expected_command,
|
||||
MAIN,
|
||||
argument=["https://example.com/audio.pcm"],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"])
|
||||
async def test_play_media_requires_audio_notification_capability(
|
||||
hass: HomeAssistant,
|
||||
devices: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Expect an error if the device lacks audio notification support."""
|
||||
|
||||
devices.get_device_status.return_value[MAIN].pop(
|
||||
Capability.AUDIO_NOTIFICATION, None
|
||||
)
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
entity = hass.data["entity_components"][MEDIA_PLAYER_DOMAIN].get_entity(
|
||||
"media_player.soundbar"
|
||||
)
|
||||
assert entity is not None
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError, match="Device does not support audio notifications"
|
||||
):
|
||||
await entity.async_play_media(MediaType.MUSIC, "https://example.com/source.mp3")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"])
|
||||
async def test_play_media_rejects_unsupported_media_type(
|
||||
hass: HomeAssistant,
|
||||
devices: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Unsupported media types should raise an error."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
entity = hass.data["entity_components"][MEDIA_PLAYER_DOMAIN].get_entity(
|
||||
"media_player.soundbar"
|
||||
)
|
||||
assert entity is not None
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError, match="Unsupported media type for SmartThings audio"
|
||||
):
|
||||
await entity.async_play_media(
|
||||
MediaType.TVSHOW, "https://example.com/source.mp3"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"])
|
||||
async def test_play_media_uses_media_source_resolution(
|
||||
hass: HomeAssistant,
|
||||
devices: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""Media source IDs are resolved and processed before playback."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
manager = AsyncMock()
|
||||
manager.async_prepare_notification.return_value = "https://example.com/audio.pcm"
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.smartthings.media_player.async_get_audio_manager",
|
||||
AsyncMock(return_value=manager),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.media_player.async_process_play_media_url",
|
||||
return_value="https://example.com/processed.mp3",
|
||||
) as mock_process,
|
||||
patch(
|
||||
"homeassistant.components.smartthings.media_player.media_source.is_media_source_id",
|
||||
return_value=True,
|
||||
) as mock_is_media,
|
||||
patch(
|
||||
"homeassistant.components.smartthings.media_player.media_source.async_resolve_media",
|
||||
AsyncMock(
|
||||
return_value=SimpleNamespace(url="https://example.com/from_source")
|
||||
),
|
||||
) as mock_resolve,
|
||||
):
|
||||
entity = hass.data["entity_components"][MEDIA_PLAYER_DOMAIN].get_entity(
|
||||
"media_player.soundbar"
|
||||
)
|
||||
assert entity is not None
|
||||
|
||||
await entity.async_play_media(MediaType.MUSIC, "media-source://foo")
|
||||
|
||||
mock_is_media.assert_called_once()
|
||||
mock_resolve.assert_called_once()
|
||||
mock_process.assert_called_with(hass, "https://example.com/from_source")
|
||||
devices.execute_device_command.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"])
|
||||
async def test_play_media_wraps_audio_errors(
|
||||
hass: HomeAssistant,
|
||||
devices: AsyncMock,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
) -> None:
|
||||
"""SmartThings audio errors propagate as HomeAssistantError."""
|
||||
|
||||
await setup_integration(hass, mock_config_entry)
|
||||
|
||||
manager = AsyncMock()
|
||||
manager.async_prepare_notification.side_effect = SmartThingsAudioError("boom")
|
||||
|
||||
entity = hass.data["entity_components"][MEDIA_PLAYER_DOMAIN].get_entity(
|
||||
"media_player.soundbar"
|
||||
)
|
||||
assert entity is not None
|
||||
|
||||
with (
|
||||
patch(
|
||||
"homeassistant.components.smartthings.media_player.async_get_audio_manager",
|
||||
AsyncMock(return_value=manager),
|
||||
),
|
||||
patch(
|
||||
"homeassistant.components.smartthings.media_player.async_process_play_media_url",
|
||||
return_value="https://example.com/source.mp3",
|
||||
),
|
||||
pytest.raises(HomeAssistantError, match="boom"),
|
||||
):
|
||||
await entity.async_play_media(MediaType.MUSIC, "https://example.com/source.mp3")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("device_fixture", ["hw_q80r_soundbar"])
|
||||
async def test_media_play(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
|
||||
@@ -70,7 +69,6 @@
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
|
||||
@@ -120,7 +118,6 @@
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
|
||||
@@ -170,7 +167,6 @@
|
||||
'labels': set({
|
||||
}),
|
||||
'name': None,
|
||||
'object_id_base': None,
|
||||
'options': dict({
|
||||
}),
|
||||
'original_device_class': <ButtonDeviceClass.IDENTIFY: 'identify'>,
|
||||
|
||||
@@ -443,37 +443,6 @@ async def test_migrate_from_future_version_fails(hass: HomeAssistant) -> None:
|
||||
assert entry.version == 3 # Version should remain unchanged
|
||||
|
||||
|
||||
async def test_oauth_implementation_not_available(hass: HomeAssistant) -> None:
|
||||
"""Test that missing OAuth implementation triggers reauth."""
|
||||
mock_entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
version=2,
|
||||
unique_id=UNIQUE_ID,
|
||||
data={
|
||||
"auth_implementation": DOMAIN,
|
||||
"token": {
|
||||
"access_token": "test_access_token",
|
||||
"refresh_token": "test_refresh_token",
|
||||
"expires_at": int(time.time()) + 3600,
|
||||
},
|
||||
},
|
||||
)
|
||||
mock_entry.add_to_hass(hass)
|
||||
|
||||
# Mock the implementation lookup to raise ValueError
|
||||
with patch(
|
||||
"homeassistant.components.teslemetry.async_get_config_entry_implementation",
|
||||
side_effect=ValueError("Implementation not available"),
|
||||
):
|
||||
await hass.config_entries.async_setup(mock_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entry = hass.config_entries.async_get_entry(mock_entry.entry_id)
|
||||
assert entry is not None
|
||||
# Should trigger reauth, not just fail silently
|
||||
assert entry.state is ConfigEntryState.SETUP_ERROR
|
||||
|
||||
|
||||
RETRY_EXCEPTIONS = [
|
||||
(RateLimited(data={"after": 5}), 5.0),
|
||||
(InvalidResponse(), 10.0),
|
||||
|
||||
@@ -83,13 +83,13 @@
|
||||
'capabilities': dict({
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_modes': list([
|
||||
'auto',
|
||||
'manual',
|
||||
'off',
|
||||
]),
|
||||
'target_temp_step': 1.0,
|
||||
@@ -130,13 +130,13 @@
|
||||
'friendly_name': 'Anbau',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_modes': list([
|
||||
'auto',
|
||||
'manual',
|
||||
'off',
|
||||
]),
|
||||
'supported_features': <ClimateEntityFeature: 17>,
|
||||
@@ -159,13 +159,13 @@
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
]),
|
||||
'max_temp': 70.0,
|
||||
'min_temp': 1.0,
|
||||
'preset_modes': list([
|
||||
'holiday',
|
||||
'auto',
|
||||
'manual',
|
||||
'eco',
|
||||
]),
|
||||
'target_temp_step': 0.5,
|
||||
@@ -208,14 +208,14 @@
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
]),
|
||||
'max_temp': 70.0,
|
||||
'min_temp': 1.0,
|
||||
'preset_mode': None,
|
||||
'preset_modes': list([
|
||||
'holiday',
|
||||
'auto',
|
||||
'manual',
|
||||
'eco',
|
||||
]),
|
||||
'supported_features': <ClimateEntityFeature: 17>,
|
||||
@@ -453,13 +453,13 @@
|
||||
'capabilities': dict({
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_modes': list([
|
||||
'auto',
|
||||
'manual',
|
||||
'off',
|
||||
]),
|
||||
'target_temp_step': 1.0,
|
||||
@@ -501,14 +501,14 @@
|
||||
'friendly_name': 'Empore',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': None,
|
||||
'preset_modes': list([
|
||||
'auto',
|
||||
'manual',
|
||||
'off',
|
||||
]),
|
||||
'supported_features': <ClimateEntityFeature: 17>,
|
||||
@@ -532,12 +532,12 @@
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_modes': list([
|
||||
'auto',
|
||||
'manual',
|
||||
'off',
|
||||
]),
|
||||
'target_temp_step': 1.0,
|
||||
@@ -580,13 +580,13 @@
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': None,
|
||||
'preset_modes': list([
|
||||
'auto',
|
||||
'manual',
|
||||
'off',
|
||||
]),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
@@ -1107,12 +1107,12 @@
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
]),
|
||||
'max_temp': 5.9,
|
||||
'min_temp': 0.1,
|
||||
'preset_modes': list([
|
||||
'auto',
|
||||
'manual',
|
||||
'holiday',
|
||||
]),
|
||||
'target_temp_step': 0.5,
|
||||
@@ -1155,13 +1155,13 @@
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
]),
|
||||
'max_temp': 5.9,
|
||||
'min_temp': 0.1,
|
||||
'preset_mode': None,
|
||||
'preset_modes': list([
|
||||
'auto',
|
||||
'manual',
|
||||
'holiday',
|
||||
]),
|
||||
'supported_features': <ClimateEntityFeature: 17>,
|
||||
@@ -1185,13 +1185,10 @@
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
]),
|
||||
'max_temp': 90.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_modes': list([
|
||||
'auto',
|
||||
'manual',
|
||||
]),
|
||||
'target_temp_step': 1.0,
|
||||
}),
|
||||
'config_entry_id': <ANY>,
|
||||
@@ -1218,7 +1215,7 @@
|
||||
'platform': 'tuya',
|
||||
'previous_unique_id': None,
|
||||
'suggested_object_id': None,
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'supported_features': <ClimateEntityFeature: 385>,
|
||||
'translation_key': None,
|
||||
'unique_id': 'tuya.sb3zdertrw50bgogkw',
|
||||
'unit_of_measurement': None,
|
||||
@@ -1232,15 +1229,11 @@
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
<HVACMode.HEAT_COOL: 'heat_cool'>,
|
||||
]),
|
||||
'max_temp': 90.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': None,
|
||||
'preset_modes': list([
|
||||
'auto',
|
||||
'manual',
|
||||
]),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'supported_features': <ClimateEntityFeature: 385>,
|
||||
'target_temp_step': 1.0,
|
||||
'temperature': 12.0,
|
||||
}),
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
from uiprotect.data import Camera, Chime, Doorlock, IRLEDMode, Light, RingSetting
|
||||
from uiprotect.data import Camera, Doorlock, IRLEDMode, Light
|
||||
|
||||
from homeassistant.components.unifiprotect.const import DEFAULT_ATTRIBUTION
|
||||
from homeassistant.components.unifiprotect.number import (
|
||||
@@ -264,140 +264,3 @@ async def test_number_lock_auto_close(
|
||||
)
|
||||
|
||||
mock_method.assert_called_once_with(timedelta(seconds=15.0))
|
||||
|
||||
|
||||
def _setup_chime_with_doorbell(
|
||||
chime: Chime, doorbell: Camera, volume: int = 50
|
||||
) -> None:
|
||||
"""Set up chime with paired doorbell for testing."""
|
||||
chime.camera_ids = [doorbell.id]
|
||||
chime.ring_settings = [
|
||||
RingSetting(
|
||||
camera_id=doorbell.id,
|
||||
repeat_times=1,
|
||||
ringtone_id="test-ringtone-id",
|
||||
volume=volume,
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
async def test_chime_ring_volume_setup(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
ufp: MockUFPFixture,
|
||||
chime: Chime,
|
||||
doorbell: Camera,
|
||||
) -> None:
|
||||
"""Test chime ring volume number entity setup."""
|
||||
_setup_chime_with_doorbell(chime, doorbell, volume=75)
|
||||
|
||||
await init_entry(hass, ufp, [chime, doorbell], regenerate_ids=False)
|
||||
|
||||
entity_id = "number.test_chime_ring_volume_test_camera"
|
||||
entity = entity_registry.async_get(entity_id)
|
||||
assert entity is not None
|
||||
assert entity.unique_id == f"{chime.mac}_ring_volume_{doorbell.id}"
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state is not None
|
||||
assert state.state == "75"
|
||||
assert state.attributes[ATTR_ATTRIBUTION] == DEFAULT_ATTRIBUTION
|
||||
|
||||
|
||||
async def test_chime_ring_volume_set_value(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
chime: Chime,
|
||||
doorbell: Camera,
|
||||
) -> None:
|
||||
"""Test setting chime ring volume."""
|
||||
_setup_chime_with_doorbell(chime, doorbell)
|
||||
|
||||
await init_entry(hass, ufp, [chime, doorbell], regenerate_ids=False)
|
||||
|
||||
entity_id = "number.test_chime_ring_volume_test_camera"
|
||||
|
||||
with patch_ufp_method(
|
||||
chime, "set_volume_for_camera_public", new_callable=AsyncMock
|
||||
) as mock_method:
|
||||
await hass.services.async_call(
|
||||
"number",
|
||||
"set_value",
|
||||
{ATTR_ENTITY_ID: entity_id, "value": 80.0},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_method.assert_called_once_with(doorbell, 80)
|
||||
|
||||
|
||||
async def test_chime_ring_volume_multiple_cameras(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
chime: Chime,
|
||||
doorbell: Camera,
|
||||
) -> None:
|
||||
"""Test chime ring volume with multiple paired cameras."""
|
||||
doorbell2 = doorbell.model_copy()
|
||||
doorbell2.id = "test-doorbell-2"
|
||||
doorbell2.name = "Test Doorbell 2"
|
||||
doorbell2.mac = "aa:bb:cc:dd:ee:02"
|
||||
|
||||
chime.camera_ids = [doorbell.id, doorbell2.id]
|
||||
chime.ring_settings = [
|
||||
RingSetting(
|
||||
camera_id=doorbell.id,
|
||||
repeat_times=1,
|
||||
ringtone_id="test-ringtone-id",
|
||||
volume=60,
|
||||
),
|
||||
RingSetting(
|
||||
camera_id=doorbell2.id,
|
||||
repeat_times=2,
|
||||
ringtone_id="test-ringtone-id-2",
|
||||
volume=80,
|
||||
),
|
||||
]
|
||||
|
||||
await init_entry(hass, ufp, [chime, doorbell, doorbell2], regenerate_ids=False)
|
||||
|
||||
state1 = hass.states.get("number.test_chime_ring_volume_test_camera")
|
||||
assert state1 is not None
|
||||
assert state1.state == "60"
|
||||
|
||||
state2 = hass.states.get("number.test_chime_ring_volume_test_doorbell_2")
|
||||
assert state2 is not None
|
||||
assert state2.state == "80"
|
||||
|
||||
|
||||
async def test_chime_ring_volume_unavailable_when_unpaired(
|
||||
hass: HomeAssistant,
|
||||
ufp: MockUFPFixture,
|
||||
chime: Chime,
|
||||
doorbell: Camera,
|
||||
) -> None:
|
||||
"""Test chime ring volume becomes unavailable when camera is unpaired."""
|
||||
_setup_chime_with_doorbell(chime, doorbell)
|
||||
|
||||
await init_entry(hass, ufp, [chime, doorbell], regenerate_ids=False)
|
||||
|
||||
entity_id = "number.test_chime_ring_volume_test_camera"
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == "50"
|
||||
|
||||
# Simulate removing the camera pairing
|
||||
new_chime = chime.model_copy()
|
||||
new_chime.ring_settings = []
|
||||
|
||||
ufp.api.bootstrap.chimes = {new_chime.id: new_chime}
|
||||
ufp.api.bootstrap.nvr.system_info.ustorage = None
|
||||
mock_msg = Mock()
|
||||
mock_msg.changed_data = {}
|
||||
mock_msg.new_obj = new_chime
|
||||
|
||||
ufp.ws_msg(mock_msg)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state
|
||||
assert state.state == "unavailable"
|
||||
|
||||
Reference in New Issue
Block a user