Compare commits

..

1 Commits

Author SHA1 Message Date
abmantis c2f11d25e1 Add button platform to Edifier Infrared 2026-06-16 19:26:27 +01:00
17 changed files with 608 additions and 167 deletions
+6 -6
View File
@@ -30,7 +30,7 @@ from homeassistant.exceptions import (
from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADDITIONAL_SETTINGS
from .const import DEFAULT_SSL, DEFAULT_VERIFY_SSL, DOMAIN, SECTION_ADVANCED_SETTINGS
from .coordinator import (
AirOSConfigEntry,
AirOSDataUpdateCoordinator,
@@ -55,14 +55,14 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
# By default airOS 8 comes with self-signed SSL certificates,
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(
hass, verify_ssl=entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL]
hass, verify_ssl=entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL]
)
conn_data = {
CONF_HOST: entry.data[CONF_HOST],
CONF_USERNAME: entry.data[CONF_USERNAME],
CONF_PASSWORD: entry.data[CONF_PASSWORD],
"use_ssl": entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL],
"use_ssl": entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
"session": session,
}
@@ -116,15 +116,15 @@ async def async_setup_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> boo
async def async_migrate_entry(hass: HomeAssistant, entry: AirOSConfigEntry) -> bool:
"""Migrate old config entry."""
# 1.1 Migrate config_entry to add additional ssl settings
# 1.1 Migrate config_entry to add advanced ssl settings
if entry.version == 1 and entry.minor_version == 1:
new_minor_version = 2
new_data = {**entry.data}
additional_data = {
advanced_data = {
CONF_SSL: DEFAULT_SSL,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
}
new_data[SECTION_ADDITIONAL_SETTINGS] = additional_data
new_data[SECTION_ADVANCED_SETTINGS] = advanced_data
hass.config_entries.async_update_entry(
entry,
@@ -52,7 +52,7 @@ from .const import (
HOSTNAME,
IP_ADDRESS,
MAC_ADDRESS,
SECTION_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS,
)
_LOGGER = logging.getLogger(__name__)
@@ -66,7 +66,7 @@ STEP_DISCOVERY_DATA_SCHEMA = vol.Schema(
{
vol.Required(CONF_USERNAME, default=DEFAULT_USERNAME): str,
vol.Required(CONF_PASSWORD): str,
vol.Required(SECTION_ADDITIONAL_SETTINGS): section(
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Required(CONF_SSL, default=DEFAULT_SSL): bool,
@@ -134,7 +134,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
# with no option in the web UI to change or upload a custom certificate.
session = async_get_clientsession(
self.hass,
verify_ssl=config_data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL],
verify_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL],
)
try:
@@ -143,7 +143,7 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
username=config_data[CONF_USERNAME],
password=config_data[CONF_PASSWORD],
session=session,
use_ssl=config_data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL],
use_ssl=config_data[SECTION_ADVANCED_SETTINGS][CONF_SSL],
)
except (
@@ -234,18 +234,18 @@ class AirOSConfigFlow(ConfigFlow, domain=DOMAIN):
autocomplete="current-password",
)
),
vol.Required(SECTION_ADDITIONAL_SETTINGS): section(
vol.Required(SECTION_ADVANCED_SETTINGS): section(
vol.Schema(
{
vol.Required(
CONF_SSL,
default=current_data[SECTION_ADDITIONAL_SETTINGS][
default=current_data[SECTION_ADVANCED_SETTINGS][
CONF_SSL
],
): bool,
vol.Required(
CONF_VERIFY_SSL,
default=current_data[SECTION_ADDITIONAL_SETTINGS][
default=current_data[SECTION_ADVANCED_SETTINGS][
CONF_VERIFY_SSL
],
): bool,
+1 -1
View File
@@ -12,7 +12,7 @@ MANUFACTURER = "Ubiquiti"
DEFAULT_VERIFY_SSL = False
DEFAULT_SSL = True
SECTION_ADDITIONAL_SETTINGS = "additional_settings"
SECTION_ADVANCED_SETTINGS = "advanced_settings"
# Discovery related
DEFAULT_USERNAME = "ubnt"
+2 -2
View File
@@ -4,7 +4,7 @@ from homeassistant.const import CONF_HOST, CONF_SSL
from homeassistant.helpers.device_registry import CONNECTION_NETWORK_MAC, DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import DOMAIN, MANUFACTURER, SECTION_ADDITIONAL_SETTINGS
from .const import DOMAIN, MANUFACTURER, SECTION_ADVANCED_SETTINGS
from .coordinator import AirOSDataUpdateCoordinator
@@ -20,7 +20,7 @@ class AirOSEntity(CoordinatorEntity[AirOSDataUpdateCoordinator]):
airos_data = self.coordinator.data
url_schema = (
"https"
if coordinator.config_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL]
if coordinator.config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
else "http"
)
+12 -12
View File
@@ -33,16 +33,16 @@
},
"description": "Enter the username and password for {device_name}",
"sections": {
"additional_settings": {
"advanced_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data::ssl%]",
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::verify_ssl%]"
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::manual::sections::additional_settings::name%]"
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
}
}
},
@@ -58,7 +58,7 @@
"username": "Administrator username for the airOS device, normally 'ubnt'"
},
"sections": {
"additional_settings": {
"advanced_settings": {
"data": {
"ssl": "Use HTTPS",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
@@ -67,7 +67,7 @@
"ssl": "Whether the connection should be encrypted (required for most devices)",
"verify_ssl": "Whether the certificate should be verified when using HTTPS. This should be off for self-signed certificates"
},
"name": "Additional settings"
"name": "Advanced settings"
}
}
},
@@ -87,16 +87,16 @@
"password": "[%key:component::airos::config::step::manual::data_description::password%]"
},
"sections": {
"additional_settings": {
"advanced_settings": {
"data": {
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data::ssl%]",
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data::ssl%]",
"verify_ssl": "[%key:common::config_flow::data::verify_ssl%]"
},
"data_description": {
"ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::additional_settings::data_description::verify_ssl%]"
"ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::ssl%]",
"verify_ssl": "[%key:component::airos::config::step::manual::sections::advanced_settings::data_description::verify_ssl%]"
},
"name": "[%key:component::airos::config::step::manual::sections::additional_settings::name%]"
"name": "[%key:component::airos::config::step::manual::sections::advanced_settings::name%]"
}
}
},
@@ -4,7 +4,7 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.MEDIA_PLAYER]
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
@@ -0,0 +1,180 @@
"""Button platform for Edifier infrared integration."""
from dataclasses import dataclass
from infrared_protocols.codes.edifier.models import EdifierCommandSet, EdifierModel
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
from homeassistant.components.infrared import InfraredEmitterConsumerEntity
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import CONF_COMMAND_SET, CONF_INFRARED_ENTITY_ID, EdifierCode
from .entity import EdifierIrEntity
PARALLEL_UPDATES = 1
@dataclass(frozen=True, kw_only=True)
class EdifierIrButtonEntityDescription(ButtonEntityDescription):
"""Describes Edifier IR button entity."""
command_code: EdifierCode
COMMAND_SET_BUTTONS: dict[
EdifierCommandSet,
tuple[EdifierIrButtonEntityDescription, ...],
] = {
EdifierCommandSet.R1700BT: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierR1700BTCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="line_1",
translation_key="line_1",
command_code=EdifierR1700BTCode.LINE_1,
),
EdifierIrButtonEntityDescription(
key="line_2",
translation_key="line_2",
command_code=EdifierR1700BTCode.LINE_2,
),
EdifierIrButtonEntityDescription(
key="fx_on",
translation_key="fx_on",
command_code=EdifierR1700BTCode.FX_ON,
),
EdifierIrButtonEntityDescription(
key="fx_off",
translation_key="fx_off",
command_code=EdifierR1700BTCode.FX_OFF,
),
),
EdifierCommandSet.R1280DB: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierR1280DBCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="line_1",
translation_key="line_1",
command_code=EdifierR1280DBCode.LINE_1,
),
EdifierIrButtonEntityDescription(
key="line_2",
translation_key="line_2",
command_code=EdifierR1280DBCode.LINE_2,
),
EdifierIrButtonEntityDescription(
key="optical",
translation_key="optical",
command_code=EdifierR1280DBCode.OPTICAL,
),
EdifierIrButtonEntityDescription(
key="coax",
translation_key="coax",
command_code=EdifierR1280DBCode.COAX,
),
),
EdifierCommandSet.S360DB: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierS360DBCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="optical",
translation_key="optical",
command_code=EdifierS360DBCode.OPTICAL,
),
EdifierIrButtonEntityDescription(
key="coax",
translation_key="coax",
command_code=EdifierS360DBCode.COAX,
),
EdifierIrButtonEntityDescription(
key="pc",
translation_key="pc",
command_code=EdifierS360DBCode.PC,
),
EdifierIrButtonEntityDescription(
key="aux",
translation_key="aux",
command_code=EdifierS360DBCode.AUX,
),
),
EdifierCommandSet.RC20G: (
EdifierIrButtonEntityDescription(
key="bluetooth",
translation_key="bluetooth",
command_code=EdifierRC20GCode.BLUETOOTH,
),
EdifierIrButtonEntityDescription(
key="pc",
translation_key="pc",
command_code=EdifierRC20GCode.PC,
),
EdifierIrButtonEntityDescription(
key="aux",
translation_key="aux",
command_code=EdifierRC20GCode.AUX,
),
EdifierIrButtonEntityDescription(
key="optical",
translation_key="optical",
command_code=EdifierRC20GCode.OPTICAL,
),
EdifierIrButtonEntityDescription(
key="coax",
translation_key="coax",
command_code=EdifierRC20GCode.COAX,
),
),
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Edifier IR buttons from a config entry."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
command_set = EdifierCommandSet(entry.data[CONF_COMMAND_SET])
model = EdifierModel(entry.data[CONF_MODEL])
async_add_entities(
EdifierIrButton(entry, model, infrared_entity_id, description)
for description in COMMAND_SET_BUTTONS.get(command_set, ())
)
class EdifierIrButton(EdifierIrEntity, InfraredEmitterConsumerEntity, ButtonEntity):
"""Edifier IR button entity."""
entity_description: EdifierIrButtonEntityDescription
def __init__(
self,
entry: ConfigEntry,
model: EdifierModel,
infrared_entity_id: str,
description: EdifierIrButtonEntityDescription,
) -> None:
"""Initialize Edifier IR button."""
super().__init__(entry, model, unique_id_suffix=description.key)
self._infrared_emitter_entity_id = infrared_entity_id
self.entity_description = description
async def async_press(self) -> None:
"""Press the button."""
await self._send_command(self.entity_description.command_code.to_command())
@@ -18,5 +18,36 @@
"title": "Set up Edifier IR speaker"
}
}
},
"entity": {
"button": {
"aux": {
"name": "AUX"
},
"bluetooth": {
"name": "Bluetooth"
},
"coax": {
"name": "Coaxial"
},
"fx_off": {
"name": "FX off"
},
"fx_on": {
"name": "FX on"
},
"line_1": {
"name": "Line 1"
},
"line_2": {
"name": "Line 2"
},
"optical": {
"name": "Optical"
},
"pc": {
"name": "PC"
}
}
}
}
+15 -19
View File
@@ -308,17 +308,17 @@ class Events(Base):
def from_event(event: Event) -> Events:
"""Create an event database object from a native event."""
context = event.context
# The unused legacy columns (event_type, event_data, time_fired,
# context_id, context_user_id, context_parent_id) are nullable with no
# default, so they are intentionally left unset here. Assigning them
# None would still insert NULL, but each assignment goes through
# SQLAlchemy's instrumented attribute machinery, which is a measurable
# cost when run for every recorded event.
return Events(
event_type=None,
event_data=None,
origin_idx=event.origin.idx,
time_fired=None,
time_fired_ts=event.time_fired_timestamp,
context_id=None,
context_id_bin=ulid_to_bytes_or_none(context.id),
context_user_id=None,
context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id),
context_parent_id=None,
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
)
@@ -491,18 +491,19 @@ class States(Base):
else:
last_reported_ts = state.last_reported_timestamp
context = event.context
# The unused legacy columns (entity_id, attributes, context_id,
# context_user_id, context_parent_id, last_updated, last_changed) are
# nullable with no default, so they are intentionally left unset here.
# Assigning them None would still insert NULL, but each assignment goes
# through SQLAlchemy's instrumented attribute machinery, which is a
# measurable cost when run for every recorded state change.
return States(
state=state_value,
entity_id=None,
attributes=None,
context_id=None,
context_id_bin=ulid_to_bytes_or_none(context.id),
context_user_id=None,
context_user_id_bin=uuid_hex_to_bytes_or_none(context.user_id),
context_parent_id=None,
context_parent_id_bin=ulid_to_bytes_or_none(context.parent_id),
origin_idx=event.origin.idx,
last_updated=None,
last_changed=None,
last_updated_ts=last_updated_ts,
last_changed_ts=last_changed_ts,
last_reported_ts=last_reported_ts,
@@ -559,13 +560,8 @@ class StateAttributes(Base):
# None state means the state was removed from the state machine
if (state := event.data["new_state"]) is None:
return b"{}"
if (state_info := state.state_info) and (
unrecorded_attributes := state_info["unrecorded_attributes"]
):
# The entity has unrecorded attributes, so a combined exclude set
# has to be built. The common case (no unrecorded attributes) falls
# through to the shared constant below without allocating a set per
# recorded state change.
if state_info := state.state_info:
unrecorded_attributes = state_info["unrecorded_attributes"]
exclude_attrs = {
*ALL_DOMAIN_EXCLUDE_ATTRS,
*unrecorded_attributes,
+6 -1
View File
@@ -10,6 +10,7 @@ from datetime import datetime, time as dt_time, timedelta
import functools as ft
import inspect
import logging
import re
import sys
from typing import (
TYPE_CHECKING,
@@ -145,6 +146,10 @@ _PLATFORM_ALIASES: dict[str | None, str | None] = {
"trigger": None,
}
INPUT_ENTITY_ID = re.compile(
r"^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(?<!_)$"
)
CONDITION_DESCRIPTION_CACHE: HassKey[dict[str, dict[str, Any] | None]] = HassKey(
"condition_description_cache"
@@ -1716,7 +1721,7 @@ def state(
state_value = req_state_value
if (
isinstance(req_state_value, str)
and cv.INPUT_ENTITY_ID.match(req_state_value) is not None
and INPUT_ENTITY_ID.match(req_state_value) is not None
):
if not (state_entity := hass.states.get(req_state_value)):
raise ConditionErrorMessage(
+1 -20
View File
@@ -1549,10 +1549,6 @@ NUMERIC_STATE_CONDITION_SCHEMA = vol.All(
has_at_least_one_key(CONF_BELOW, CONF_ABOVE),
)
INPUT_ENTITY_ID = re.compile(
r"^input_(?:select|text|number|boolean|datetime)\.(?!.+__)(?!_)[\da-z_]+(?<!_)$"
)
STATE_CONDITION_BASE_SCHEMA = {
**CONDITION_BASE_SCHEMA,
vol.Required(CONF_CONDITION): "state",
@@ -1589,22 +1585,7 @@ def STATE_CONDITION_SCHEMA(value: Any) -> dict[str, Any]:
else:
validated = STATE_CONDITION_STATE_SCHEMA(value)
validated = key_dependency("for", "state")(validated)
if CONF_FOR in validated:
# `for` measures how long the entity has held its current state via
# last_changed, so it only supports a single literal state. It cannot
# track an attribute, a list of states, or a state resolved from
# another entity.
if CONF_ATTRIBUTE in validated:
raise vol.Invalid("Cannot use 'for' with an attribute")
state = validated[CONF_STATE]
if isinstance(state, list):
raise vol.Invalid("Cannot use 'for' with a list of states")
if INPUT_ENTITY_ID.match(state):
raise vol.Invalid("Cannot use 'for' with a state referencing an entity")
return validated
return key_dependency("for", "state")(validated)
TEMPLATE_CONDITION_SCHEMA = vol.Schema(
@@ -639,7 +639,7 @@
}),
}),
'entry_data': dict({
'additional_settings': dict({
'advanced_settings': dict({
'ssl': True,
'verify_ssl': False,
}),
+14 -14
View File
@@ -21,7 +21,7 @@ from homeassistant.components.airos.const import (
HOSTNAME,
IP_ADDRESS,
MAC_ADDRESS,
SECTION_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS,
)
from homeassistant.config_entries import (
SOURCE_DHCP,
@@ -48,7 +48,7 @@ NEW_PASSWORD = "new_password"
REAUTH_STEP = "reauth_confirm"
RECONFIGURE_STEP = "reconfigure"
MOCK_ADDITIONAL_SETTINGS = {
MOCK_ADVANCED_SETTINGS = {
CONF_SSL: True,
CONF_VERIFY_SSL: False,
}
@@ -57,7 +57,7 @@ MOCK_CONFIG = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
}
MOCK_CONFIG_REAUTH = {
CONF_HOST: "1.1.1.1",
@@ -410,7 +410,7 @@ async def test_successful_reconfigure(
user_input = {
CONF_PASSWORD: NEW_PASSWORD,
SECTION_ADDITIONAL_SETTINGS: {
SECTION_ADVANCED_SETTINGS: {
CONF_SSL: True,
CONF_VERIFY_SSL: True,
},
@@ -426,8 +426,8 @@ async def test_successful_reconfigure(
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert updated_entry.data[CONF_PASSWORD] == NEW_PASSWORD
assert updated_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL] is True
assert updated_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL] is True
assert updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True
assert updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is True
assert updated_entry.data[CONF_HOST] == MOCK_CONFIG[CONF_HOST]
assert updated_entry.data[CONF_USERNAME] == MOCK_CONFIG[CONF_USERNAME]
@@ -468,7 +468,7 @@ async def test_reconfigure_flow_failure(
user_input = {
CONF_PASSWORD: NEW_PASSWORD,
SECTION_ADDITIONAL_SETTINGS: {
SECTION_ADVANCED_SETTINGS: {
CONF_SSL: True,
CONF_VERIFY_SSL: True,
},
@@ -525,7 +525,7 @@ async def test_reconfigure_unique_id_mismatch(
user_input = {
CONF_PASSWORD: NEW_PASSWORD,
SECTION_ADDITIONAL_SETTINGS: {
SECTION_ADVANCED_SETTINGS: {
CONF_SSL: True,
CONF_VERIFY_SSL: True,
},
@@ -546,8 +546,8 @@ async def test_reconfigure_unique_id_mismatch(
updated_entry = hass.config_entries.async_get_entry(mock_config_entry.entry_id)
assert updated_entry.data[CONF_PASSWORD] == MOCK_CONFIG[CONF_PASSWORD]
assert (
updated_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL]
== MOCK_CONFIG[SECTION_ADDITIONAL_SETTINGS][CONF_SSL]
updated_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL]
== MOCK_CONFIG[SECTION_ADVANCED_SETTINGS][CONF_SSL]
)
@@ -611,7 +611,7 @@ async def test_discover_flow_one_device_found(
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
@@ -687,7 +687,7 @@ async def test_discover_flow_multiple_devices_found(
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "test-password",
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
@@ -785,7 +785,7 @@ async def test_configure_device_flow_exceptions(
{
CONF_USERNAME: "wrong-user",
CONF_PASSWORD: "wrong-password",
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
@@ -801,7 +801,7 @@ async def test_configure_device_flow_exceptions(
{
CONF_USERNAME: DEFAULT_USERNAME,
CONF_PASSWORD: "some-password",
SECTION_ADDITIONAL_SETTINGS: MOCK_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS: MOCK_ADVANCED_SETTINGS,
},
)
+7 -7
View File
@@ -14,7 +14,7 @@ from homeassistant.components.airos.const import (
DEFAULT_SSL,
DEFAULT_VERIFY_SSL,
DOMAIN,
SECTION_ADDITIONAL_SETTINGS,
SECTION_ADVANCED_SETTINGS,
)
from homeassistant.components.airos.coordinator import async_fetch_airos_data
from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN
@@ -46,7 +46,7 @@ MOCK_CONFIG_PLAIN = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "ubnt",
CONF_PASSWORD: "test-password",
SECTION_ADDITIONAL_SETTINGS: {
SECTION_ADVANCED_SETTINGS: {
CONF_SSL: False,
CONF_VERIFY_SSL: False,
},
@@ -56,7 +56,7 @@ MOCK_CONFIG_V1_2 = {
CONF_HOST: "1.1.1.1",
CONF_USERNAME: "ubnt",
CONF_PASSWORD: "test-password",
SECTION_ADDITIONAL_SETTINGS: {
SECTION_ADVANCED_SETTINGS: {
CONF_SSL: DEFAULT_SSL,
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
},
@@ -86,8 +86,8 @@ async def test_setup_entry_with_default_ssl(
use_ssl=DEFAULT_SSL,
)
assert mock_config_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL] is True
assert mock_config_entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL] is False
assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is True
assert mock_config_entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False
async def test_setup_entry_without_ssl(
@@ -120,8 +120,8 @@ async def test_setup_entry_without_ssl(
use_ssl=False,
)
assert entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_SSL] is False
assert entry.data[SECTION_ADDITIONAL_SETTINGS][CONF_VERIFY_SSL] is False
assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_SSL] is False
assert entry.data[SECTION_ADVANCED_SETTINGS][CONF_VERIFY_SSL] is False
async def test_ssl_migrate_entry(
@@ -0,0 +1,251 @@
# serializer version: 1
# name: test_entities[button.edifier_r1700bt_bluetooth-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'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': None,
'entity_id': 'button.edifier_r1700bt_bluetooth',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Bluetooth',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Bluetooth',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'bluetooth',
'unique_id': '01JTEST0000000000000000000_bluetooth',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_bluetooth-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT Bluetooth',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_bluetooth',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.edifier_r1700bt_fx_off-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'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': None,
'entity_id': 'button.edifier_r1700bt_fx_off',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'FX off',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'FX off',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'fx_off',
'unique_id': '01JTEST0000000000000000000_fx_off',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_fx_off-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT FX off',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_fx_off',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.edifier_r1700bt_fx_on-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'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': None,
'entity_id': 'button.edifier_r1700bt_fx_on',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'FX on',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'FX on',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'fx_on',
'unique_id': '01JTEST0000000000000000000_fx_on',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_fx_on-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT FX on',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_fx_on',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.edifier_r1700bt_line_1-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'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': None,
'entity_id': 'button.edifier_r1700bt_line_1',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Line 1',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Line 1',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'line_1',
'unique_id': '01JTEST0000000000000000000_line_1',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_line_1-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT Line 1',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_line_1',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
# name: test_entities[button.edifier_r1700bt_line_2-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'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': None,
'entity_id': 'button.edifier_r1700bt_line_2',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Line 2',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Line 2',
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'line_2',
'unique_id': '01JTEST0000000000000000000_line_2',
'unit_of_measurement': None,
})
# ---
# name: test_entities[button.edifier_r1700bt_line_2-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'friendly_name': 'Edifier R1700BT Line 2',
}),
'context': <ANY>,
'entity_id': 'button.edifier_r1700bt_line_2',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unknown',
})
# ---
@@ -0,0 +1,73 @@
"""Tests for the Edifier Infrared button platform."""
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, SERVICE_PRESS
from homeassistant.const import ATTR_ENTITY_ID, Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry, snapshot_platform
from tests.components.common import assert_availability_follows_source_entity
from tests.components.infrared import EMITTER_ENTITY_ID
from tests.components.infrared.common import MockInfraredEmitterEntity
BLUETOOTH_BUTTON_ENTITY_ID = "button.edifier_r1700bt_bluetooth"
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return [Platform.BUTTON]
@pytest.mark.usefixtures("init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the button entities are created with correct attributes."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("entity_id", "expected_code"),
[
("button.edifier_r1700bt_bluetooth", EdifierR1700BTCode.BLUETOOTH),
("button.edifier_r1700bt_line_1", EdifierR1700BTCode.LINE_1),
("button.edifier_r1700bt_line_2", EdifierR1700BTCode.LINE_2),
("button.edifier_r1700bt_fx_on", EdifierR1700BTCode.FX_ON),
("button.edifier_r1700bt_fx_off", EdifierR1700BTCode.FX_OFF),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_button_press_sends_correct_code(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
entity_id: str,
expected_code: EdifierR1700BTCode,
) -> None:
"""Test each button press sends the correct IR code."""
await hass.services.async_call(
BUTTON_DOMAIN,
SERVICE_PRESS,
{ATTR_ENTITY_ID: entity_id},
blocking=True,
)
assert len(mock_infrared_emitter_entity.send_command_calls) == 1
assert mock_infrared_emitter_entity.send_command_calls[0] == expected_code
@pytest.mark.usefixtures("init_integration")
async def test_button_availability_follows_ir_entity(
hass: HomeAssistant,
) -> None:
"""Test button becomes unavailable when IR entity is unavailable."""
await assert_availability_follows_source_entity(
hass, BLUETOOTH_BUTTON_ENTITY_ID, EMITTER_ENTITY_ID
)
-76
View File
@@ -1462,44 +1462,6 @@ async def test_state_for_invalid_template(
assert not test.async_check()
@pytest.mark.parametrize(
("extra_config", "error"),
[
pytest.param(
{"attribute": "battery_level"},
r"Cannot use 'for' with an attribute",
id="attribute",
),
pytest.param(
{"state": ["100", "200"]},
r"Cannot use 'for' with a list of states",
id="list_of_states",
),
pytest.param(
{"state": "input_number.threshold"},
r"Cannot use 'for' with a state referencing an entity",
id="state_from_entity",
),
],
)
def test_state_for_not_allowed(extra_config: dict[str, Any], error: str) -> None:
"""Test state condition rejects `for` with unsupported `state`/`attribute`.
`for` is anchored to the entity's last_changed, which only advances on state
changes and reflects a single current state. It therefore cannot be combined
with an attribute, a list of states, or a state resolved from another entity.
"""
config = {
"condition": "state",
"entity_id": "sensor.temperature",
"state": "100",
"for": {"seconds": 5},
**extra_config,
}
with pytest.raises(vol.Invalid, match=error):
cv.CONDITION_SCHEMA(config)
async def test_state_unknown_attribute(hass: HomeAssistant) -> None:
"""Test that state returns False on unknown attribute."""
# Unknown attribute
@@ -1532,44 +1494,6 @@ async def test_state_unknown_attribute(hass: HomeAssistant) -> None:
)
@pytest.mark.parametrize(
("req_state", "attribute_value", "expected"),
[
# A list `state` is matched as alternatives, so the attribute value must
# equal one of the items; the list itself is never compared as a whole.
pytest.param(["a", "b"], "a", True, id="item_in_list"),
pytest.param(["a", "b"], ["a", "b"], False, id="list_is_not_an_item"),
# Nesting the list makes the list value itself one of the items to match.
pytest.param([["a", "b"]], ["a", "b"], True, id="list_in_list_of_lists"),
pytest.param([["a", "b"]], "a", False, id="scalar_not_in_list_of_lists"),
],
)
async def test_state_attribute_list_matching(
hass: HomeAssistant,
req_state: list[Any],
attribute_value: str | list[str],
expected: bool,
) -> None:
"""Test how a state-attribute condition matches against a list `state`.
A list `state` is treated as alternatives (match any item), so a list-valued
attribute only matches when the list is nested as an item of `state`. This
documents the current behavior; the implementation is unchanged.
"""
config = {
"condition": "state",
"entity_id": "sensor.test",
"attribute": "options",
"state": req_state,
}
config = cv.CONDITION_SCHEMA(config)
config = await condition.async_validate_condition_config(hass, config)
test = await condition.async_from_config(hass, config)
hass.states.async_set("sensor.test", "on", {"options": attribute_value})
assert test.async_check() is expected
async def test_state_multiple_entities(hass: HomeAssistant) -> None:
"""Test with multiple entities in condition."""
config = {