Compare commits

...

8 Commits

Author SHA1 Message Date
abmantis d2b705a617 Rename var 2026-05-27 13:09:05 +01:00
abmantis 43cb41d396 Fix iot_class 2026-05-27 13:06:18 +01:00
abmantis dba52262f3 Merge branch 'dev' of github.com:home-assistant/core into edifier_ir1 2026-05-27 11:46:37 +01:00
cdnninja efe0000fbe Bump pyvesync to 3.4.2 (#168402) 2026-05-27 12:43:01 +02:00
abmantis c43155ed4b Add Edifier Infrared integration 2026-05-27 11:42:00 +01:00
starkillerOG 98a7cc66ef Reolink battery fast start (#171840) 2026-05-27 12:41:32 +02:00
Erik Montnemery 7feaf71b9e Make TrackerEntity in_zones win over lat/long (#172313) 2026-05-27 11:27:34 +02:00
Erik Montnemery 00a0fae7bc Improve numerical trigger and condition tests (#172308) 2026-05-27 11:23:49 +02:00
35 changed files with 1584 additions and 24 deletions
Generated
+2
View File
@@ -453,6 +453,8 @@ CLAUDE.md @home-assistant/core
/tests/components/ecovacs/ @mib1185 @edenhaus @Augar
/homeassistant/components/ecowitt/ @pvizeli
/tests/components/ecowitt/ @pvizeli
/homeassistant/components/edifier_infrared/ @abmantis
/tests/components/edifier_infrared/ @abmantis
/homeassistant/components/efergy/ @tkdrob
/tests/components/efergy/ @tkdrob
/homeassistant/components/egardia/ @jeroenterheerdt
@@ -221,8 +221,8 @@ class TrackerEntity(
"""Return the entity_id of zones the device is currently in.
The list may be in any order; the base class sorts it by zone radius
and discards zones which do not exist. Ignored if latitude and
longitude are both set.
and discards zones which do not exist. Takes precedence over latitude
and longitude when set (including when set to an empty list).
"""
return self._attr_in_zones
@@ -252,11 +252,7 @@ class TrackerEntity(
@callback
def _async_write_ha_state(self) -> None:
"""Calculate active zones."""
if self.available and self.latitude is not None and self.longitude is not None:
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
elif (zones := self.in_zones) is not None:
if (zones := self.in_zones) is not None:
zone_states = sorted(
(
zone_state
@@ -270,6 +266,12 @@ class TrackerEntity(
None,
)
self.__in_zones = [z.entity_id for z in zone_states]
elif (
self.available and self.latitude is not None and self.longitude is not None
):
self.__active_zone, self.__in_zones = zone.async_in_zones(
self.hass, self.latitude, self.longitude, self.location_accuracy
)
else:
self.__active_zone = None
self.__in_zones = None
@@ -0,0 +1,18 @@
"""Edifier infrared integration for Home Assistant."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
PLATFORMS = [Platform.MEDIA_PLAYER]
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Set up Edifier IR from a config entry."""
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
"""Unload an Edifier IR config entry."""
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
@@ -0,0 +1,82 @@
"""Config flow for Edifier infrared integration."""
from typing import Any
import voluptuous as vol
from homeassistant.components.infrared import (
DOMAIN as INFRARED_DOMAIN,
async_get_emitters,
)
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_MODEL
from homeassistant.helpers.selector import (
EntitySelector,
EntitySelectorConfig,
SelectSelector,
SelectSelectorConfig,
SelectSelectorMode,
)
from .const import (
CONF_COMMAND_SET,
CONF_INFRARED_ENTITY_ID,
DOMAIN,
MODEL_TO_COMMAND_SET,
EdifierModel,
)
class EdifierIrConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle config flow for Edifier IR."""
VERSION = 1
MINOR_VERSION = 1
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step - select IR entity and speaker model."""
emitter_entity_ids = async_get_emitters(self.hass)
if not emitter_entity_ids:
return self.async_abort(reason="no_emitters")
if user_input is not None:
infrared_entity_id = user_input[CONF_INFRARED_ENTITY_ID]
model = EdifierModel(user_input[CONF_MODEL])
command_set = MODEL_TO_COMMAND_SET[model]
await self.async_set_unique_id(f"{command_set}_{infrared_entity_id}")
self._abort_if_unique_id_configured()
entity_name = infrared_entity_id
if state := self.hass.states.get(infrared_entity_id):
entity_name = state.name or infrared_entity_id
return self.async_create_entry(
title=f"Edifier {model.value} via {entity_name}",
data={
CONF_INFRARED_ENTITY_ID: infrared_entity_id,
CONF_MODEL: model.value,
CONF_COMMAND_SET: command_set.value,
},
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
EntitySelectorConfig(
domain=INFRARED_DOMAIN, include_entities=emitter_entity_ids
)
),
vol.Required(CONF_MODEL): SelectSelector(
SelectSelectorConfig(
options=[model.value for model in EdifierModel],
mode=SelectSelectorMode.DROPDOWN,
)
),
}
),
)
@@ -0,0 +1,76 @@
"""Constants for the Edifier infrared integration."""
from enum import StrEnum
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
from infrared_protocols.codes.edifier.s360db import EdifierS360DBCode
DOMAIN = "edifier_infrared"
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
CONF_COMMAND_SET = "command_set"
type EdifierCode = (
EdifierR1700BTCode
| EdifierR1280DBCode
| EdifierR1280TCode
| EdifierS360DBCode
| EdifierRC20GCode
)
class EdifierCommandSets(StrEnum):
"""Edifier command set groupings."""
R1700BT = "r1700bt"
R1280DB = "r1280db"
R1280T = "r1280t"
S360DB = "s360db"
RC20G = "rc20g"
class EdifierModel(StrEnum):
"""Edifier speaker models."""
# R1700BT command set
R1700BT = "R1700BT"
R1700BTS = "R1700BTs"
RC17A = "RC17A"
RC80B = "RC80B"
R1855DB = "R1855DB"
# R1280DB command set
R1280DB = "R1280DB"
R2730DB = "R2730DB"
RC10D1 = "RC10D1"
R2000DB = "R2000DB"
# R1280T command set (basic)
R1280T = "R1280T"
# S360DB command set
S360DB = "S360DB"
RC31A = "RC31A"
# RC20G command set (unique left/right volume controls)
RC20G = "RC20G"
MODEL_TO_COMMAND_SET: dict[EdifierModel, EdifierCommandSets] = {
# R1700BT command set
EdifierModel.R1700BT: EdifierCommandSets.R1700BT,
EdifierModel.R1700BTS: EdifierCommandSets.R1700BT,
EdifierModel.RC17A: EdifierCommandSets.R1700BT,
EdifierModel.RC80B: EdifierCommandSets.R1700BT,
EdifierModel.R1855DB: EdifierCommandSets.R1700BT,
# R1280DB command set
EdifierModel.R1280DB: EdifierCommandSets.R1280DB,
EdifierModel.R2730DB: EdifierCommandSets.R1280DB,
EdifierModel.RC10D1: EdifierCommandSets.R1280DB,
EdifierModel.R2000DB: EdifierCommandSets.R1280DB,
# R1280T command set
EdifierModel.R1280T: EdifierCommandSets.R1280T,
# S360DB command set
EdifierModel.S360DB: EdifierCommandSets.S360DB,
EdifierModel.RC31A: EdifierCommandSets.S360DB,
# RC20G command set
EdifierModel.RC20G: EdifierCommandSets.RC20G,
}
@@ -0,0 +1,25 @@
"""Common entity for Edifier infrared integration."""
from homeassistant.config_entries import ConfigEntry
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import Entity
from .const import DOMAIN, EdifierModel
class EdifierIrEntity(Entity):
"""Edifier IR base entity providing common device info."""
_attr_has_entity_name = True
def __init__(
self, entry: ConfigEntry, model: EdifierModel, unique_id_suffix: str
) -> None:
"""Initialize Edifier IR entity."""
self._attr_unique_id = f"{entry.entry_id}_{unique_id_suffix}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, entry.entry_id)},
name=f"Edifier {model.value}",
manufacturer="Edifier",
model=model.value,
)
@@ -0,0 +1,11 @@
{
"domain": "edifier_infrared",
"name": "Edifier Infrared",
"codeowners": ["@abmantis"],
"config_flow": true,
"dependencies": ["infrared"],
"documentation": "https://www.home-assistant.io/integrations/edifier_infrared",
"integration_type": "device",
"iot_class": "assumed_state",
"quality_scale": "bronze"
}
@@ -0,0 +1,179 @@
"""Media player platform for Edifier infrared integration."""
from infrared_protocols.codes.edifier.r1280db import EdifierR1280DBCode
from infrared_protocols.codes.edifier.r1280t import EdifierR1280TCode
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.infrared import InfraredEmitterConsumerEntity
from homeassistant.components.media_player import (
MediaPlayerDeviceClass,
MediaPlayerEntity,
MediaPlayerEntityFeature,
MediaPlayerState,
)
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,
EdifierCommandSets,
EdifierModel,
)
from .entity import EdifierIrEntity
PARALLEL_UPDATES = 1
COMMAND_SET_COMMANDS: dict[
EdifierCommandSets,
dict[
MediaPlayerEntityFeature,
tuple[EdifierCode | tuple[EdifierCode, ...], ...],
],
] = {
EdifierCommandSets.R1700BT: {
MediaPlayerEntityFeature.TURN_ON: (EdifierR1700BTCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierR1700BTCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierR1700BTCode.VOLUME_UP,),
(EdifierR1700BTCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1700BTCode.MUTE,),
MediaPlayerEntityFeature.PLAY: (EdifierR1700BTCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierR1700BTCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1700BTCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1700BTCode.BACK,),
},
EdifierCommandSets.R1280DB: {
MediaPlayerEntityFeature.TURN_ON: (EdifierR1280DBCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierR1280DBCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierR1280DBCode.VOLUME_UP,),
(EdifierR1280DBCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280DBCode.MUTE,),
MediaPlayerEntityFeature.PLAY: (EdifierR1280DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierR1280DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierR1280DBCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierR1280DBCode.BACK,),
},
EdifierCommandSets.R1280T: {
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierR1280TCode.VOLUME_UP,),
(EdifierR1280TCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierR1280TCode.MUTE,),
},
EdifierCommandSets.S360DB: {
MediaPlayerEntityFeature.TURN_ON: (EdifierS360DBCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierS360DBCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierS360DBCode.VOLUME_UP,),
(EdifierS360DBCode.VOLUME_DOWN,),
),
MediaPlayerEntityFeature.PLAY: (EdifierS360DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierS360DBCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierS360DBCode.NEXT,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierS360DBCode.PREVIOUS,),
},
EdifierCommandSets.RC20G: {
MediaPlayerEntityFeature.TURN_ON: (EdifierRC20GCode.POWER,),
MediaPlayerEntityFeature.TURN_OFF: (EdifierRC20GCode.POWER,),
MediaPlayerEntityFeature.VOLUME_STEP: (
(EdifierRC20GCode.VOLUME_UP_LEFT, EdifierRC20GCode.VOLUME_UP_RIGHT),
(EdifierRC20GCode.VOLUME_DOWN_LEFT, EdifierRC20GCode.VOLUME_DOWN_RIGHT),
),
MediaPlayerEntityFeature.VOLUME_MUTE: (EdifierRC20GCode.MUTE,),
MediaPlayerEntityFeature.PLAY: (EdifierRC20GCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.PAUSE: (EdifierRC20GCode.PLAY_PAUSE,),
MediaPlayerEntityFeature.NEXT_TRACK: (EdifierRC20GCode.FORWARD,),
MediaPlayerEntityFeature.PREVIOUS_TRACK: (EdifierRC20GCode.PREVIOUS,),
},
}
async def async_setup_entry(
hass: HomeAssistant,
entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Edifier IR media player."""
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
command_set = EdifierCommandSets(entry.data[CONF_COMMAND_SET])
model = EdifierModel(entry.data[CONF_MODEL])
async_add_entities(
[EdifierIrMediaPlayer(entry, model, infrared_entity_id, command_set)]
)
class EdifierIrMediaPlayer(
EdifierIrEntity, InfraredEmitterConsumerEntity, MediaPlayerEntity
):
"""Edifier IR media player entity."""
_attr_name = None
_attr_assumed_state = True
_attr_device_class = MediaPlayerDeviceClass.SPEAKER
def __init__(
self,
entry: ConfigEntry,
model: EdifierModel,
infrared_entity_id: str,
command_set: EdifierCommandSets,
) -> None:
"""Initialize Edifier IR media player."""
super().__init__(entry, model, unique_id_suffix="media_player")
self._infrared_emitter_entity_id = infrared_entity_id
self._commands = COMMAND_SET_COMMANDS[command_set]
self._attr_state = MediaPlayerState.ON
self._attr_supported_features = MediaPlayerEntityFeature(0)
for feature in self._commands:
self._attr_supported_features |= feature
async def _send_codes(self, *codes: EdifierCode) -> None:
"""Send one or more IR commands."""
for code in codes:
await self._send_command(code.to_command())
async def async_turn_on(self) -> None:
"""Turn on the speaker."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_ON])
async def async_turn_off(self) -> None:
"""Turn off the speaker."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.TURN_OFF])
async def async_volume_up(self) -> None:
"""Send volume up command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][0])
async def async_volume_down(self) -> None:
"""Send volume down command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_STEP][1])
async def async_mute_volume(self, mute: bool) -> None:
"""Send mute command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.VOLUME_MUTE])
async def async_media_play(self) -> None:
"""Send play command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PLAY])
async def async_media_pause(self) -> None:
"""Send pause command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PAUSE])
async def async_media_next_track(self) -> None:
"""Send next track command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.NEXT_TRACK])
async def async_media_previous_track(self) -> None:
"""Send previous track command."""
await self._send_codes(*self._commands[MediaPlayerEntityFeature.PREVIOUS_TRACK])
@@ -0,0 +1,114 @@
rules:
# Bronze
action-setup:
status: exempt
comment: |
This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: |
This integration does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: |
This integration does not provide additional actions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
entity-event-setup: done
entity-unique-id: done
has-entity-name: done
runtime-data:
status: exempt
comment: |
This integration does not store runtime data.
test-before-configure: done
test-before-setup:
status: exempt
comment: |
This integration only proxies commands through an existing infrared
entity, so there is no separate connection to validate during setup.
unique-config-entry: done
# Silver
action-exceptions:
status: exempt
comment: |
This integration does not register custom actions.
config-entry-unloading: done
docs-configuration-parameters: done
docs-installation-parameters: done
entity-unavailable: done
integration-owner: done
log-when-unavailable: done
parallel-updates: done
reauthentication-flow:
status: exempt
comment: |
This integration does not require authentication.
test-coverage: done
# Gold
devices: done
diagnostics: todo
discovery-update-info:
status: exempt
comment: |
This integration does not support discovery.
discovery:
status: exempt
comment: |
This integration is configured manually via config flow.
docs-data-update:
status: exempt
comment: |
This integration does not fetch data from devices.
docs-examples: todo
docs-known-limitations: done
docs-supported-devices: done
docs-supported-functions: done
docs-troubleshooting: todo
docs-use-cases: done
dynamic-devices:
status: exempt
comment: |
Each config entry creates a single device.
entity-category:
status: exempt
comment: |
The media player entity is the primary entity and does not need a category.
entity-device-class: done
entity-disabled-by-default:
status: exempt
comment: |
The media player entity is the primary entity and should be enabled by default.
entity-translations: done
exception-translations:
status: exempt
comment: |
This integration does not raise exceptions.
icon-translations: done
reconfiguration-flow: todo
repair-issues:
status: exempt
comment: |
This integration does not have repairable issues.
stale-devices:
status: exempt
comment: |
Each config entry manages exactly one device.
# Platinum
async-dependency:
status: exempt
comment: |
This integration depends on infrared_protocols which provides only code
definitions with no I/O, so async dependency does not apply.
inject-websession:
status: exempt
comment: |
This integration does not make HTTP requests.
strict-typing: todo
@@ -0,0 +1,22 @@
{
"config": {
"abort": {
"already_configured": "This Edifier device has already been configured with this transmitter.",
"no_emitters": "No infrared transmitter entities found. Please set up an infrared device first."
},
"step": {
"user": {
"data": {
"infrared_entity_id": "IR transmitter",
"model": "Speaker model"
},
"data_description": {
"infrared_entity_id": "Select the infrared transmitter entity to use.",
"model": "Choose your Edifier speaker model from the list."
},
"description": "Configure your Edifier speaker for IR control.",
"title": "Set up Edifier IR speaker"
}
}
}
}
@@ -24,6 +24,7 @@ from homeassistant.helpers.typing import ConfigType
from .const import (
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
CONF_BC_CONNECT,
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_FIRMWARE_CHECK_TIME,
@@ -102,6 +103,8 @@ async def async_setup_entry(
!= config_entry.data.get(CONF_SUPPORTS_PRIVACY_MODE)
or host.api.baichuan.port != config_entry.data.get(CONF_BC_PORT)
or host.api.baichuan_only != config_entry.data.get(CONF_BC_ONLY)
or host.api.baichuan.connection_type.value
!= config_entry.data.get(CONF_BC_CONNECT)
):
if host.api.port != config_entry.data[CONF_PORT]:
_LOGGER.warning(
@@ -126,6 +129,7 @@ async def async_setup_entry(
CONF_USE_HTTPS: host.api.use_https,
CONF_BC_PORT: host.api.baichuan.port,
CONF_BC_ONLY: host.api.baichuan_only,
CONF_BC_CONNECT: host.api.baichuan.connection_type.value,
CONF_SUPPORTS_PRIVACY_MODE: host.api.supported(None, "privacy_mode"),
}
hass.config_entries.async_update_entry(config_entry, data=data)
@@ -37,6 +37,7 @@ from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .const import (
CONF_BC_CONNECT,
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
@@ -310,6 +311,7 @@ class ReolinkFlowHandler(ConfigFlow, domain=DOMAIN):
user_input[CONF_USE_HTTPS] = host.api.use_https
user_input[CONF_BC_PORT] = host.api.baichuan.port
user_input[CONF_BC_ONLY] = host.api.baichuan_only
user_input[CONF_BC_CONNECT] = host.api.baichuan.connection_type.value
user_input[CONF_SUPPORTS_PRIVACY_MODE] = host.api.supported(
None, "privacy_mode"
)
@@ -7,6 +7,7 @@ DOMAIN = "reolink"
CONF_USE_HTTPS = "use_https"
CONF_BC_PORT = "baichuan_port"
CONF_BC_ONLY = "baichuan_only"
CONF_BC_CONNECT = "baichuan_connection"
CONF_SUPPORTS_PRIVACY_MODE = "privacy_mode_supported"
CONF_FIRMWARE_CHECK_TIME = "firmware_check_time"
+9 -1
View File
@@ -11,7 +11,7 @@ import aiohttp
from aiohttp.web import Request
from reolink_aio.api import ALLOWED_SPECIAL_CHARS, Host
from reolink_aio.baichuan import DEFAULT_BC_PORT
from reolink_aio.enums import SubType
from reolink_aio.enums import ConnectionEnum, SubType
from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError
from homeassistant.components import webhook
@@ -36,6 +36,7 @@ from .const import (
BATTERY_ALL_WAKE_UPDATE_INTERVAL,
BATTERY_PASSIVE_WAKE_UPDATE_INTERVAL,
BATTERY_WAKE_UPDATE_INTERVAL,
CONF_BC_CONNECT,
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
@@ -77,6 +78,12 @@ class ReolinkHost:
self._config_entry = config_entry
self._config = config
self._unique_id: str = ""
try:
bc_connection = ConnectionEnum(
config.get(CONF_BC_CONNECT, ConnectionEnum.unknown.value)
)
except ValueError:
bc_connection = ConnectionEnum.unknown
def get_aiohttp_session() -> aiohttp.ClientSession:
"""Return the HA aiohttp session."""
@@ -96,6 +103,7 @@ class ReolinkHost:
timeout=DEFAULT_TIMEOUT,
aiohttp_get_session_callback=get_aiohttp_session,
bc_port=config.get(CONF_BC_PORT, DEFAULT_BC_PORT),
bc_connection=bc_connection,
bc_only=config.get(CONF_BC_ONLY, False),
)
@@ -16,5 +16,5 @@
"iot_class": "cloud_polling",
"loggers": ["pyvesync"],
"quality_scale": "bronze",
"requirements": ["pyvesync==3.4.1"]
"requirements": ["pyvesync==3.4.2"]
}
+1
View File
@@ -185,6 +185,7 @@ FLOWS = {
"econet",
"ecovacs",
"ecowitt",
"edifier_infrared",
"edl21",
"efergy",
"egauge",
@@ -1654,6 +1654,12 @@
"config_flow": true,
"iot_class": "local_push"
},
"edifier_infrared": {
"name": "Edifier Infrared",
"integration_type": "device",
"config_flow": true,
"iot_class": "assumed_state"
},
"edimax": {
"name": "Edimax",
"integration_type": "hub",
+1 -1
View File
@@ -2785,7 +2785,7 @@ pyvera==0.3.16
pyversasense==0.0.6
# homeassistant.components.vesync
pyvesync==3.4.1
pyvesync==3.4.2
# homeassistant.components.vizio
pyvizio==0.1.61
+19 -3
View File
@@ -683,15 +683,31 @@ async def test_load_unload_entry_tracker(
None,
1.0,
2.0,
STATE_HOME,
{
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_GPS_ACCURACY: 0,
ATTR_IN_ZONES: ["zone.home"],
ATTR_LATITUDE: 1.0,
ATTR_LONGITUDE: 2.0,
},
id="in_zones_wins_over_lat_long",
),
pytest.param(
None,
[],
None,
50.0,
60.0,
STATE_NOT_HOME,
{
ATTR_SOURCE_TYPE: SourceType.GPS,
ATTR_GPS_ACCURACY: 0,
ATTR_IN_ZONES: [],
ATTR_LATITUDE: 1.0,
ATTR_LONGITUDE: 2.0,
ATTR_LATITUDE: 50.0,
ATTR_LONGITUDE: 60.0,
},
id="in_zones_ignored_when_lat_long_set",
id="empty_in_zones_wins_over_lat_long",
),
pytest.param(
None,
@@ -0,0 +1 @@
"""Tests for the Edifier Infrared integration."""
@@ -0,0 +1,100 @@
"""Common fixtures for the Edifier Infrared tests."""
from collections.abc import Generator
from unittest.mock import patch
import pytest
from homeassistant.components.edifier_infrared import PLATFORMS
from homeassistant.components.edifier_infrared.const import (
CONF_COMMAND_SET,
CONF_INFRARED_ENTITY_ID,
DOMAIN,
EdifierCommandSets,
EdifierModel,
)
from homeassistant.const import CONF_MODEL, Platform
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
from tests.components.infrared import (
EMITTER_ENTITY_ID as MOCK_INFRARED_EMITTER_ENTITY_ID,
)
from tests.components.infrared.common import MockInfraredEmitterEntity
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock config entry."""
return MockConfigEntry(
domain=DOMAIN,
entry_id="01JTEST0000000000000000000",
title="Edifier R1700BT via Test IR emitter",
data={
CONF_INFRARED_ENTITY_ID: MOCK_INFRARED_EMITTER_ENTITY_ID,
CONF_MODEL: EdifierModel.R1700BT.value,
CONF_COMMAND_SET: EdifierCommandSets.R1700BT.value,
},
unique_id=f"r1700bt_{MOCK_INFRARED_EMITTER_ENTITY_ID}",
)
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return PLATFORMS
@pytest.fixture
def mock_edifier_code_to_command() -> Generator[None]:
"""Patch Edifier *Code.to_command to return the code enum directly.
This allows tests to assert on the high-level code enum value
rather than the raw NEC timings.
"""
with (
patch(
"infrared_protocols.codes.edifier.r1700bt.EdifierR1700BTCode.to_command",
autospec=True,
side_effect=lambda self: self,
),
patch(
"infrared_protocols.codes.edifier.r1280db.EdifierR1280DBCode.to_command",
autospec=True,
side_effect=lambda self: self,
),
patch(
"infrared_protocols.codes.edifier.r1280t.EdifierR1280TCode.to_command",
autospec=True,
side_effect=lambda self: self,
),
patch(
"infrared_protocols.codes.edifier.s360db.EdifierS360DBCode.to_command",
autospec=True,
side_effect=lambda self: self,
),
patch(
"infrared_protocols.codes.edifier.rc20g.EdifierRC20GCode.to_command",
autospec=True,
side_effect=lambda self: self,
),
):
yield
@pytest.fixture
async def init_integration(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
mock_edifier_code_to_command: None,
platforms: list[Platform],
) -> MockConfigEntry:
"""Set up the Edifier Infrared integration for testing."""
mock_config_entry.add_to_hass(hass)
with patch("homeassistant.components.edifier_infrared.PLATFORMS", platforms):
await hass.config_entries.async_setup(mock_config_entry.entry_id)
await hass.async_block_till_done()
return mock_config_entry
@@ -0,0 +1,55 @@
# serializer version: 1
# name: test_entities[media_player.edifier_r1700bt-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'media_player',
'entity_category': None,
'entity_id': 'media_player.edifier_r1700bt',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': None,
'options': dict({
}),
'original_device_class': <MediaPlayerDeviceClass.SPEAKER: 'speaker'>,
'original_icon': None,
'original_name': None,
'platform': 'edifier_infrared',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': <MediaPlayerEntityFeature: 17849>,
'translation_key': None,
'unique_id': '01JTEST0000000000000000000_media_player',
'unit_of_measurement': None,
})
# ---
# name: test_entities[media_player.edifier_r1700bt-state]
StateSnapshot({
'attributes': ReadOnlyDict({
'assumed_state': True,
'device_class': 'speaker',
'friendly_name': 'Edifier R1700BT',
'supported_features': <MediaPlayerEntityFeature: 17849>,
}),
'context': <ANY>,
'entity_id': 'media_player.edifier_r1700bt',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'on',
})
# ---
@@ -0,0 +1,132 @@
"""Tests for the Edifier Infrared config flow."""
import pytest
from homeassistant.components.edifier_infrared.const import (
CONF_COMMAND_SET,
CONF_INFRARED_ENTITY_ID,
DOMAIN,
EdifierCommandSets,
EdifierModel,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_MODEL
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from homeassistant.helpers import entity_registry as er
from tests.common import MockConfigEntry
from tests.components.infrared import EMITTER_ENTITY_ID
@pytest.mark.parametrize(
("model", "expected_command_set"),
[
(EdifierModel.R1700BT, EdifierCommandSets.R1700BT),
(EdifierModel.R1280DB, EdifierCommandSets.R1280DB),
(EdifierModel.R1280T, EdifierCommandSets.R1280T),
(EdifierModel.S360DB, EdifierCommandSets.S360DB),
(EdifierModel.RC20G, EdifierCommandSets.RC20G),
],
)
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
async def test_user_flow_success(
hass: HomeAssistant,
model: EdifierModel,
expected_command_set: EdifierCommandSets,
) -> None:
"""Test successful user config flow for each command set."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID,
CONF_MODEL: model.value,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"Edifier {model.value} via Test IR emitter"
assert result["data"] == {
CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID,
CONF_MODEL: model.value,
CONF_COMMAND_SET: expected_command_set.value,
}
assert (
result["result"].unique_id
== f"{expected_command_set.value}_{EMITTER_ENTITY_ID}"
)
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
async def test_user_flow_already_configured(
hass: HomeAssistant, mock_config_entry: MockConfigEntry
) -> None:
"""Test user flow aborts when entry is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID,
CONF_MODEL: EdifierModel.R1700BT.value,
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("init_infrared")
async def test_user_flow_no_emitters(hass: HomeAssistant) -> None:
"""Test user flow aborts when no infrared emitters exist."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "no_emitters"
@pytest.mark.usefixtures("mock_infrared_emitter_entity")
@pytest.mark.parametrize(
("entity_name", "expected_title"),
[
(None, "Edifier R1700BT via Test IR emitter"),
("Living room IR", "Edifier R1700BT via Living room IR"),
],
)
async def test_user_flow_title_from_entity_name(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
entity_name: str | None,
expected_title: str,
) -> None:
"""Test config entry title uses the entity name."""
entity_registry.async_update_entity(EMITTER_ENTITY_ID, name=entity_name)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
user_input={
CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID,
CONF_MODEL: EdifierModel.R1700BT.value,
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == expected_title
@@ -0,0 +1,19 @@
"""Tests for the Edifier Infrared integration setup."""
from homeassistant.config_entries import ConfigEntryState
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def test_setup_and_unload_entry(
hass: HomeAssistant, init_integration: MockConfigEntry
) -> None:
"""Test setting up and unloading a config entry."""
entry = init_integration
assert entry.state is ConfigEntryState.LOADED
await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
assert entry.state is ConfigEntryState.NOT_LOADED
@@ -0,0 +1,144 @@
"""Tests for the Edifier Infrared media player platform."""
from infrared_protocols.codes.edifier.r1700bt import EdifierR1700BTCode
from infrared_protocols.codes.edifier.rc20g import EdifierRC20GCode
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.edifier_infrared.const import (
CONF_COMMAND_SET,
CONF_INFRARED_ENTITY_ID,
DOMAIN,
EdifierCommandSets,
EdifierModel,
)
from homeassistant.components.media_player import (
DOMAIN as MEDIA_PLAYER_DOMAIN,
SERVICE_MEDIA_NEXT_TRACK,
SERVICE_MEDIA_PAUSE,
SERVICE_MEDIA_PLAY,
SERVICE_MEDIA_PREVIOUS_TRACK,
SERVICE_TURN_OFF,
SERVICE_TURN_ON,
SERVICE_VOLUME_DOWN,
SERVICE_VOLUME_MUTE,
SERVICE_VOLUME_UP,
)
from homeassistant.const import ATTR_ENTITY_ID, CONF_MODEL, 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
MEDIA_PLAYER_ENTITY_ID = "media_player.edifier_r1700bt"
@pytest.fixture
def platforms() -> list[Platform]:
"""Return platforms to set up."""
return [Platform.MEDIA_PLAYER]
@pytest.mark.usefixtures("init_integration")
async def test_entities(
hass: HomeAssistant,
snapshot: SnapshotAssertion,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the media player entity is created with correct attributes."""
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.parametrize(
("service", "service_data", "expected_code"),
[
(SERVICE_TURN_ON, {}, EdifierR1700BTCode.POWER),
(SERVICE_TURN_OFF, {}, EdifierR1700BTCode.POWER),
(SERVICE_VOLUME_UP, {}, EdifierR1700BTCode.VOLUME_UP),
(SERVICE_VOLUME_DOWN, {}, EdifierR1700BTCode.VOLUME_DOWN),
(SERVICE_VOLUME_MUTE, {"is_volume_muted": True}, EdifierR1700BTCode.MUTE),
(SERVICE_MEDIA_PLAY, {}, EdifierR1700BTCode.PLAY_PAUSE),
(SERVICE_MEDIA_PAUSE, {}, EdifierR1700BTCode.PLAY_PAUSE),
(SERVICE_MEDIA_NEXT_TRACK, {}, EdifierR1700BTCode.FORWARD),
(SERVICE_MEDIA_PREVIOUS_TRACK, {}, EdifierR1700BTCode.BACK),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_media_player_action_sends_correct_code(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
service: str,
service_data: dict[str, bool],
expected_code: EdifierR1700BTCode,
) -> None:
"""Test each media player action sends the correct IR code."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
service,
{ATTR_ENTITY_ID: MEDIA_PLAYER_ENTITY_ID, **service_data},
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.parametrize(
"mock_config_entry",
[
MockConfigEntry(
domain=DOMAIN,
entry_id="01JTEST0000000000000000001",
title="Edifier RC20G via Test IR emitter",
data={
CONF_INFRARED_ENTITY_ID: EMITTER_ENTITY_ID,
CONF_MODEL: EdifierModel.RC20G.value,
CONF_COMMAND_SET: EdifierCommandSets.RC20G.value,
},
unique_id=f"rc20g_{EMITTER_ENTITY_ID}",
)
],
)
@pytest.mark.parametrize(
("service", "expected_codes"),
[
(
SERVICE_VOLUME_UP,
(EdifierRC20GCode.VOLUME_UP_LEFT, EdifierRC20GCode.VOLUME_UP_RIGHT),
),
(
SERVICE_VOLUME_DOWN,
(EdifierRC20GCode.VOLUME_DOWN_LEFT, EdifierRC20GCode.VOLUME_DOWN_RIGHT),
),
],
)
@pytest.mark.usefixtures("init_integration")
async def test_rc20g_volume_sends_left_and_right_codes(
hass: HomeAssistant,
mock_infrared_emitter_entity: MockInfraredEmitterEntity,
service: str,
expected_codes: tuple[EdifierRC20GCode, ...],
) -> None:
"""Test that RC20G volume up/down send both left and right channel codes."""
await hass.services.async_call(
MEDIA_PLAYER_DOMAIN,
service,
{ATTR_ENTITY_ID: "media_player.edifier_rc20g"},
blocking=True,
)
assert tuple(mock_infrared_emitter_entity.send_command_calls) == expected_codes
@pytest.mark.usefixtures("init_integration")
async def test_media_player_availability_follows_ir_entity(
hass: HomeAssistant,
) -> None:
"""Test media player becomes unavailable when IR entity is unavailable."""
await assert_availability_follows_source_entity(
hass, MEDIA_PLAYER_ENTITY_ID, EMITTER_ENTITY_ID
)
@@ -172,16 +172,16 @@ async def setup_zone(hass: HomeAssistant) -> None:
{"in_zones": []},
"not_home",
),
# in_zones + gps: gps wins, in_zones recomputed from coordinates
# in_zones + gps: in_zones wins, gps coordinates still reported as attributes
(
{"gps": [10, 20], "in_zones": ["zone.school"]},
{
"latitude": 10,
"longitude": 20,
"gps_accuracy": 30,
"in_zones": ["zone.home"],
"in_zones": ["zone.school"],
},
"home",
"School",
),
],
)
+3
View File
@@ -5,6 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from reolink_aio.api import Chime
from reolink_aio.enums import ConnectionEnum
from reolink_aio.exceptions import ReolinkError
from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL
@@ -50,6 +51,7 @@ TEST_CAM_MODEL = "RLC-123"
TEST_DUO_MODEL = "Reolink Duo PoE"
TEST_PRIVACY = True
TEST_BC_PORT = 5678
TEST_BC_CON = ConnectionEnum.tcp.value
@pytest.fixture
@@ -162,6 +164,7 @@ def _init_host_mock(host_mock: MagicMock) -> None:
host_mock.baichuan_only = False
# Disable tcp push by default for tests
host_mock.baichuan.port = TEST_BC_PORT
host_mock.baichuan.connection_type = ConnectionEnum(TEST_BC_CON)
host_mock.baichuan.events_active = False
host_mock.baichuan.login_sucess = True
host_mock.baichuan.subscribe_events = AsyncMock()
@@ -7,6 +7,7 @@ from unittest.mock import ANY, AsyncMock, MagicMock, call, patch
from aiohttp import ClientSession
from freezegun.api import FrozenDateTimeFactory
import pytest
from reolink_aio.enums import ConnectionEnum
from reolink_aio.exceptions import (
ApiError,
CredentialsInvalidError,
@@ -18,6 +19,7 @@ from reolink_aio.exceptions import (
from homeassistant import config_entries
from homeassistant.components.reolink.config_flow import DEFAULT_PROTOCOL
from homeassistant.components.reolink.const import (
CONF_BC_CONNECT,
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
@@ -42,6 +44,7 @@ from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo
from .conftest import (
DHCP_FORMATTED_MAC,
TEST_BC_CON,
TEST_BC_PORT,
TEST_HOST,
TEST_HOST2,
@@ -91,6 +94,7 @@ async def test_config_flow_manual_success(hass: HomeAssistant) -> None:
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
}
assert result["options"] == {
@@ -146,6 +150,7 @@ async def test_config_flow_privacy_success(
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
}
assert result["options"] == {
@@ -188,6 +193,7 @@ async def test_config_flow_baichuan_only(
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: True,
}
assert result["options"] == {
@@ -350,6 +356,7 @@ async def test_config_flow_errors(hass: HomeAssistant, reolink_host: MagicMock)
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
}
assert result["options"] == {
@@ -370,6 +377,7 @@ async def test_options_flow(hass: HomeAssistant) -> None:
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -411,6 +419,7 @@ async def test_reauth(hass: HomeAssistant) -> None:
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -459,6 +468,7 @@ async def test_reauth_abort_unique_id_mismatch(
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -529,6 +539,7 @@ async def test_dhcp_flow(hass: HomeAssistant) -> None:
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
}
assert result["options"] == {
@@ -553,6 +564,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac(
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -595,6 +607,7 @@ async def test_dhcp_ip_update_aborted_if_wrong_mac(
timeout=DEFAULT_TIMEOUT,
aiohttp_get_session_callback=ANY,
bc_port=TEST_BC_PORT,
bc_connection=ConnectionEnum(TEST_BC_CON),
bc_only=False,
)
assert expected_call in reolink_host_class.call_args_list
@@ -677,6 +690,7 @@ async def test_dhcp_ip_update(
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -720,6 +734,7 @@ async def test_dhcp_ip_update(
timeout=DEFAULT_TIMEOUT,
aiohttp_get_session_callback=ANY,
bc_port=TEST_BC_PORT,
bc_connection=ConnectionEnum(TEST_BC_CON),
bc_only=False,
)
assert expected_call in reolink_host_class.call_args_list
@@ -753,6 +768,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected(
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -786,6 +802,7 @@ async def test_dhcp_ip_update_ingnored_if_still_connected(
timeout=DEFAULT_TIMEOUT,
aiohttp_get_session_callback=ANY,
bc_port=TEST_BC_PORT,
bc_connection=ConnectionEnum(TEST_BC_CON),
bc_only=False,
)
assert expected_call in reolink_host_class.call_args_list
@@ -815,6 +832,7 @@ async def test_reconfig(hass: HomeAssistant) -> None:
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
@@ -864,6 +882,7 @@ async def test_reconfig_abort_unique_id_mismatch(
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_CONNECT: TEST_BC_CON,
CONF_BC_ONLY: False,
},
options={
+61 -5
View File
@@ -7,9 +7,10 @@ from unittest.mock import AsyncMock, MagicMock, patch
from aiohttp import ClientResponseError
from freezegun.api import FrozenDateTimeFactory
import pytest
from reolink_aio.enums import SubType
from reolink_aio.enums import ConnectionEnum, SubType
from reolink_aio.exceptions import NotSupportedError, ReolinkError, SubscriptionError
from homeassistant.components.reolink.const import CONF_BC_CONNECT, DOMAIN
from homeassistant.components.reolink.coordinator import DEVICE_UPDATE_INTERVAL_MIN
from homeassistant.components.reolink.host import (
FIRST_ONVIF_LONG_POLL_TIMEOUT,
@@ -21,14 +22,39 @@ from homeassistant.components.reolink.host import (
)
from homeassistant.components.webhook import async_handle_webhook
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_OFF, STATE_ON, Platform
from homeassistant.const import (
CONF_HOST,
CONF_PASSWORD,
CONF_PORT,
CONF_PROTOCOL,
CONF_USERNAME,
STATE_OFF,
STATE_ON,
Platform,
)
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.network import NoURLAvailableError
from homeassistant.util.aiohttp import MockRequest
from .conftest import TEST_CAM_NAME
from .conftest import (
CONF_BC_ONLY,
CONF_BC_PORT,
CONF_SUPPORTS_PRIVACY_MODE,
CONF_USE_HTTPS,
DEFAULT_PROTOCOL,
TEST_BC_PORT,
TEST_CAM_NAME,
TEST_HOST,
TEST_MAC,
TEST_NVR_NAME,
TEST_PASSWORD,
TEST_PORT,
TEST_PRIVACY,
TEST_USE_HTTPS,
TEST_USERNAME,
)
from tests.common import MockConfigEntry, async_fire_time_changed
from tests.components.diagnostics import get_diagnostics_for_config_entry
@@ -82,7 +108,6 @@ async def test_webhook_callback(
freezer: FrozenDateTimeFactory,
config_entry: MockConfigEntry,
reolink_host: MagicMock,
entity_registry: er.EntityRegistry,
) -> None:
"""Test webhook callback with motion sensor."""
reolink_host.motion_detected.return_value = False
@@ -178,6 +203,37 @@ async def test_no_mac(
reolink_host.mac_address = original
async def test_invalid_bc_connection(
hass: HomeAssistant,
reolink_host: MagicMock,
) -> None:
"""Test setup of host with an outdated, invalid bc_connection."""
config_entry = MockConfigEntry(
domain=DOMAIN,
unique_id=format_mac(TEST_MAC),
data={
CONF_HOST: TEST_HOST,
CONF_USERNAME: TEST_USERNAME,
CONF_PASSWORD: TEST_PASSWORD,
CONF_PORT: TEST_PORT,
CONF_USE_HTTPS: TEST_USE_HTTPS,
CONF_SUPPORTS_PRIVACY_MODE: TEST_PRIVACY,
CONF_BC_PORT: TEST_BC_PORT,
CONF_BC_ONLY: False,
CONF_BC_CONNECT: "invalid_test",
},
options={
CONF_PROTOCOL: DEFAULT_PROTOCOL,
},
title=TEST_NVR_NAME,
)
config_entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
assert config_entry.state is ConfigEntryState.LOADED
assert config_entry.data[CONF_BC_CONNECT] == ConnectionEnum.tcp.value
async def test_subscribe_error(
hass: HomeAssistant,
config_entry: MockConfigEntry,
+1 -1
View File
@@ -56,7 +56,7 @@ DEVICE_FIXTURES: dict[str, list[tuple[str, str, str]]] = {
("post", "/cloud/v2/deviceManaged/bypassV2", "air-purifier-detail.json")
],
"Dimmable Light": [
("post", "/cloud/v1/deviceManaged/deviceDetail", "device-detail.json")
("post", "/cloud/v1/deviceManaged/deviceDetail", "dimmable-light-detail.json")
],
"Temperature Light": [
("post", "/cloud/v1/deviceManaged/bypass", "light-detail.json")
@@ -0,0 +1,22 @@
{
"traceId": "1234",
"code": 0,
"msg": "request success",
"module": null,
"stacktrace": null,
"result": {
"deviceName": "Dimmable",
"name": "Dimmable",
"brightNess": "80",
"deviceStatus": "on",
"activeTime": 0,
"defaultDeviceImg": "https://image.vesync.com/defaultImages/ESL100_Series/icon_dimmable_bulb_80.png",
"timer": null,
"scheduleCount": 0,
"away": null,
"schedule": null,
"ownerShip": "1",
"deviceImg": "https://image.vesync.com/defaultImages/deviceDefaultImages/wifismartbulb_240.png",
"connectionStatus": "online"
}
}
@@ -300,6 +300,8 @@
# name: test_light_state[Dimmable Light][light.dimmable_light]
StateSnapshot({
'attributes': ReadOnlyDict({
'brightness': 204,
'color_mode': <ColorMode.BRIGHTNESS: 'brightness'>,
'friendly_name': 'Dimmable Light',
'supported_color_modes': list([
<ColorMode.BRIGHTNESS: 'brightness'>,
@@ -311,7 +313,7 @@
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
'state': 'on',
})
# ---
# name: test_light_state[Dimmer Switch][devices]
@@ -662,17 +662,27 @@
# name: test_update_state[Dimmable Light][update.dimmable_light_firmware]
StateSnapshot({
'attributes': ReadOnlyDict({
'auto_update': False,
'device_class': 'firmware',
'display_precision': 0,
'entity_picture': '/api/brands/integration/vesync/icon.png',
'friendly_name': 'Dimmable Light Firmware',
'in_progress': False,
'installed_version': '1.0.0',
'latest_version': '1.0.1',
'release_summary': None,
'release_url': None,
'skipped_version': None,
'supported_features': <UpdateEntityFeature: 0>,
'title': None,
'update_percentage': None,
}),
'context': <ANY>,
'entity_id': 'update.dimmable_light_firmware',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'unavailable',
'state': 'on',
})
# ---
# name: test_update_state[Dimmer Switch][devices]
+57
View File
@@ -3326,6 +3326,63 @@ async def _setup_numerical_condition(
"90",
False,
),
# outside (inverse of between) — limits are non-inclusive, so a value
# equal to either bound is treated as "not inside" and matches
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
"50",
False,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
"20",
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
"80",
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
"10",
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
"90",
True,
),
],
)
async def test_numerical_condition_thresholds(
+371
View File
@@ -1937,6 +1937,188 @@ async def test_numerical_state_attribute_changed_error_handling(
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_options", "new_value", "expected_fires"),
[
# above — limit is non-inclusive
({"threshold": {"type": "above", "value": {"number": 50}}}, 75, True),
({"threshold": {"type": "above", "value": {"number": 50}}}, 50, False),
({"threshold": {"type": "above", "value": {"number": 50}}}, 25, False),
# below — limit is non-inclusive
({"threshold": {"type": "below", "value": {"number": 50}}}, 25, True),
({"threshold": {"type": "below", "value": {"number": 50}}}, 50, False),
({"threshold": {"type": "below", "value": {"number": 50}}}, 75, False),
# between — both limits are non-inclusive
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
50,
True,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
20,
False,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
80,
False,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
10,
False,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
90,
False,
),
# outside — values equal to either bound are treated as "not inside"
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
50,
False,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
20,
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
80,
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
10,
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
90,
True,
),
# any — fires on every numerical change regardless of value
({"threshold": {"type": "any"}}, 0, True),
({"threshold": {"type": "any"}}, 50, True),
({"threshold": {"type": "any"}}, 1000, True),
],
)
async def test_numerical_state_attribute_changed_trigger_thresholds(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger_options: dict[str, Any],
new_value: float,
expected_fires: bool,
) -> None:
"""Test numerical changed trigger above/below/between/outside/any thresholds.
Verifies that the threshold limits are non-inclusive: a tracked value
exactly equal to a limit is treated as "not inside" the range.
"""
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {
"attribute_changed": make_entity_numerical_state_changed_trigger(
{"test": DomainSpec(value_source="test_attribute")}
),
}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
# Seed the entity with a starting value that differs from new_value so
# the changed-transition is always satisfied; the test then exercises
# the is_valid_state boundary semantics for the new value.
initial_value = -1 if new_value != -1 else -2
hass.states.async_set("test.test_entity", "on", {"test_attribute": initial_value})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "test.attribute_changed",
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
CONF_OPTIONS: trigger_options,
},
"action": {
"service": "test.automation",
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
},
}
},
)
assert len(service_calls) == 0
hass.states.async_set("test.test_entity", "on", {"test_attribute": new_value})
await hass.async_block_till_done()
assert len(service_calls) == (1 if expected_fires else 0)
async def test_numerical_state_attribute_changed_entity_limit_unit_validation(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None:
@@ -2845,6 +3027,195 @@ async def test_numerical_state_attribute_crossed_threshold_error_handling(
assert len(service_calls) == 0
@pytest.mark.parametrize(
("trigger_options", "new_value", "expected_fires"),
[
# above — limit is non-inclusive, crossing exactly onto the limit does
# not enter the range
({"threshold": {"type": "above", "value": {"number": 50}}}, 75, True),
({"threshold": {"type": "above", "value": {"number": 50}}}, 50, False),
({"threshold": {"type": "above", "value": {"number": 50}}}, 25, False),
# below — limit is non-inclusive
({"threshold": {"type": "below", "value": {"number": 50}}}, 25, True),
({"threshold": {"type": "below", "value": {"number": 50}}}, 50, False),
({"threshold": {"type": "below", "value": {"number": 50}}}, 75, False),
# between — both limits are non-inclusive
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
50,
True,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
20,
False,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
80,
False,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
10,
False,
),
(
{
"threshold": {
"type": "between",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
90,
False,
),
# outside — values equal to either bound are treated as "not inside"
# and therefore enter the "outside" range from the inside seed value
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
50,
False,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
20,
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
80,
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
10,
True,
),
(
{
"threshold": {
"type": "outside",
"value_min": {"number": 20},
"value_max": {"number": 80},
}
},
90,
True,
),
],
)
async def test_numerical_state_attribute_crossed_threshold_trigger_thresholds(
hass: HomeAssistant,
service_calls: list[ServiceCall],
trigger_options: dict[str, Any],
new_value: float,
expected_fires: bool,
) -> None:
"""Test crossed-threshold trigger above/below/between/outside thresholds.
Verifies the threshold limits are non-inclusive: transitioning to a value
exactly equal to a limit does not enter the range, so the trigger does
not fire. For "outside", values equal to either bound are considered
outside and therefore do cause the trigger to fire.
"""
async def async_get_triggers(hass: HomeAssistant) -> dict[str, type[Trigger]]:
return {
"crossed_threshold": make_entity_numerical_state_crossed_threshold_trigger(
{"test": DomainSpec(value_source="test_attribute")}
),
}
mock_integration(hass, MockModule("test"))
mock_platform(hass, "test.trigger", Mock(async_get_triggers=async_get_triggers))
# Seed the entity with a value that is NOT in the target range so the
# transition into the new value is a potential "cross". The seed is
# chosen per threshold type to ensure is_valid_state(from_state) is
# False and the seed value differs from any parametrized new_value.
seed_values = {
"above": 0, # 0 is not above 50
"below": 100, # 100 is not below 50
"between": 0, # 0 is not inside (20, 80)
"outside": 30, # 30 is inside (20, 80), i.e. not "outside"
}
seed_value = seed_values[trigger_options["threshold"]["type"]]
hass.states.async_set("test.test_entity", "on", {"test_attribute": seed_value})
await async_setup_component(
hass,
automation.DOMAIN,
{
automation.DOMAIN: {
"trigger": {
CONF_PLATFORM: "test.crossed_threshold",
CONF_TARGET: {CONF_ENTITY_ID: "test.test_entity"},
CONF_OPTIONS: trigger_options,
},
"action": {
"service": "test.automation",
"data_template": {CONF_ENTITY_ID: "{{ trigger.entity_id }}"},
},
}
},
)
assert len(service_calls) == 0
hass.states.async_set("test.test_entity", "on", {"test_attribute": new_value})
await hass.async_block_till_done()
assert len(service_calls) == (1 if expected_fires else 0)
async def test_numerical_state_attribute_crossed_threshold_entity_limit_unit_validation(
hass: HomeAssistant, service_calls: list[ServiceCall]
) -> None: