mirror of
https://github.com/home-assistant/core.git
synced 2026-02-06 23:34:51 +01:00
Compare commits
4 Commits
python-3.1
...
lg_infrare
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81415a3cb1 | ||
|
|
ed1bb685da | ||
|
|
6c610dfe73 | ||
|
|
90bacbb98e |
5
.github/workflows/builder.yml
vendored
5
.github/workflows/builder.yml
vendored
@@ -10,12 +10,12 @@ on:
|
||||
|
||||
env:
|
||||
BUILD_TYPE: core
|
||||
DEFAULT_PYTHON: "3.14.3"
|
||||
DEFAULT_PYTHON: "3.14.2"
|
||||
PIP_TIMEOUT: 60
|
||||
UV_HTTP_TIMEOUT: 60
|
||||
UV_SYSTEM_PYTHON: "true"
|
||||
# Base image version from https://github.com/home-assistant/docker
|
||||
BASE_IMAGE_VERSION: "2026.02.0"
|
||||
BASE_IMAGE_VERSION: "2026.01.0"
|
||||
ARCHITECTURES: '["amd64", "aarch64"]'
|
||||
|
||||
jobs:
|
||||
@@ -235,7 +235,6 @@ jobs:
|
||||
build-args: |
|
||||
BUILD_FROM=${{ steps.vars.outputs.base_image }}
|
||||
tags: ghcr.io/home-assistant/${{ matrix.arch }}-homeassistant:${{ needs.init.outputs.version }}
|
||||
outputs: type=image,push=true,compression=zstd,compression-level=9,force-compression=true,oci-mediatypes=true
|
||||
labels: |
|
||||
io.hass.arch=${{ matrix.arch }}
|
||||
io.hass.version=${{ needs.init.outputs.version }}
|
||||
|
||||
4
.github/workflows/ci.yaml
vendored
4
.github/workflows/ci.yaml
vendored
@@ -41,8 +41,8 @@ env:
|
||||
UV_CACHE_VERSION: 1
|
||||
MYPY_CACHE_VERSION: 1
|
||||
HA_SHORT_VERSION: "2026.3"
|
||||
DEFAULT_PYTHON: "3.14.3"
|
||||
ALL_PYTHON_VERSIONS: "['3.14.3']"
|
||||
DEFAULT_PYTHON: "3.14.2"
|
||||
ALL_PYTHON_VERSIONS: "['3.14.2']"
|
||||
# 10.3 is the oldest supported version
|
||||
# - 10.3.32 is the version currently shipped with Synology (as of 17 Feb 2022)
|
||||
# 10.6 is the current long-term-support
|
||||
|
||||
2
.github/workflows/translations.yml
vendored
2
.github/workflows/translations.yml
vendored
@@ -10,7 +10,7 @@ on:
|
||||
- "**strings.json"
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.14.3"
|
||||
DEFAULT_PYTHON: "3.14.2"
|
||||
|
||||
jobs:
|
||||
upload:
|
||||
|
||||
2
.github/workflows/wheels.yml
vendored
2
.github/workflows/wheels.yml
vendored
@@ -17,7 +17,7 @@ on:
|
||||
- "script/gen_requirements_all.py"
|
||||
|
||||
env:
|
||||
DEFAULT_PYTHON: "3.14.3"
|
||||
DEFAULT_PYTHON: "3.14.2"
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.workflow }}-${{ github.ref_name}}
|
||||
|
||||
@@ -282,6 +282,7 @@ homeassistant.components.imgw_pib.*
|
||||
homeassistant.components.immich.*
|
||||
homeassistant.components.incomfort.*
|
||||
homeassistant.components.inels.*
|
||||
homeassistant.components.infrared.*
|
||||
homeassistant.components.input_button.*
|
||||
homeassistant.components.input_select.*
|
||||
homeassistant.components.input_text.*
|
||||
@@ -314,6 +315,7 @@ homeassistant.components.ld2410_ble.*
|
||||
homeassistant.components.led_ble.*
|
||||
homeassistant.components.lektrico.*
|
||||
homeassistant.components.letpot.*
|
||||
homeassistant.components.lg_infrared.*
|
||||
homeassistant.components.libre_hardware_monitor.*
|
||||
homeassistant.components.lidarr.*
|
||||
homeassistant.components.lifx.*
|
||||
|
||||
2
CODEOWNERS
generated
2
CODEOWNERS
generated
@@ -782,6 +782,8 @@ build.json @home-assistant/supervisor
|
||||
/tests/components/inels/ @epdevlab
|
||||
/homeassistant/components/influxdb/ @mdegat01
|
||||
/tests/components/influxdb/ @mdegat01
|
||||
/homeassistant/components/infrared/ @home-assistant/core
|
||||
/tests/components/infrared/ @home-assistant/core
|
||||
/homeassistant/components/inkbird/ @bdraco
|
||||
/tests/components/inkbird/ @bdraco
|
||||
/homeassistant/components/input_boolean/ @home-assistant/core
|
||||
|
||||
@@ -59,15 +59,13 @@ async def async_setup_entry(
|
||||
|
||||
coordinator = entry.runtime_data
|
||||
|
||||
# DND keys
|
||||
old_key = "do_not_disturb"
|
||||
new_key = "dnd"
|
||||
# Replace unique id for "DND" switch and remove from Speaker Group
|
||||
await async_update_unique_id(
|
||||
hass, coordinator, SWITCH_DOMAIN, "do_not_disturb", "dnd"
|
||||
)
|
||||
|
||||
# Remove old DND switch from virtual groups
|
||||
await async_remove_dnd_from_virtual_group(hass, coordinator, old_key)
|
||||
|
||||
# Replace unique id for DND switch
|
||||
await async_update_unique_id(hass, coordinator, SWITCH_DOMAIN, old_key, new_key)
|
||||
# Remove DND switch from virtual groups
|
||||
await async_remove_dnd_from_virtual_group(hass, coordinator)
|
||||
|
||||
known_devices: set[str] = set()
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ def alexa_api_call[_T: AmazonEntity, **_P](
|
||||
async def async_update_unique_id(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
platform: str,
|
||||
domain: str,
|
||||
old_key: str,
|
||||
new_key: str,
|
||||
) -> None:
|
||||
@@ -63,9 +63,7 @@ async def async_update_unique_id(
|
||||
|
||||
for serial_num in coordinator.data:
|
||||
unique_id = f"{serial_num}-{old_key}"
|
||||
if entity_id := entity_registry.async_get_entity_id(
|
||||
DOMAIN, platform, unique_id
|
||||
):
|
||||
if entity_id := entity_registry.async_get_entity_id(domain, DOMAIN, unique_id):
|
||||
_LOGGER.debug("Updating unique_id for %s", entity_id)
|
||||
new_unique_id = unique_id.replace(old_key, new_key)
|
||||
|
||||
@@ -76,13 +74,12 @@ async def async_update_unique_id(
|
||||
async def async_remove_dnd_from_virtual_group(
|
||||
hass: HomeAssistant,
|
||||
coordinator: AmazonDevicesCoordinator,
|
||||
key: str,
|
||||
) -> None:
|
||||
"""Remove entity DND from virtual group."""
|
||||
entity_registry = er.async_get(hass)
|
||||
|
||||
for serial_num in coordinator.data:
|
||||
unique_id = f"{serial_num}-{key}"
|
||||
unique_id = f"{serial_num}-do_not_disturb"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
DOMAIN, SWITCH_DOMAIN, unique_id
|
||||
)
|
||||
@@ -107,7 +104,7 @@ async def async_remove_unsupported_notification_sensors(
|
||||
):
|
||||
unique_id = f"{serial_num}-{notification_key}"
|
||||
entity_id = entity_registry.async_get_entity_id(
|
||||
DOMAIN, SENSOR_DOMAIN, unique_id=unique_id
|
||||
domain=SENSOR_DOMAIN, platform=DOMAIN, unique_id=unique_id
|
||||
)
|
||||
is_unsupported = not coordinator.data[serial_num].notifications_supported
|
||||
|
||||
|
||||
@@ -2,16 +2,15 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pyatv.const import FeatureName, FeatureState, KeyboardFocusState
|
||||
from pyatv.const import KeyboardFocusState
|
||||
from pyatv.interface import AppleTV, KeyboardListener
|
||||
|
||||
from homeassistant.components.binary_sensor import BinarySensorEntity
|
||||
from homeassistant.const import CONF_NAME
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from . import SIGNAL_CONNECTED, AppleTvConfigEntry
|
||||
from . import AppleTvConfigEntry
|
||||
from .entity import AppleTVEntity
|
||||
|
||||
|
||||
@@ -22,22 +21,10 @@ async def async_setup_entry(
|
||||
) -> None:
|
||||
"""Load Apple TV binary sensor based on a config entry."""
|
||||
# apple_tv config entries always have a unique id
|
||||
assert config_entry.unique_id is not None
|
||||
name: str = config_entry.data[CONF_NAME]
|
||||
manager = config_entry.runtime_data
|
||||
cb: CALLBACK_TYPE
|
||||
|
||||
def setup_entities(atv: AppleTV) -> None:
|
||||
if atv.features.in_state(FeatureState.Available, FeatureName.TextFocusState):
|
||||
assert config_entry.unique_id is not None
|
||||
name: str = config_entry.data[CONF_NAME]
|
||||
async_add_entities(
|
||||
[AppleTVKeyboardFocused(name, config_entry.unique_id, manager)]
|
||||
)
|
||||
cb()
|
||||
|
||||
cb = async_dispatcher_connect(
|
||||
hass, f"{SIGNAL_CONNECTED}_{config_entry.unique_id}", setup_entities
|
||||
)
|
||||
config_entry.async_on_unload(cb)
|
||||
async_add_entities([AppleTVKeyboardFocused(name, config_entry.unique_id, manager)])
|
||||
|
||||
|
||||
class AppleTVKeyboardFocused(AppleTVEntity, BinarySensorEntity, KeyboardListener):
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/conversation",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.1.28"]
|
||||
"requirements": ["hassil==3.5.0", "home-assistant-intents==2026.2.3"]
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ from aioesphomeapi import (
|
||||
Event,
|
||||
EventInfo,
|
||||
FanInfo,
|
||||
InfraredInfo,
|
||||
LightInfo,
|
||||
LockInfo,
|
||||
MediaPlayerInfo,
|
||||
@@ -85,6 +86,7 @@ INFO_TYPE_TO_PLATFORM: dict[type[EntityInfo], Platform] = {
|
||||
DateTimeInfo: Platform.DATETIME,
|
||||
EventInfo: Platform.EVENT,
|
||||
FanInfo: Platform.FAN,
|
||||
InfraredInfo: Platform.INFRARED,
|
||||
LightInfo: Platform.LIGHT,
|
||||
LockInfo: Platform.LOCK,
|
||||
MediaPlayerInfo: Platform.MEDIA_PLAYER,
|
||||
@@ -520,6 +522,27 @@ class RuntimeEntryData:
|
||||
),
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_on_infrared_proxy_receive(
|
||||
self, hass: HomeAssistant, receive_event: Any
|
||||
) -> None:
|
||||
"""Handle an infrared proxy receive event."""
|
||||
# Fire a Home Assistant event with the infrared data
|
||||
device_info = self.device_info
|
||||
if not device_info:
|
||||
return
|
||||
|
||||
hass.bus.async_fire(
|
||||
f"{DOMAIN}_infrared_proxy_received",
|
||||
{
|
||||
"device_name": device_info.name,
|
||||
"device_mac": device_info.mac_address,
|
||||
"entry_id": self.entry_id,
|
||||
"key": receive_event.key,
|
||||
"timings": receive_event.timings,
|
||||
},
|
||||
)
|
||||
|
||||
@callback
|
||||
def async_register_assist_satellite_config_updated_callback(
|
||||
self,
|
||||
|
||||
100
homeassistant/components/esphome/infrared.py
Normal file
100
homeassistant/components/esphome/infrared.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""Infrared platform for ESPHome."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from aioesphomeapi import EntityInfo, EntityState, InfraredCapability, InfraredInfo
|
||||
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import entity_platform
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
|
||||
from .const import DOMAIN
|
||||
from .entity import EsphomeEntity, async_static_info_updated
|
||||
from .entry_data import ESPHomeConfigEntry
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PARALLEL_UPDATES = 0
|
||||
|
||||
|
||||
class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
|
||||
"""ESPHome infrared entity using native API."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Infrared Transmitter"
|
||||
|
||||
@callback
|
||||
def _on_device_update(self) -> None:
|
||||
"""Call when device updates or entry data changes."""
|
||||
super()._on_device_update()
|
||||
if self._entry_data.available:
|
||||
# Infrared entities should go available as soon as the device comes online
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command.
|
||||
|
||||
Raises:
|
||||
HomeAssistantError: If transmission fails.
|
||||
"""
|
||||
timings = [
|
||||
interval
|
||||
for timing in command.get_raw_timings()
|
||||
for interval in (timing.high_us, -timing.low_us)
|
||||
]
|
||||
_LOGGER.debug("Sending command: %s", timings)
|
||||
|
||||
try:
|
||||
self._client.infrared_rf_transmit_raw_timings(
|
||||
self._static_info.key,
|
||||
carrier_frequency=command.modulation,
|
||||
timings=timings,
|
||||
)
|
||||
except Exception as err:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="error_sending_ir_command",
|
||||
translation_placeholders={
|
||||
"device_name": self._device_info.name,
|
||||
"error": str(err),
|
||||
},
|
||||
) from err
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ESPHomeConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up ESPHome infrared entities, filtering out receiver-only devices."""
|
||||
entry_data = entry.runtime_data
|
||||
entry_data.info[InfraredInfo] = {}
|
||||
platform = entity_platform.async_get_current_platform()
|
||||
|
||||
def filtered_static_info_update(infos: list[EntityInfo]) -> None:
|
||||
transmitter_infos: list[EntityInfo] = [
|
||||
info
|
||||
for info in infos
|
||||
if isinstance(info, InfraredInfo)
|
||||
and info.capabilities & InfraredCapability.TRANSMITTER
|
||||
]
|
||||
async_static_info_updated(
|
||||
hass,
|
||||
entry_data,
|
||||
platform,
|
||||
async_add_entities,
|
||||
InfraredInfo,
|
||||
EsphomeInfraredEntity,
|
||||
EntityState,
|
||||
transmitter_infos,
|
||||
)
|
||||
|
||||
entry_data.cleanup_callbacks.append(
|
||||
entry_data.async_register_static_info_callback(
|
||||
InfraredInfo, filtered_static_info_update
|
||||
)
|
||||
)
|
||||
@@ -17,6 +17,8 @@ from aioesphomeapi import (
|
||||
EncryptionPlaintextAPIError,
|
||||
ExecuteServiceResponse,
|
||||
HomeassistantServiceCall,
|
||||
InfraredCapability,
|
||||
InfraredInfo,
|
||||
InvalidAuthAPIError,
|
||||
InvalidEncryptionKeyAPIError,
|
||||
LogLevel,
|
||||
@@ -692,6 +694,15 @@ class ESPHomeManager:
|
||||
cli.subscribe_zwave_proxy_request(self._async_zwave_proxy_request)
|
||||
)
|
||||
|
||||
if any(
|
||||
isinstance(info, InfraredInfo)
|
||||
and info.capabilities & InfraredCapability.RECEIVER
|
||||
for info in entity_infos
|
||||
):
|
||||
entry_data.disconnect_callbacks.add(
|
||||
cli.subscribe_infrared_rf_receive(self._async_infrared_proxy_receive)
|
||||
)
|
||||
|
||||
cli.subscribe_home_assistant_states_and_services(
|
||||
on_state=entry_data.async_update_state,
|
||||
on_service_call=self.async_on_service_call,
|
||||
@@ -722,6 +733,10 @@ class ESPHomeManager:
|
||||
self.hass, self.entry_data.device_info, zwave_home_id
|
||||
)
|
||||
|
||||
def _async_infrared_proxy_receive(self, receive_event: Any) -> None:
|
||||
"""Handle an infrared proxy receive event."""
|
||||
self.entry_data.async_on_infrared_proxy_receive(self.hass, receive_event)
|
||||
|
||||
async def on_disconnect(self, expected_disconnect: bool) -> None:
|
||||
"""Run disconnect callbacks on API disconnect."""
|
||||
entry_data = self.entry_data
|
||||
|
||||
@@ -137,6 +137,9 @@
|
||||
"error_compiling": {
|
||||
"message": "Error compiling {configuration}. Try again in ESPHome dashboard for more information."
|
||||
},
|
||||
"error_sending_ir_command": {
|
||||
"message": "Error sending IR command to {device_name}: {error}"
|
||||
},
|
||||
"error_uploading": {
|
||||
"message": "Error during OTA (Over-The-Air) update of {configuration}. Try again in ESPHome dashboard for more information."
|
||||
},
|
||||
|
||||
@@ -181,7 +181,7 @@ class EvoChild(EvoEntity):
|
||||
|
||||
self._device_state_attrs = {
|
||||
"activeFaults": self._evo_device.active_faults,
|
||||
"setpoints": self.setpoints,
|
||||
"setpoints": self._setpoints,
|
||||
}
|
||||
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
"""The Fressnapf Tracker integration."""
|
||||
|
||||
import logging
|
||||
|
||||
from fressnapftracker import (
|
||||
ApiClient,
|
||||
AuthClient,
|
||||
Device,
|
||||
FressnapfTrackerAuthenticationError,
|
||||
FressnapfTrackerError,
|
||||
FressnapfTrackerInvalidTrackerResponseError,
|
||||
Tracker,
|
||||
)
|
||||
from fressnapftracker import AuthClient, FressnapfTrackerAuthenticationError
|
||||
|
||||
from homeassistant.const import CONF_ACCESS_TOKEN, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.httpx_client import get_async_client
|
||||
from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue
|
||||
|
||||
from .const import CONF_USER_ID, DOMAIN
|
||||
from .coordinator import (
|
||||
@@ -32,43 +21,6 @@ PLATFORMS: list[Platform] = [
|
||||
Platform.SWITCH,
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def _get_valid_tracker(hass: HomeAssistant, device: Device) -> Tracker | None:
|
||||
"""Test if the tracker returns valid data and return it.
|
||||
|
||||
Malformed data might indicate the tracker is broken or hasn't been properly registered with the app.
|
||||
"""
|
||||
client = ApiClient(
|
||||
serial_number=device.serialnumber,
|
||||
device_token=device.token,
|
||||
client=get_async_client(hass),
|
||||
)
|
||||
try:
|
||||
return await client.get_tracker()
|
||||
except FressnapfTrackerInvalidTrackerResponseError:
|
||||
_LOGGER.warning(
|
||||
"Tracker with serialnumber %s is invalid. Consider removing it via the App",
|
||||
device.serialnumber,
|
||||
)
|
||||
async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"invalid_fressnapf_tracker_{device.serialnumber}",
|
||||
issue_domain=DOMAIN,
|
||||
learn_more_url="https://www.home-assistant.io/integrations/fressnapf_tracker/",
|
||||
is_fixable=False,
|
||||
severity=IssueSeverity.WARNING,
|
||||
translation_key="invalid_fressnapf_tracker",
|
||||
translation_placeholders={
|
||||
"tracker_id": device.serialnumber,
|
||||
},
|
||||
)
|
||||
return None
|
||||
except FressnapfTrackerError as err:
|
||||
raise ConfigEntryNotReady(err) from err
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant, entry: FressnapfTrackerConfigEntry
|
||||
@@ -88,15 +40,12 @@ async def async_setup_entry(
|
||||
|
||||
coordinators: list[FressnapfTrackerDataUpdateCoordinator] = []
|
||||
for device in devices:
|
||||
tracker = await _get_valid_tracker(hass, device)
|
||||
if tracker is None:
|
||||
continue
|
||||
coordinator = FressnapfTrackerDataUpdateCoordinator(
|
||||
hass,
|
||||
entry,
|
||||
device,
|
||||
initial_data=tracker,
|
||||
)
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
coordinators.append(coordinator)
|
||||
|
||||
entry.runtime_data = coordinators
|
||||
|
||||
@@ -34,7 +34,6 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
|
||||
hass: HomeAssistant,
|
||||
config_entry: FressnapfTrackerConfigEntry,
|
||||
device: Device,
|
||||
initial_data: Tracker,
|
||||
) -> None:
|
||||
"""Initialize."""
|
||||
super().__init__(
|
||||
@@ -50,7 +49,6 @@ class FressnapfTrackerDataUpdateCoordinator(DataUpdateCoordinator[Tracker]):
|
||||
device_token=device.token,
|
||||
client=get_async_client(hass),
|
||||
)
|
||||
self.data = initial_data
|
||||
|
||||
async def _async_update_data(self) -> Tracker:
|
||||
try:
|
||||
|
||||
@@ -92,11 +92,5 @@
|
||||
"not_seen_recently": {
|
||||
"message": "The flashlight cannot be activated when the tracker has not moved recently."
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"invalid_fressnapf_tracker": {
|
||||
"description": "The Fressnapf GPS tracker with the serial number `{tracker_id}` is sending invalid data. This most likely indicates the tracker is broken or hasn't been properly registered with the app. Please remove the tracker via the Fressnapf app. You can then close this issue.",
|
||||
"title": "Invalid Fressnapf GPS tracker detected"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["google_air_quality_api"],
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["google_air_quality_api==3.0.1"]
|
||||
"requirements": ["google_air_quality_api==3.0.0"]
|
||||
}
|
||||
|
||||
156
homeassistant/components/infrared/__init__.py
Normal file
156
homeassistant/components/infrared/__init__.py
Normal file
@@ -0,0 +1,156 @@
|
||||
"""Provides functionality to interact with infrared devices."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
from datetime import datetime, timedelta
|
||||
import logging
|
||||
from typing import final
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import Context, HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.helpers import config_validation as cv, entity_registry as er
|
||||
from homeassistant.helpers.entity import EntityDescription
|
||||
from homeassistant.helpers.entity_component import EntityComponent
|
||||
from homeassistant.helpers.restore_state import RestoreEntity
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
from homeassistant.util import dt as dt_util
|
||||
from homeassistant.util.hass_dict import HassKey
|
||||
|
||||
from .const import DOMAIN
|
||||
from .protocols import InfraredCommand, NECInfraredCommand, Timing
|
||||
|
||||
__all__ = [
|
||||
"DOMAIN",
|
||||
"InfraredCommand",
|
||||
"InfraredEntity",
|
||||
"InfraredEntityDescription",
|
||||
"NECInfraredCommand",
|
||||
"Timing",
|
||||
"async_get_emitters",
|
||||
"async_send_command",
|
||||
]
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
DATA_COMPONENT: HassKey[EntityComponent[InfraredEntity]] = HassKey(DOMAIN)
|
||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||
PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA
|
||||
PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE
|
||||
SCAN_INTERVAL = timedelta(seconds=30)
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up the infrared domain."""
|
||||
component = hass.data[DATA_COMPONENT] = EntityComponent[InfraredEntity](
|
||||
_LOGGER, DOMAIN, hass, SCAN_INTERVAL
|
||||
)
|
||||
await component.async_setup(config)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_setup_entry(entry)
|
||||
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Unload a config entry."""
|
||||
return await hass.data[DATA_COMPONENT].async_unload_entry(entry)
|
||||
|
||||
|
||||
@callback
|
||||
def async_get_emitters(hass: HomeAssistant) -> list[InfraredEntity]:
|
||||
"""Get all infrared emitters."""
|
||||
component = hass.data.get(DATA_COMPONENT)
|
||||
if component is None:
|
||||
return []
|
||||
|
||||
return list(component.entities)
|
||||
|
||||
|
||||
async def async_send_command(
|
||||
hass: HomeAssistant,
|
||||
entity_uuid: str,
|
||||
command: InfraredCommand,
|
||||
context: Context | None = None,
|
||||
) -> None:
|
||||
"""Send an IR command to the specified infrared entity.
|
||||
|
||||
Raises:
|
||||
HomeAssistantError: If the infrared entity is not found.
|
||||
"""
|
||||
component = hass.data.get(DATA_COMPONENT)
|
||||
if component is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="component_not_loaded",
|
||||
)
|
||||
|
||||
ent_reg = er.async_get(hass)
|
||||
entity_id = er.async_validate_entity_id(ent_reg, entity_uuid)
|
||||
entity = component.get_entity(entity_id)
|
||||
if entity is None:
|
||||
raise HomeAssistantError(
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="entity_not_found",
|
||||
translation_placeholders={"entity_id": entity_id},
|
||||
)
|
||||
|
||||
if context is not None:
|
||||
entity.async_set_context(context)
|
||||
|
||||
await entity.async_send_command_internal(command)
|
||||
|
||||
|
||||
class InfraredEntityDescription(EntityDescription, frozen_or_thawed=True):
|
||||
"""Describes infrared entities."""
|
||||
|
||||
|
||||
class InfraredEntity(RestoreEntity):
|
||||
"""Base class for infrared transmitter entities."""
|
||||
|
||||
entity_description: InfraredEntityDescription
|
||||
_attr_should_poll = False
|
||||
_attr_state: None
|
||||
|
||||
__last_command_sent: datetime | None = None
|
||||
|
||||
@property
|
||||
@final
|
||||
def state(self) -> str | None:
|
||||
"""Return the entity state."""
|
||||
if (last_command := self.__last_command_sent) is None:
|
||||
return None
|
||||
return last_command.isoformat(timespec="milliseconds")
|
||||
|
||||
@final
|
||||
async def async_send_command_internal(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command and update state.
|
||||
|
||||
Should not be overridden, handles setting last sent timestamp.
|
||||
"""
|
||||
await self.async_send_command(command)
|
||||
self.__last_command_sent = dt_util.utcnow()
|
||||
self.async_write_ha_state()
|
||||
|
||||
@final
|
||||
async def async_internal_added_to_hass(self) -> None:
|
||||
"""Call when the infrared entity is added to hass."""
|
||||
await super().async_internal_added_to_hass()
|
||||
state = await self.async_get_last_state()
|
||||
if state is not None and state.state is not None:
|
||||
self.__last_command_sent = dt_util.parse_datetime(state.state)
|
||||
|
||||
@abstractmethod
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Send an IR command.
|
||||
|
||||
Args:
|
||||
command: The IR command to send.
|
||||
|
||||
Raises:
|
||||
HomeAssistantError: If transmission fails.
|
||||
"""
|
||||
5
homeassistant/components/infrared/const.py
Normal file
5
homeassistant/components/infrared/const.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""Constants for the Infrared integration."""
|
||||
|
||||
from typing import Final
|
||||
|
||||
DOMAIN: Final = "infrared"
|
||||
7
homeassistant/components/infrared/icons.json
Normal file
7
homeassistant/components/infrared/icons.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"entity_component": {
|
||||
"_": {
|
||||
"default": "mdi:led-on"
|
||||
}
|
||||
}
|
||||
}
|
||||
8
homeassistant/components/infrared/manifest.json
Normal file
8
homeassistant/components/infrared/manifest.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"domain": "infrared",
|
||||
"name": "Infrared",
|
||||
"codeowners": ["@home-assistant/core"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/infrared",
|
||||
"integration_type": "entity",
|
||||
"quality_scale": "internal"
|
||||
}
|
||||
124
homeassistant/components/infrared/protocols.py
Normal file
124
homeassistant/components/infrared/protocols.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""IR protocol definitions for the Infrared integration."""
|
||||
|
||||
import abc
|
||||
from dataclasses import dataclass
|
||||
from typing import override
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Timing:
|
||||
"""High/low signal timing."""
|
||||
|
||||
high_us: int
|
||||
low_us: int
|
||||
|
||||
|
||||
class InfraredCommand(abc.ABC):
|
||||
"""Base class for IR commands."""
|
||||
|
||||
repeat_count: int
|
||||
modulation: int
|
||||
|
||||
def __init__(self, *, modulation: int, repeat_count: int = 0) -> None:
|
||||
"""Initialize the IR command."""
|
||||
self.modulation = modulation
|
||||
self.repeat_count = repeat_count
|
||||
|
||||
@abc.abstractmethod
|
||||
def get_raw_timings(self) -> list[Timing]:
|
||||
"""Get raw timings for the command."""
|
||||
|
||||
|
||||
class NECInfraredCommand(InfraredCommand):
|
||||
"""NEC IR command."""
|
||||
|
||||
address: int
|
||||
command: int
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
address: int,
|
||||
command: int,
|
||||
modulation: int = 38000,
|
||||
repeat_count: int = 0,
|
||||
) -> None:
|
||||
"""Initialize the NEC IR command."""
|
||||
super().__init__(modulation=modulation, repeat_count=repeat_count)
|
||||
self.address = address
|
||||
self.command = command
|
||||
|
||||
@override
|
||||
def get_raw_timings(self) -> list[Timing]:
|
||||
"""Get raw timings for the NEC command.
|
||||
|
||||
NEC protocol timing (in microseconds):
|
||||
- Leader pulse: 9000µs high, 4500µs low
|
||||
- Logical '0': 562µs high, 562µs low
|
||||
- Logical '1': 562µs high, 1687µs low
|
||||
- End pulse: 562µs high
|
||||
- Repeat code: 9000µs high, 2250µs low, 562µs end pulse
|
||||
- Frame gap: ~96ms between end pulse and next frame (total frame ~108ms)
|
||||
|
||||
Data format (32 bits, LSB first):
|
||||
- Standard NEC: address (8-bit) + ~address (8-bit) + command (8-bit) + ~command (8-bit)
|
||||
- Extended NEC: address_low (8-bit) + address_high (8-bit) + command (8-bit) + ~command (8-bit)
|
||||
"""
|
||||
# NEC timing constants (microseconds)
|
||||
leader_high = 9000
|
||||
leader_low = 4500
|
||||
bit_high = 562
|
||||
zero_low = 562
|
||||
one_low = 1687
|
||||
repeat_low = 2250
|
||||
frame_gap = 96000 # Gap to make total frame ~108ms
|
||||
|
||||
timings: list[Timing] = [Timing(high_us=leader_high, low_us=leader_low)]
|
||||
|
||||
# Determine if standard (8-bit) or extended (16-bit) address
|
||||
if self.address <= 0xFF:
|
||||
# Standard NEC: address + inverted address
|
||||
address_low = self.address & 0xFF
|
||||
address_high = (~self.address) & 0xFF
|
||||
else:
|
||||
# Extended NEC: 16-bit address (no inversion)
|
||||
address_low = self.address & 0xFF
|
||||
address_high = (self.address >> 8) & 0xFF
|
||||
|
||||
command_byte = self.command & 0xFF
|
||||
command_inverted = (~self.command) & 0xFF
|
||||
|
||||
# Build 32-bit command data (LSB first in transmission)
|
||||
data = (
|
||||
address_low
|
||||
| (address_high << 8)
|
||||
| (command_byte << 16)
|
||||
| (command_inverted << 24)
|
||||
)
|
||||
|
||||
for _ in range(32):
|
||||
bit = data & 1
|
||||
if bit:
|
||||
timings.append(Timing(high_us=bit_high, low_us=one_low))
|
||||
else:
|
||||
timings.append(Timing(high_us=bit_high, low_us=zero_low))
|
||||
data >>= 1
|
||||
|
||||
# End pulse
|
||||
timings.append(Timing(high_us=bit_high, low_us=0))
|
||||
|
||||
# Add repeat codes if requested
|
||||
for _ in range(self.repeat_count):
|
||||
# Replace the last timing's low_us with the frame gap
|
||||
last_timing = timings[-1]
|
||||
timings[-1] = Timing(high_us=last_timing.high_us, low_us=frame_gap)
|
||||
|
||||
# Repeat code: leader burst + shorter space + end pulse
|
||||
timings.extend(
|
||||
[
|
||||
Timing(high_us=leader_high, low_us=repeat_low),
|
||||
Timing(high_us=bit_high, low_us=0),
|
||||
]
|
||||
)
|
||||
|
||||
return timings
|
||||
10
homeassistant/components/infrared/strings.json
Normal file
10
homeassistant/components/infrared/strings.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"exceptions": {
|
||||
"component_not_loaded": {
|
||||
"message": "Infrared component not loaded"
|
||||
},
|
||||
"entity_not_found": {
|
||||
"message": "Infrared entity `{entity_id}` not found"
|
||||
}
|
||||
}
|
||||
}
|
||||
20
homeassistant/components/lg_infrared/__init__.py
Normal file
20
homeassistant/components/lg_infrared/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""LG IR Remote integration for Home Assistant."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
PLATFORMS = [Platform.BUTTON, Platform.MEDIA_PLAYER]
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up LG 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 a LG IR config entry."""
|
||||
return await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
256
homeassistant/components/lg_infrared/button.py
Normal file
256
homeassistant/components/lg_infrared/button.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Button platform for LG IR integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from homeassistant.components.button import ButtonEntity, ButtonEntityDescription
|
||||
from homeassistant.components.infrared import NECInfraredCommand, async_send_command
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .const import (
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_INFRARED_ENTITY_ID,
|
||||
DOMAIN,
|
||||
LG_ADDRESS,
|
||||
LGDeviceType,
|
||||
LGTVCommand,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, kw_only=True)
|
||||
class LgIrButtonEntityDescription(ButtonEntityDescription):
|
||||
"""Describes LG IR button entity."""
|
||||
|
||||
command_code: int
|
||||
|
||||
|
||||
TV_BUTTON_DESCRIPTIONS: tuple[LgIrButtonEntityDescription, ...] = (
|
||||
LgIrButtonEntityDescription(
|
||||
key="power_on",
|
||||
translation_key="power_on",
|
||||
command_code=LGTVCommand.POWER_ON,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="power_off",
|
||||
translation_key="power_off",
|
||||
command_code=LGTVCommand.POWER_OFF,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="hdmi_1",
|
||||
translation_key="hdmi_1",
|
||||
command_code=LGTVCommand.HDMI_1,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="hdmi_2",
|
||||
translation_key="hdmi_2",
|
||||
command_code=LGTVCommand.HDMI_2,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="hdmi_3",
|
||||
translation_key="hdmi_3",
|
||||
command_code=LGTVCommand.HDMI_3,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="hdmi_4",
|
||||
translation_key="hdmi_4",
|
||||
command_code=LGTVCommand.HDMI_4,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="exit",
|
||||
translation_key="exit",
|
||||
command_code=LGTVCommand.EXIT,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="info",
|
||||
translation_key="info",
|
||||
command_code=LGTVCommand.INFO,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="guide",
|
||||
translation_key="guide",
|
||||
command_code=LGTVCommand.GUIDE,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="up",
|
||||
translation_key="up",
|
||||
command_code=LGTVCommand.NAV_UP,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="down",
|
||||
translation_key="down",
|
||||
command_code=LGTVCommand.NAV_DOWN,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="left",
|
||||
translation_key="left",
|
||||
command_code=LGTVCommand.NAV_LEFT,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="right",
|
||||
translation_key="right",
|
||||
command_code=LGTVCommand.NAV_RIGHT,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="ok",
|
||||
translation_key="ok",
|
||||
command_code=LGTVCommand.OK,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="back",
|
||||
translation_key="back",
|
||||
command_code=LGTVCommand.BACK,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="home",
|
||||
translation_key="home",
|
||||
command_code=LGTVCommand.HOME,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="menu",
|
||||
translation_key="menu",
|
||||
command_code=LGTVCommand.MENU,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="input",
|
||||
translation_key="input",
|
||||
command_code=LGTVCommand.INPUT,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_0",
|
||||
translation_key="num_0",
|
||||
command_code=LGTVCommand.NUM_0,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_1",
|
||||
translation_key="num_1",
|
||||
command_code=LGTVCommand.NUM_1,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_2",
|
||||
translation_key="num_2",
|
||||
command_code=LGTVCommand.NUM_2,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_3",
|
||||
translation_key="num_3",
|
||||
command_code=LGTVCommand.NUM_3,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_4",
|
||||
translation_key="num_4",
|
||||
command_code=LGTVCommand.NUM_4,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_5",
|
||||
translation_key="num_5",
|
||||
command_code=LGTVCommand.NUM_5,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_6",
|
||||
translation_key="num_6",
|
||||
command_code=LGTVCommand.NUM_6,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_7",
|
||||
translation_key="num_7",
|
||||
command_code=LGTVCommand.NUM_7,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_8",
|
||||
translation_key="num_8",
|
||||
command_code=LGTVCommand.NUM_8,
|
||||
),
|
||||
LgIrButtonEntityDescription(
|
||||
key="num_9",
|
||||
translation_key="num_9",
|
||||
command_code=LGTVCommand.NUM_9,
|
||||
),
|
||||
)
|
||||
|
||||
HIFI_BUTTON_DESCRIPTIONS: tuple[LgIrButtonEntityDescription, ...] = (
|
||||
LgIrButtonEntityDescription(
|
||||
key="power_on",
|
||||
translation_key="power_on",
|
||||
command_code=LGTVCommand.POWER_ON,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LG IR buttons from config entry."""
|
||||
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
|
||||
device_type = entry.data.get(CONF_DEVICE_TYPE, LGDeviceType.TV)
|
||||
if device_type == LGDeviceType.TV:
|
||||
async_add_entities(
|
||||
LgIrButton(entry, infrared_entity_id, description)
|
||||
for description in TV_BUTTON_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
class LgIrButton(ButtonEntity):
|
||||
"""LG IR button entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_description: LgIrButtonEntityDescription
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
entry: ConfigEntry,
|
||||
infrared_entity_id: str,
|
||||
description: LgIrButtonEntityDescription,
|
||||
) -> None:
|
||||
"""Initialize LG IR button."""
|
||||
self._entry = entry
|
||||
self._infrared_entity_id = infrared_entity_id
|
||||
self._description = description
|
||||
self.entity_description = description
|
||||
self._attr_unique_id = f"{entry.entry_id}_{description.key}"
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)}, name="LG TV", manufacturer="LG"
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to infrared entity state changes."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle infrared entity state changes."""
|
||||
new_state = event.data["new_state"]
|
||||
self._attr_available = (
|
||||
new_state is not None and new_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass, [self._infrared_entity_id], _async_ir_state_changed
|
||||
)
|
||||
)
|
||||
|
||||
# Set initial availability based on current infrared entity state
|
||||
ir_state = self.hass.states.get(self._infrared_entity_id)
|
||||
self._attr_available = (
|
||||
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""Press the button."""
|
||||
command = NECInfraredCommand(
|
||||
address=LG_ADDRESS,
|
||||
command=self._description.command_code,
|
||||
repeat_count=1,
|
||||
)
|
||||
await async_send_command(
|
||||
self.hass, self._infrared_entity_id, command, context=self._context
|
||||
)
|
||||
82
homeassistant/components/lg_infrared/config_flow.py
Normal file
82
homeassistant/components/lg_infrared/config_flow.py
Normal file
@@ -0,0 +1,82 @@
|
||||
"""Config flow for LG IR 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.helpers.selector import (
|
||||
EntitySelector,
|
||||
EntitySelectorConfig,
|
||||
SelectSelector,
|
||||
SelectSelectorConfig,
|
||||
SelectSelectorMode,
|
||||
)
|
||||
|
||||
from .const import CONF_DEVICE_TYPE, CONF_INFRARED_ENTITY_ID, DOMAIN, LGDeviceType
|
||||
|
||||
DEVICE_TYPE_NAMES: dict[LGDeviceType, str] = {
|
||||
LGDeviceType.TV: "TV",
|
||||
}
|
||||
|
||||
|
||||
class LgIrConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Handle config flow for LG IR."""
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> ConfigFlowResult:
|
||||
"""Handle the initial step."""
|
||||
entities = async_get_emitters(self.hass)
|
||||
if not entities:
|
||||
return self.async_abort(reason="no_emitters")
|
||||
|
||||
valid_entity_ids = [entity.entity_id for entity in entities]
|
||||
|
||||
if user_input is not None:
|
||||
entity_id = user_input[CONF_INFRARED_ENTITY_ID]
|
||||
device_type = user_input[CONF_DEVICE_TYPE]
|
||||
|
||||
await self.async_set_unique_id(f"lg_ir_{device_type}_{entity_id}")
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Get entity name for the title
|
||||
entity_name = next(
|
||||
(
|
||||
entity.name or entity.entity_id
|
||||
for entity in entities
|
||||
if entity.entity_id == entity_id
|
||||
),
|
||||
entity_id,
|
||||
)
|
||||
device_type_name = DEVICE_TYPE_NAMES[LGDeviceType(device_type)]
|
||||
title = f"LG {device_type_name} via {entity_name}"
|
||||
|
||||
return self.async_create_entry(title=title, data=user_input)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="user",
|
||||
data_schema=vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_DEVICE_TYPE): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[device_type.value for device_type in LGDeviceType],
|
||||
translation_key=CONF_DEVICE_TYPE,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
)
|
||||
),
|
||||
vol.Required(CONF_INFRARED_ENTITY_ID): EntitySelector(
|
||||
EntitySelectorConfig(
|
||||
domain=INFRARED_DOMAIN,
|
||||
include_entities=valid_entity_ids,
|
||||
)
|
||||
),
|
||||
}
|
||||
),
|
||||
)
|
||||
59
homeassistant/components/lg_infrared/const.py
Normal file
59
homeassistant/components/lg_infrared/const.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""Constants for the LG IR integration."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
DOMAIN = "lg_infrared"
|
||||
CONF_INFRARED_ENTITY_ID = "infrared_entity_id"
|
||||
CONF_DEVICE_TYPE = "device_type"
|
||||
|
||||
LG_ADDRESS = 0xFB04
|
||||
|
||||
|
||||
class LGDeviceType(StrEnum):
|
||||
"""LG device types."""
|
||||
|
||||
TV = "tv"
|
||||
|
||||
|
||||
class LGTVCommand:
|
||||
"""LG TV IR command codes."""
|
||||
|
||||
BACK = 0xD728
|
||||
CHANNEL_DOWN = 0xFE01
|
||||
CHANNEL_UP = 0xFF00
|
||||
EXIT = 0xA45B
|
||||
FAST_FORWARD = 0x718E
|
||||
GUIDE = 0x56A9
|
||||
HDMI_1 = 0x31CE
|
||||
HDMI_2 = 0x33CC
|
||||
HDMI_3 = 0x16E9
|
||||
HDMI_4 = 0x25DA
|
||||
HOME = 0x837C
|
||||
INFO = 0x55AA
|
||||
INPUT = 0xF40B
|
||||
MENU = 0xBC43
|
||||
MUTE = 0xF609
|
||||
NAV_DOWN = 0xBE41
|
||||
NAV_LEFT = 0xF807
|
||||
NAV_RIGHT = 0xF906
|
||||
NAV_UP = 0xBF40
|
||||
NUM_0 = 0xEF10
|
||||
NUM_1 = 0xEE11
|
||||
NUM_2 = 0xED12
|
||||
NUM_3 = 0xEC13
|
||||
NUM_4 = 0xEB14
|
||||
NUM_5 = 0xEA15
|
||||
NUM_6 = 0xE916
|
||||
NUM_7 = 0xE817
|
||||
NUM_8 = 0xE718
|
||||
NUM_9 = 0xE619
|
||||
OK = 0xBB44
|
||||
PAUSE = 0x45BA
|
||||
PLAY = 0x4FB0
|
||||
POWER = 0xF708
|
||||
POWER_ON = 0x3BC4
|
||||
POWER_OFF = 0x3AC5
|
||||
REWIND = 0x708F
|
||||
STOP = 0x4EB1
|
||||
VOLUME_DOWN = 0xFC03
|
||||
VOLUME_UP = 0xFD02
|
||||
11
homeassistant/components/lg_infrared/manifest.json
Normal file
11
homeassistant/components/lg_infrared/manifest.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"domain": "lg_infrared",
|
||||
"name": "LG Infrared",
|
||||
"codeowners": [],
|
||||
"config_flow": true,
|
||||
"dependencies": ["infrared"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/lg_infrared",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_push",
|
||||
"quality_scale": "bronze"
|
||||
}
|
||||
142
homeassistant/components/lg_infrared/media_player.py
Normal file
142
homeassistant/components/lg_infrared/media_player.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""Media player platform for LG IR integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from homeassistant.components.infrared import NECInfraredCommand, async_send_command
|
||||
from homeassistant.components.media_player import (
|
||||
MediaPlayerDeviceClass,
|
||||
MediaPlayerEntity,
|
||||
MediaPlayerEntityFeature,
|
||||
MediaPlayerState,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import Event, EventStateChangedData, HomeAssistant, callback
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_state_change_event
|
||||
|
||||
from .const import (
|
||||
CONF_DEVICE_TYPE,
|
||||
CONF_INFRARED_ENTITY_ID,
|
||||
DOMAIN,
|
||||
LG_ADDRESS,
|
||||
LGDeviceType,
|
||||
LGTVCommand,
|
||||
)
|
||||
|
||||
|
||||
async def async_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
entry: ConfigEntry,
|
||||
async_add_entities: AddConfigEntryEntitiesCallback,
|
||||
) -> None:
|
||||
"""Set up LG IR media player from config entry."""
|
||||
infrared_entity_id = entry.data[CONF_INFRARED_ENTITY_ID]
|
||||
device_type = entry.data.get(CONF_DEVICE_TYPE, LGDeviceType.TV)
|
||||
if device_type == LGDeviceType.TV:
|
||||
async_add_entities([LgIrTvMediaPlayer(entry, infrared_entity_id)])
|
||||
|
||||
|
||||
class LgIrTvMediaPlayer(MediaPlayerEntity):
|
||||
"""LG IR media player entity."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = None
|
||||
_attr_assumed_state = True
|
||||
_attr_device_class = MediaPlayerDeviceClass.TV
|
||||
_attr_supported_features = (
|
||||
MediaPlayerEntityFeature.TURN_ON
|
||||
| MediaPlayerEntityFeature.TURN_OFF
|
||||
| MediaPlayerEntityFeature.VOLUME_STEP
|
||||
| MediaPlayerEntityFeature.VOLUME_MUTE
|
||||
| MediaPlayerEntityFeature.NEXT_TRACK
|
||||
| MediaPlayerEntityFeature.PREVIOUS_TRACK
|
||||
| MediaPlayerEntityFeature.PLAY
|
||||
| MediaPlayerEntityFeature.PAUSE
|
||||
| MediaPlayerEntityFeature.STOP
|
||||
)
|
||||
|
||||
def __init__(self, entry: ConfigEntry, infrared_entity_id: str) -> None:
|
||||
"""Initialize LG IR media player."""
|
||||
self._entry = entry
|
||||
self._infrared_entity_id = infrared_entity_id
|
||||
self._attr_unique_id = f"{entry.entry_id}_media_player"
|
||||
self._attr_state = MediaPlayerState.ON
|
||||
self._attr_device_info = DeviceInfo(
|
||||
identifiers={(DOMAIN, entry.entry_id)}, name="LG TV", manufacturer="LG"
|
||||
)
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Subscribe to infrared entity state changes."""
|
||||
await super().async_added_to_hass()
|
||||
|
||||
@callback
|
||||
def _async_ir_state_changed(event: Event[EventStateChangedData]) -> None:
|
||||
"""Handle infrared entity state changes."""
|
||||
new_state = event.data["new_state"]
|
||||
self._attr_available = (
|
||||
new_state is not None and new_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
self.async_write_ha_state()
|
||||
|
||||
self.async_on_remove(
|
||||
async_track_state_change_event(
|
||||
self.hass, [self._infrared_entity_id], _async_ir_state_changed
|
||||
)
|
||||
)
|
||||
|
||||
# Set initial availability based on current infrared entity state
|
||||
ir_state = self.hass.states.get(self._infrared_entity_id)
|
||||
self._attr_available = (
|
||||
ir_state is not None and ir_state.state != STATE_UNAVAILABLE
|
||||
)
|
||||
|
||||
async def _send_command(self, command_code: int, repeat_count: int = 1) -> None:
|
||||
"""Send an IR command using the LG protocol."""
|
||||
command = NECInfraredCommand(
|
||||
address=LG_ADDRESS, command=command_code, repeat_count=repeat_count
|
||||
)
|
||||
await async_send_command(
|
||||
self.hass, self._infrared_entity_id, command, context=self._context
|
||||
)
|
||||
|
||||
async def async_turn_on(self) -> None:
|
||||
"""Turn on the TV."""
|
||||
await self._send_command(LGTVCommand.POWER)
|
||||
|
||||
async def async_turn_off(self) -> None:
|
||||
"""Turn off the TV."""
|
||||
await self._send_command(LGTVCommand.POWER)
|
||||
|
||||
async def async_volume_up(self) -> None:
|
||||
"""Send volume up command."""
|
||||
await self._send_command(LGTVCommand.VOLUME_UP)
|
||||
|
||||
async def async_volume_down(self) -> None:
|
||||
"""Send volume down command."""
|
||||
await self._send_command(LGTVCommand.VOLUME_DOWN)
|
||||
|
||||
async def async_mute_volume(self, mute: bool) -> None:
|
||||
"""Send mute command."""
|
||||
await self._send_command(LGTVCommand.MUTE)
|
||||
|
||||
async def async_media_next_track(self) -> None:
|
||||
"""Send channel up command."""
|
||||
await self._send_command(LGTVCommand.CHANNEL_UP)
|
||||
|
||||
async def async_media_previous_track(self) -> None:
|
||||
"""Send channel down command."""
|
||||
await self._send_command(LGTVCommand.CHANNEL_DOWN)
|
||||
|
||||
async def async_media_play(self) -> None:
|
||||
"""Send play command."""
|
||||
await self._send_command(LGTVCommand.PLAY)
|
||||
|
||||
async def async_media_pause(self) -> None:
|
||||
"""Send pause command."""
|
||||
await self._send_command(LGTVCommand.PAUSE)
|
||||
|
||||
async def async_media_stop(self) -> None:
|
||||
"""Send stop command."""
|
||||
await self._send_command(LGTVCommand.STOP)
|
||||
127
homeassistant/components/lg_infrared/quality_scale.yaml
Normal file
127
homeassistant/components/lg_infrared/quality_scale.yaml
Normal file
@@ -0,0 +1,127 @@
|
||||
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:
|
||||
status: exempt
|
||||
comment: |
|
||||
This is a proof of concept integration, brand assets will be added later.
|
||||
common-modules:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration is simple and does not share patterns with others.
|
||||
config-flow-test-coverage:
|
||||
status: exempt
|
||||
comment: |
|
||||
This is a proof of concept integration, config flow tests will be added later.
|
||||
config-flow: done
|
||||
dependency-transparency: done
|
||||
docs-actions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not provide additional actions.
|
||||
docs-high-level-description:
|
||||
status: exempt
|
||||
comment: |
|
||||
This is a proof of concept integration, documentation will be added later.
|
||||
docs-installation-instructions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This is a proof of concept integration, documentation will be added later.
|
||||
docs-removal-instructions:
|
||||
status: exempt
|
||||
comment: |
|
||||
This is a proof of concept integration, documentation will be added later.
|
||||
entity-event-setup:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not subscribe to events.
|
||||
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: |
|
||||
Setup validation is handled by checking emitter existence in remote.py.
|
||||
unique-config-entry: done
|
||||
# Silver
|
||||
action-exceptions: todo
|
||||
config-entry-unloading: done
|
||||
docs-configuration-parameters: todo
|
||||
docs-installation-parameters: todo
|
||||
entity-unavailable: done
|
||||
integration-owner: todo
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: todo
|
||||
reauthentication-flow:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not require authentication.
|
||||
test-coverage: todo
|
||||
# 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: todo
|
||||
docs-examples: todo
|
||||
docs-known-limitations: todo
|
||||
docs-supported-devices: todo
|
||||
docs-supported-functions: todo
|
||||
docs-troubleshooting: todo
|
||||
docs-use-cases: todo
|
||||
dynamic-devices:
|
||||
status: exempt
|
||||
comment: |
|
||||
Each config entry creates a single device.
|
||||
entity-category:
|
||||
status: exempt
|
||||
comment: |
|
||||
The remote entity is the primary entity and does not need a category.
|
||||
entity-device-class:
|
||||
status: exempt
|
||||
comment: |
|
||||
Remote entities do not have a device class.
|
||||
entity-disabled-by-default:
|
||||
status: exempt
|
||||
comment: |
|
||||
The remote entity is the primary entity and should be enabled by default.
|
||||
entity-translations: todo
|
||||
exception-translations: todo
|
||||
icon-translations: todo
|
||||
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 only depends on ir_proxy which is part of Home Assistant.
|
||||
inject-websession:
|
||||
status: exempt
|
||||
comment: |
|
||||
This integration does not make HTTP requests.
|
||||
strict-typing: todo
|
||||
114
homeassistant/components/lg_infrared/strings.json
Normal file
114
homeassistant/components/lg_infrared/strings.json
Normal file
@@ -0,0 +1,114 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"already_configured": "This LG 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": {
|
||||
"device_type": "Device type",
|
||||
"infrared_entity_id": "Infrared transmitter"
|
||||
},
|
||||
"description": "Select the device type and the infrared transmitter entity to use for controlling your LG device.",
|
||||
"title": "Set up LG IR Remote"
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"back": {
|
||||
"name": "Back"
|
||||
},
|
||||
"down": {
|
||||
"name": "Down"
|
||||
},
|
||||
"exit": {
|
||||
"name": "Exit"
|
||||
},
|
||||
"guide": {
|
||||
"name": "Guide"
|
||||
},
|
||||
"hdmi_1": {
|
||||
"name": "HDMI 1"
|
||||
},
|
||||
"hdmi_2": {
|
||||
"name": "HDMI 2"
|
||||
},
|
||||
"hdmi_3": {
|
||||
"name": "HDMI 3"
|
||||
},
|
||||
"hdmi_4": {
|
||||
"name": "HDMI 4"
|
||||
},
|
||||
"home": {
|
||||
"name": "Home"
|
||||
},
|
||||
"info": {
|
||||
"name": "Info"
|
||||
},
|
||||
"input": {
|
||||
"name": "Input"
|
||||
},
|
||||
"left": {
|
||||
"name": "Left"
|
||||
},
|
||||
"menu": {
|
||||
"name": "Menu"
|
||||
},
|
||||
"num_0": {
|
||||
"name": "0"
|
||||
},
|
||||
"num_1": {
|
||||
"name": "1"
|
||||
},
|
||||
"num_2": {
|
||||
"name": "2"
|
||||
},
|
||||
"num_3": {
|
||||
"name": "3"
|
||||
},
|
||||
"num_4": {
|
||||
"name": "4"
|
||||
},
|
||||
"num_5": {
|
||||
"name": "5"
|
||||
},
|
||||
"num_6": {
|
||||
"name": "6"
|
||||
},
|
||||
"num_7": {
|
||||
"name": "7"
|
||||
},
|
||||
"num_8": {
|
||||
"name": "8"
|
||||
},
|
||||
"num_9": {
|
||||
"name": "9"
|
||||
},
|
||||
"ok": {
|
||||
"name": "OK"
|
||||
},
|
||||
"power_off": {
|
||||
"name": "Power off"
|
||||
},
|
||||
"power_on": {
|
||||
"name": "Power on"
|
||||
},
|
||||
"right": {
|
||||
"name": "Right"
|
||||
},
|
||||
"up": {
|
||||
"name": "Up"
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"device_type": {
|
||||
"options": {
|
||||
"hifi": "Hi-Fi",
|
||||
"tv": "TV"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["pyliebherrhomeapi"],
|
||||
"quality_scale": "silver",
|
||||
"quality_scale": "bronze",
|
||||
"requirements": ["pyliebherrhomeapi==0.2.1"],
|
||||
"zeroconf": [
|
||||
{
|
||||
|
||||
@@ -34,7 +34,7 @@ rules:
|
||||
docs-installation-parameters: done
|
||||
entity-unavailable: done
|
||||
integration-owner: done
|
||||
log-when-unavailable: done
|
||||
log-when-unavailable: todo
|
||||
parallel-updates: done
|
||||
reauthentication-flow: done
|
||||
test-coverage: done
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"""The syncthing integration."""
|
||||
|
||||
import asyncio
|
||||
from asyncio import Task
|
||||
import logging
|
||||
|
||||
import aiosyncthing
|
||||
@@ -14,7 +13,7 @@ from homeassistant.const import (
|
||||
EVENT_HOMEASSISTANT_STOP,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_send
|
||||
|
||||
@@ -58,8 +57,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async def cancel_listen_task(event: Event) -> None:
|
||||
"""Cancel the listen task on Home Assistant stop."""
|
||||
async def cancel_listen_task(_):
|
||||
await syncthing.unsubscribe()
|
||||
|
||||
entry.async_on_unload(
|
||||
@@ -82,46 +80,44 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
class SyncthingClient:
|
||||
"""A Syncthing client."""
|
||||
|
||||
def __init__(
|
||||
self, hass: HomeAssistant, client: aiosyncthing.Syncthing, server_id: str
|
||||
) -> None:
|
||||
def __init__(self, hass, client, server_id):
|
||||
"""Initialize the client."""
|
||||
self._hass = hass
|
||||
self._client = client
|
||||
self._server_id = server_id
|
||||
self._listen_task: Task[None] | None = None
|
||||
self._listen_task = None
|
||||
|
||||
@property
|
||||
def server_id(self) -> str:
|
||||
def server_id(self):
|
||||
"""Get server id."""
|
||||
return self._server_id
|
||||
|
||||
@property
|
||||
def url(self) -> str:
|
||||
def url(self):
|
||||
"""Get server URL."""
|
||||
return self._client.url
|
||||
|
||||
@property
|
||||
def database(self) -> aiosyncthing.Database:
|
||||
def database(self):
|
||||
"""Get database namespace client."""
|
||||
return self._client.database
|
||||
|
||||
@property
|
||||
def system(self) -> aiosyncthing.System:
|
||||
def system(self):
|
||||
"""Get system namespace client."""
|
||||
return self._client.system
|
||||
|
||||
def subscribe(self) -> None:
|
||||
def subscribe(self):
|
||||
"""Start event listener coroutine."""
|
||||
self._listen_task = asyncio.create_task(self._listen())
|
||||
|
||||
async def unsubscribe(self) -> None:
|
||||
async def unsubscribe(self):
|
||||
"""Stop event listener coroutine."""
|
||||
if self._listen_task:
|
||||
self._listen_task.cancel()
|
||||
await self._client.close()
|
||||
|
||||
async def _listen(self) -> None:
|
||||
async def _listen(self):
|
||||
"""Listen to Syncthing events."""
|
||||
events = self._client.events
|
||||
server_was_unavailable = False
|
||||
@@ -146,7 +142,11 @@ class SyncthingClient:
|
||||
continue
|
||||
|
||||
signal_name = EVENTS[event["type"]]
|
||||
folder = event["data"].get("folder") or event["data"]["id"]
|
||||
folder = None
|
||||
if "folder" in event["data"]:
|
||||
folder = event["data"]["folder"]
|
||||
else: # A workaround, some events store folder id under `id` key
|
||||
folder = event["data"]["id"]
|
||||
async_dispatcher_send(
|
||||
self._hass,
|
||||
f"{signal_name}-{self._server_id}-{folder}",
|
||||
@@ -168,8 +168,7 @@ class SyncthingClient:
|
||||
server_was_unavailable = True
|
||||
continue
|
||||
|
||||
async def _server_available(self) -> bool:
|
||||
"""Check if the Syncthing server is available."""
|
||||
async def _server_available(self):
|
||||
try:
|
||||
await self._client.system.ping()
|
||||
except aiosyncthing.exceptions.SyncthingError:
|
||||
|
||||
@@ -21,7 +21,7 @@ DATA_SCHEMA = vol.Schema(
|
||||
)
|
||||
|
||||
|
||||
async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> dict[str, str]:
|
||||
async def validate_input(hass: HomeAssistant, data):
|
||||
"""Validate the user input allows us to connect."""
|
||||
|
||||
try:
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
"""Support for Syncthing sensors."""
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Any
|
||||
"""Support for monitoring the Syncthing instance."""
|
||||
|
||||
import aiosyncthing
|
||||
|
||||
from homeassistant.components.sensor import SensorEntity
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import PlatformNotReady
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_time_interval
|
||||
|
||||
from . import SyncthingClient
|
||||
from .const import (
|
||||
DOMAIN,
|
||||
FOLDER_PAUSED_RECEIVED,
|
||||
@@ -90,21 +86,14 @@ class FolderSensor(SensorEntity):
|
||||
"stateChanged": "state_changed",
|
||||
}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
syncthing: SyncthingClient,
|
||||
server_id: str,
|
||||
folder_id: str,
|
||||
folder_label: str,
|
||||
version: str,
|
||||
) -> None:
|
||||
def __init__(self, syncthing, server_id, folder_id, folder_label, version):
|
||||
"""Initialize the sensor."""
|
||||
self._syncthing = syncthing
|
||||
self._server_id = server_id
|
||||
self._folder_id = folder_id
|
||||
self._folder_label = folder_label
|
||||
self._state: dict[str, Any] | None = None
|
||||
self._unsub_timer: CALLBACK_TYPE | None = None
|
||||
self._state = None
|
||||
self._unsub_timer = None
|
||||
|
||||
self._short_server_id = server_id.split("-")[0]
|
||||
self._attr_name = f"{self._short_server_id} {folder_id} {folder_label}"
|
||||
@@ -118,9 +107,9 @@ class FolderSensor(SensorEntity):
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
def native_value(self):
|
||||
"""Return the state of the sensor."""
|
||||
return self._state["state"] if self._state else None
|
||||
return self._state["state"]
|
||||
|
||||
@property
|
||||
def available(self) -> bool:
|
||||
@@ -128,11 +117,11 @@ class FolderSensor(SensorEntity):
|
||||
return self._state is not None
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> Mapping[str, Any] | None:
|
||||
def extra_state_attributes(self):
|
||||
"""Return the state attributes."""
|
||||
return self._state
|
||||
|
||||
async def async_update_status(self) -> None:
|
||||
async def async_update_status(self):
|
||||
"""Request folder status and update state."""
|
||||
try:
|
||||
state = await self._syncthing.database.status(self._folder_id)
|
||||
@@ -142,11 +131,11 @@ class FolderSensor(SensorEntity):
|
||||
self._state = self._filter_state(state)
|
||||
self.async_write_ha_state()
|
||||
|
||||
def subscribe(self) -> None:
|
||||
def subscribe(self):
|
||||
"""Start polling syncthing folder status."""
|
||||
if self._unsub_timer is None:
|
||||
|
||||
async def refresh(event_time) -> None:
|
||||
async def refresh(event_time):
|
||||
"""Get the latest data from Syncthing."""
|
||||
await self.async_update_status()
|
||||
|
||||
@@ -155,7 +144,7 @@ class FolderSensor(SensorEntity):
|
||||
)
|
||||
|
||||
@callback
|
||||
def unsubscribe(self) -> None:
|
||||
def unsubscribe(self):
|
||||
"""Stop polling syncthing folder status."""
|
||||
if self._unsub_timer is not None:
|
||||
self._unsub_timer()
|
||||
@@ -165,9 +154,8 @@ class FolderSensor(SensorEntity):
|
||||
"""Handle entity which will be added."""
|
||||
|
||||
@callback
|
||||
def handle_folder_summary(event: dict[str, Any]) -> None:
|
||||
"""Handle folder summary event."""
|
||||
if self._state:
|
||||
def handle_folder_summary(event):
|
||||
if self._state is not None:
|
||||
self._state = self._filter_state(event["data"]["summary"])
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -180,9 +168,8 @@ class FolderSensor(SensorEntity):
|
||||
)
|
||||
|
||||
@callback
|
||||
def handle_state_changed(event: dict[str, Any]) -> None:
|
||||
"""Handle folder state changed event."""
|
||||
if self._state:
|
||||
def handle_state_changed(event):
|
||||
if self._state is not None:
|
||||
self._state["state"] = event["data"]["to"]
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -195,9 +182,8 @@ class FolderSensor(SensorEntity):
|
||||
)
|
||||
|
||||
@callback
|
||||
def handle_folder_paused(event: dict[str, Any]) -> None:
|
||||
"""Handle folder paused event."""
|
||||
if self._state:
|
||||
def handle_folder_paused(event):
|
||||
if self._state is not None:
|
||||
self._state["state"] = "paused"
|
||||
self.async_write_ha_state()
|
||||
|
||||
@@ -210,8 +196,7 @@ class FolderSensor(SensorEntity):
|
||||
)
|
||||
|
||||
@callback
|
||||
def handle_server_unavailable() -> None:
|
||||
"""Handle server becoming unavailable."""
|
||||
def handle_server_unavailable():
|
||||
self._state = None
|
||||
self.unsubscribe()
|
||||
self.async_write_ha_state()
|
||||
@@ -224,8 +209,7 @@ class FolderSensor(SensorEntity):
|
||||
)
|
||||
)
|
||||
|
||||
async def handle_server_available() -> None:
|
||||
"""Handle server becoming available."""
|
||||
async def handle_server_available():
|
||||
self.subscribe()
|
||||
await self.async_update_status()
|
||||
|
||||
@@ -242,20 +226,20 @@ class FolderSensor(SensorEntity):
|
||||
|
||||
await self.async_update_status()
|
||||
|
||||
def _filter_state(self, state: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Filter and map state attributes."""
|
||||
filtered_state: dict[str, Any] = {
|
||||
def _filter_state(self, state):
|
||||
# Select only needed state attributes and map their names
|
||||
state = {
|
||||
self.STATE_ATTRIBUTES[key]: value
|
||||
for key, value in state.items()
|
||||
if key in self.STATE_ATTRIBUTES
|
||||
}
|
||||
|
||||
# A workaround, for some reason, state of paused folders is an empty string
|
||||
if filtered_state["state"] == "":
|
||||
filtered_state["state"] = "paused"
|
||||
if state["state"] == "":
|
||||
state["state"] = "paused"
|
||||
|
||||
# Add some useful attributes
|
||||
filtered_state["id"] = self._folder_id
|
||||
filtered_state["label"] = self._folder_label
|
||||
state["id"] = self._folder_id
|
||||
state["label"] = self._folder_label
|
||||
|
||||
return filtered_state
|
||||
return state
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"actual_compressor_speed": {
|
||||
"default": "mdi:speedometer"
|
||||
},
|
||||
"airflow_current_speed": {
|
||||
"default": "mdi:fan"
|
||||
},
|
||||
"mode": {
|
||||
"default": "mdi:gauge"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,93 +18,89 @@ from homeassistant.util import slugify
|
||||
from . import UPDATE_TOPIC, WaterFurnaceConfigEntry, WaterFurnaceData
|
||||
|
||||
SENSORS = [
|
||||
SensorEntityDescription(name="Furnace Mode", key="mode", icon="mdi:gauge"),
|
||||
SensorEntityDescription(
|
||||
key="mode",
|
||||
translation_key="mode",
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Total Power",
|
||||
key="totalunitpower",
|
||||
translation_key="total_unit_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Active Setpoint",
|
||||
key="tstatactivesetpoint",
|
||||
translation_key="tstat_active_setpoint",
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Leaving Air",
|
||||
key="leavingairtemp",
|
||||
translation_key="leaving_air_temp",
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Room Temp",
|
||||
key="tstatroomtemp",
|
||||
translation_key="room_temp",
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Loop Temp",
|
||||
key="enteringwatertemp",
|
||||
translation_key="entering_water_temp",
|
||||
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
|
||||
device_class=SensorDeviceClass.TEMPERATURE,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Humidity Set Point",
|
||||
key="tstathumidsetpoint",
|
||||
translation_key="tstat_humid_setpoint",
|
||||
icon="mdi:water-percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Humidity",
|
||||
key="tstatrelativehumidity",
|
||||
icon="mdi:water-percent",
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
device_class=SensorDeviceClass.HUMIDITY,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Compressor Power",
|
||||
key="compressorpower",
|
||||
translation_key="compressor_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Fan Power",
|
||||
key="fanpower",
|
||||
translation_key="fan_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Aux Power",
|
||||
key="auxpower",
|
||||
translation_key="aux_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
name="Loop Pump Power",
|
||||
key="looppumppower",
|
||||
translation_key="loop_pump_power",
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="actualcompressorspeed",
|
||||
translation_key="actual_compressor_speed",
|
||||
name="Compressor Speed", key="actualcompressorspeed", icon="mdi:speedometer"
|
||||
),
|
||||
SensorEntityDescription(
|
||||
key="airflowcurrentspeed",
|
||||
translation_key="airflow_current_speed",
|
||||
name="Fan Speed", key="airflowcurrentspeed", icon="mdi:fan"
|
||||
),
|
||||
]
|
||||
|
||||
@@ -128,7 +124,6 @@ class WaterFurnaceSensor(SensorEntity):
|
||||
"""Implementing the Waterfurnace sensor."""
|
||||
|
||||
_attr_should_poll = False
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, client: WaterFurnaceData, description: SensorEntityDescription
|
||||
|
||||
@@ -26,49 +26,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"sensor": {
|
||||
"actual_compressor_speed": {
|
||||
"name": "Compressor speed"
|
||||
},
|
||||
"airflow_current_speed": {
|
||||
"name": "Fan speed"
|
||||
},
|
||||
"aux_power": {
|
||||
"name": "Aux power"
|
||||
},
|
||||
"compressor_power": {
|
||||
"name": "Compressor power"
|
||||
},
|
||||
"entering_water_temp": {
|
||||
"name": "Loop temperature"
|
||||
},
|
||||
"fan_power": {
|
||||
"name": "Fan power"
|
||||
},
|
||||
"leaving_air_temp": {
|
||||
"name": "Leaving air temperature"
|
||||
},
|
||||
"loop_pump_power": {
|
||||
"name": "Loop pump power"
|
||||
},
|
||||
"mode": {
|
||||
"name": "Furnace mode"
|
||||
},
|
||||
"room_temp": {
|
||||
"name": "Room temperature"
|
||||
},
|
||||
"total_unit_power": {
|
||||
"name": "Total power"
|
||||
},
|
||||
"tstat_active_setpoint": {
|
||||
"name": "Active setpoint"
|
||||
},
|
||||
"tstat_humid_setpoint": {
|
||||
"name": "Humidity setpoint"
|
||||
}
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
"deprecated_yaml_import_issue_cannot_connect": {
|
||||
"description": "Configuring {integration_title} via YAML is deprecated and will be removed in a future release. While importing your configuration, we could not connect to {integration_title}. Please check your YAML configuration and restart Home Assistant, or remove the {domain} key from your configuration and configure the integration via the UI.",
|
||||
|
||||
@@ -962,7 +962,7 @@ class HomeAssistant:
|
||||
async def async_block_till_done(self, wait_background_tasks: bool = False) -> None:
|
||||
"""Block until all pending work is done."""
|
||||
# To flush out any call_soon_threadsafe
|
||||
await asyncio.sleep(1e-99)
|
||||
await asyncio.sleep(0)
|
||||
start_time: float | None = None
|
||||
current_task = asyncio.current_task()
|
||||
while tasks := [
|
||||
|
||||
1
homeassistant/generated/config_flows.py
generated
1
homeassistant/generated/config_flows.py
generated
@@ -374,6 +374,7 @@ FLOWS = {
|
||||
"led_ble",
|
||||
"lektrico",
|
||||
"letpot",
|
||||
"lg_infrared",
|
||||
"lg_netcast",
|
||||
"lg_soundbar",
|
||||
"lg_thinq",
|
||||
|
||||
1
homeassistant/generated/entity_platforms.py
generated
1
homeassistant/generated/entity_platforms.py
generated
@@ -29,6 +29,7 @@ class EntityPlatforms(StrEnum):
|
||||
HUMIDIFIER = "humidifier"
|
||||
IMAGE = "image"
|
||||
IMAGE_PROCESSING = "image_processing"
|
||||
INFRARED = "infrared"
|
||||
LAWN_MOWER = "lawn_mower"
|
||||
LIGHT = "light"
|
||||
LOCK = "lock"
|
||||
|
||||
@@ -3605,6 +3605,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"lg_infrared": {
|
||||
"name": "LG Infrared",
|
||||
"integration_type": "device",
|
||||
"config_flow": true,
|
||||
"iot_class": "local_push"
|
||||
},
|
||||
"libre_hardware_monitor": {
|
||||
"name": "Libre Hardware Monitor",
|
||||
"integration_type": "device",
|
||||
|
||||
@@ -41,7 +41,7 @@ hass-nabucasa==1.12.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-frontend==20260128.6
|
||||
home-assistant-intents==2026.1.28
|
||||
home-assistant-intents==2026.2.3
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.6
|
||||
|
||||
20
mypy.ini
generated
20
mypy.ini
generated
@@ -2576,6 +2576,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.infrared.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.input_button.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
@@ -2896,6 +2906,16 @@ disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.lg_infrared.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
disallow_subclassing_any = true
|
||||
disallow_untyped_calls = true
|
||||
disallow_untyped_decorators = true
|
||||
disallow_untyped_defs = true
|
||||
warn_return_any = true
|
||||
warn_unreachable = true
|
||||
|
||||
[mypy-homeassistant.components.libre_hardware_monitor.*]
|
||||
check_untyped_defs = true
|
||||
disallow_incomplete_defs = true
|
||||
|
||||
2
requirements.txt
generated
2
requirements.txt
generated
@@ -28,7 +28,7 @@ ha-ffmpeg==3.2.2
|
||||
hass-nabucasa==1.12.0
|
||||
hassil==3.5.0
|
||||
home-assistant-bluetooth==1.13.1
|
||||
home-assistant-intents==2026.1.28
|
||||
home-assistant-intents==2026.2.3
|
||||
httpx==0.28.1
|
||||
ifaddr==0.2.0
|
||||
Jinja2==3.1.6
|
||||
|
||||
4
requirements_all.txt
generated
4
requirements_all.txt
generated
@@ -1105,7 +1105,7 @@ google-nest-sdm==9.1.2
|
||||
google-photos-library-api==0.12.1
|
||||
|
||||
# homeassistant.components.google_air_quality
|
||||
google_air_quality_api==3.0.1
|
||||
google_air_quality_api==3.0.0
|
||||
|
||||
# homeassistant.components.slide
|
||||
# homeassistant.components.slide_local
|
||||
@@ -1222,7 +1222,7 @@ holidays==0.84
|
||||
home-assistant-frontend==20260128.6
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.1.28
|
||||
home-assistant-intents==2026.2.3
|
||||
|
||||
# homeassistant.components.gentex_homelink
|
||||
homelink-integration-api==0.0.1
|
||||
|
||||
4
requirements_test_all.txt
generated
4
requirements_test_all.txt
generated
@@ -981,7 +981,7 @@ google-nest-sdm==9.1.2
|
||||
google-photos-library-api==0.12.1
|
||||
|
||||
# homeassistant.components.google_air_quality
|
||||
google_air_quality_api==3.0.1
|
||||
google_air_quality_api==3.0.0
|
||||
|
||||
# homeassistant.components.slide
|
||||
# homeassistant.components.slide_local
|
||||
@@ -1080,7 +1080,7 @@ holidays==0.84
|
||||
home-assistant-frontend==20260128.6
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2026.1.28
|
||||
home-assistant-intents==2026.2.3
|
||||
|
||||
# homeassistant.components.gentex_homelink
|
||||
homelink-integration-api==0.0.1
|
||||
|
||||
@@ -81,8 +81,8 @@ async def test_alexa_unique_id_migration(
|
||||
)
|
||||
|
||||
entity = entity_registry.async_get_or_create(
|
||||
DOMAIN,
|
||||
SWITCH_DOMAIN,
|
||||
DOMAIN,
|
||||
unique_id=f"{TEST_DEVICE_1_SN}-do_not_disturb",
|
||||
device_id=device.id,
|
||||
config_entry=mock_config_entry,
|
||||
|
||||
166
tests/components/esphome/test_infrared.py
Normal file
166
tests/components/esphome/test_infrared.py
Normal file
@@ -0,0 +1,166 @@
|
||||
"""Test ESPHome infrared platform."""
|
||||
|
||||
from aioesphomeapi import APIClient, InfraredCapability, InfraredInfo
|
||||
import pytest
|
||||
|
||||
from homeassistant.components import infrared
|
||||
from homeassistant.const import STATE_UNAVAILABLE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
|
||||
from .conftest import MockESPHomeDeviceType
|
||||
|
||||
ENTITY_ID = "infrared.test_ir"
|
||||
|
||||
|
||||
async def _mock_ir_device(
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
mock_client: APIClient,
|
||||
capabilities: InfraredCapability = InfraredCapability.TRANSMITTER,
|
||||
) -> MockESPHomeDeviceType:
|
||||
entity_info = [
|
||||
InfraredInfo(object_id="ir", key=1, name="IR", capabilities=capabilities)
|
||||
]
|
||||
return await mock_esphome_device(
|
||||
mock_client=mock_client, entity_info=entity_info, states=[]
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("capabilities", "entity_created"),
|
||||
[
|
||||
(InfraredCapability.TRANSMITTER, True),
|
||||
(InfraredCapability.RECEIVER, False),
|
||||
(InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER, True),
|
||||
(0, False),
|
||||
],
|
||||
)
|
||||
async def test_infrared_entity_transmitter(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
capabilities: InfraredCapability,
|
||||
entity_created: bool,
|
||||
) -> None:
|
||||
"""Test infrared entity with transmitter capability is created."""
|
||||
await _mock_ir_device(mock_esphome_device, mock_client, capabilities)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert (state is not None) == entity_created
|
||||
|
||||
emitters = infrared.async_get_emitters(hass)
|
||||
assert (len(emitters) == 1) == entity_created
|
||||
|
||||
|
||||
async def test_infrared_multiple_entities_mixed_capabilities(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test multiple infrared entities with mixed capabilities."""
|
||||
entity_info = [
|
||||
InfraredInfo(
|
||||
object_id="ir_transmitter",
|
||||
key=1,
|
||||
name="IR Transmitter",
|
||||
capabilities=InfraredCapability.TRANSMITTER,
|
||||
),
|
||||
InfraredInfo(
|
||||
object_id="ir_receiver",
|
||||
key=2,
|
||||
name="IR Receiver",
|
||||
capabilities=InfraredCapability.RECEIVER,
|
||||
),
|
||||
InfraredInfo(
|
||||
object_id="ir_transceiver",
|
||||
key=3,
|
||||
name="IR Transceiver",
|
||||
capabilities=InfraredCapability.TRANSMITTER | InfraredCapability.RECEIVER,
|
||||
),
|
||||
]
|
||||
await mock_esphome_device(
|
||||
mock_client=mock_client,
|
||||
entity_info=entity_info,
|
||||
states=[],
|
||||
)
|
||||
|
||||
# Only transmitter and transceiver should be created
|
||||
assert hass.states.get("infrared.test_ir_transmitter") is not None
|
||||
assert hass.states.get("infrared.test_ir_receiver") is None
|
||||
assert hass.states.get("infrared.test_ir_transceiver") is not None
|
||||
|
||||
emitters = infrared.async_get_emitters(hass)
|
||||
assert len(emitters) == 2
|
||||
|
||||
|
||||
async def test_infrared_send_command_success(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test sending IR command successfully."""
|
||||
await _mock_ir_device(mock_esphome_device, mock_client)
|
||||
|
||||
command = infrared.NECInfraredCommand(address=0x04, command=0x08, modulation=38000)
|
||||
await infrared.async_send_command(hass, ENTITY_ID, command)
|
||||
|
||||
# Verify the command was sent to the ESPHome client
|
||||
mock_client.infrared_rf_transmit_raw_timings.assert_called_once()
|
||||
call_args = mock_client.infrared_rf_transmit_raw_timings.call_args
|
||||
assert call_args[0][0] == 1 # key
|
||||
assert call_args[1]["carrier_frequency"] == 38000
|
||||
|
||||
# Verify timings (alternating positive/negative values)
|
||||
timings = call_args[1]["timings"]
|
||||
assert len(timings) > 0
|
||||
for i in range(0, len(timings), 2):
|
||||
assert timings[i] >= 0
|
||||
for i in range(1, len(timings), 2):
|
||||
assert timings[i] <= 0
|
||||
|
||||
|
||||
async def test_infrared_send_command_failure(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test sending IR command with failure raises HomeAssistantError."""
|
||||
await _mock_ir_device(mock_esphome_device, mock_client)
|
||||
|
||||
mock_client.infrared_rf_transmit_raw_timings.side_effect = Exception(
|
||||
"Connection lost"
|
||||
)
|
||||
|
||||
command = infrared.NECInfraredCommand(address=0x04, command=0x08, modulation=38000)
|
||||
|
||||
with pytest.raises(HomeAssistantError) as exc_info:
|
||||
await infrared.async_send_command(hass, ENTITY_ID, command)
|
||||
assert exc_info.value.translation_domain == "esphome"
|
||||
assert exc_info.value.translation_key == "error_sending_ir_command"
|
||||
|
||||
|
||||
async def test_infrared_entity_availability(
|
||||
hass: HomeAssistant,
|
||||
mock_client: APIClient,
|
||||
mock_esphome_device: MockESPHomeDeviceType,
|
||||
) -> None:
|
||||
"""Test infrared entity becomes available after device reconnects."""
|
||||
mock_device = await _mock_ir_device(mock_esphome_device, mock_client)
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
|
||||
await mock_device.mock_disconnect(False)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNAVAILABLE
|
||||
|
||||
await mock_device.mock_connect()
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get(ENTITY_ID)
|
||||
assert state is not None
|
||||
assert state.state != STATE_UNAVAILABLE
|
||||
@@ -167,836 +167,6 @@
|
||||
),
|
||||
])
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.bathroom_dn-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 20.0,
|
||||
'friendly_name': 'Bathroom Dn',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 16.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 20.0,
|
||||
}),
|
||||
'zone_id': '3432579',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 16.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.bathroom_dn',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.bathroom_dn-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 20.0,
|
||||
'friendly_name': 'Bathroom Dn',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 16.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 20.0,
|
||||
}),
|
||||
'zone_id': '3432579',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 16.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.bathroom_dn',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.dead_zone-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': None,
|
||||
'friendly_name': 'Dead Zone',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': False,
|
||||
}),
|
||||
'zone_id': '3432521',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.dead_zone',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.dead_zone-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': None,
|
||||
'friendly_name': 'Dead Zone',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': False,
|
||||
}),
|
||||
'zone_id': '3432521',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.dead_zone',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.front_room-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.0,
|
||||
'friendly_name': 'Front Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'temporary',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'TemporaryOverride',
|
||||
'target_heat_temperature': 21.0,
|
||||
'until': '2022-03-07T19:00:00+00:00',
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.0,
|
||||
}),
|
||||
'zone_id': '3432577',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 21.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.front_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.front_room-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.0,
|
||||
'friendly_name': 'Front Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'temporary',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'TemporaryOverride',
|
||||
'target_heat_temperature': 21.0,
|
||||
'until': '2022-03-07T19:00:00+00:00',
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.0,
|
||||
}),
|
||||
'zone_id': '3432577',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 21.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.front_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.kids_room-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.5,
|
||||
'friendly_name': 'Kids Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.5,
|
||||
}),
|
||||
'zone_id': '3449703',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.kids_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.kids_room-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.5,
|
||||
'friendly_name': 'Kids Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.5,
|
||||
}),
|
||||
'zone_id': '3449703',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.kids_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.kitchen-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 20.0,
|
||||
'friendly_name': 'Kitchen',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 20.0,
|
||||
}),
|
||||
'zone_id': '3432578',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.kitchen',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.kitchen-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 20.0,
|
||||
'friendly_name': 'Kitchen',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 20.0,
|
||||
}),
|
||||
'zone_id': '3432578',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.kitchen',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.main_bedroom-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 21.0,
|
||||
'friendly_name': 'Main Bedroom',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 16.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 21.0,
|
||||
}),
|
||||
'zone_id': '3432580',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 16.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.main_bedroom',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.main_bedroom-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 21.0,
|
||||
'friendly_name': 'Main Bedroom',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'none',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'FollowSchedule',
|
||||
'target_heat_temperature': 16.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 21.0,
|
||||
}),
|
||||
'zone_id': '3432580',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 16.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.main_bedroom',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.main_room-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.0,
|
||||
'friendly_name': 'Main Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'permanent',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'PermanentOverride',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.0,
|
||||
}),
|
||||
'zone_id': '3432576',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.main_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.main_room-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.0,
|
||||
'friendly_name': 'Main Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'permanent',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'PermanentOverride',
|
||||
'target_heat_temperature': 17.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.0,
|
||||
}),
|
||||
'zone_id': '3432576',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 17.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.main_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.my_home-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.7,
|
||||
'friendly_name': 'My Home',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'icon': 'mdi:thermostat',
|
||||
'max_temp': 35,
|
||||
'min_temp': 7,
|
||||
'preset_mode': 'eco',
|
||||
'preset_modes': list([
|
||||
'Reset',
|
||||
'eco',
|
||||
'away',
|
||||
'home',
|
||||
'Custom',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeSystemFaults': tuple(
|
||||
),
|
||||
'system_id': '3432522',
|
||||
'system_mode_status': dict({
|
||||
'is_permanent': True,
|
||||
'mode': 'AutoWithEco',
|
||||
}),
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 400>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.my_home',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.my_home-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.7,
|
||||
'friendly_name': 'My Home',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'icon': 'mdi:thermostat',
|
||||
'max_temp': 35,
|
||||
'min_temp': 7,
|
||||
'preset_mode': 'eco',
|
||||
'preset_modes': list([
|
||||
'Reset',
|
||||
'eco',
|
||||
'away',
|
||||
'home',
|
||||
'Custom',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeSystemFaults': tuple(
|
||||
),
|
||||
'system_id': '3432522',
|
||||
'system_mode_status': dict({
|
||||
'is_permanent': True,
|
||||
'mode': 'AutoWithEco',
|
||||
}),
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 400>,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.my_home',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.spare_room-state-initial]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.5,
|
||||
'friendly_name': 'Spare Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'permanent',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'PermanentOverride',
|
||||
'target_heat_temperature': 14.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 7, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.1,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 9, 23, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 15.9,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.5,
|
||||
}),
|
||||
'zone_id': '3450733',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 14.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.spare_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_entities_update_over_time[default][climate.spare_room-state-updated]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
'current_temperature': 19.5,
|
||||
'friendly_name': 'Spare Room',
|
||||
'hvac_modes': list([
|
||||
<HVACMode.OFF: 'off'>,
|
||||
<HVACMode.HEAT: 'heat'>,
|
||||
]),
|
||||
'max_temp': 35.0,
|
||||
'min_temp': 5.0,
|
||||
'preset_mode': 'permanent',
|
||||
'preset_modes': list([
|
||||
'none',
|
||||
'temporary',
|
||||
'permanent',
|
||||
]),
|
||||
'status': dict({
|
||||
'activeFaults': tuple(
|
||||
),
|
||||
'setpoint_status': dict({
|
||||
'setpoint_mode': 'PermanentOverride',
|
||||
'target_heat_temperature': 14.0,
|
||||
}),
|
||||
'setpoints': dict({
|
||||
'next_sp_from': HAFakeDatetime(2024, 7, 10, 22, 10, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'next_sp_temp': 18.6,
|
||||
'this_sp_from': HAFakeDatetime(2024, 7, 10, 8, 0, tzinfo=zoneinfo.ZoneInfo(key='Europe/London')),
|
||||
'this_sp_temp': 16.0,
|
||||
}),
|
||||
'temperature_status': dict({
|
||||
'is_available': True,
|
||||
'temperature': 19.5,
|
||||
}),
|
||||
'zone_id': '3450733',
|
||||
}),
|
||||
'supported_features': <ClimateEntityFeature: 401>,
|
||||
'temperature': 14.0,
|
||||
}),
|
||||
'context': <ANY>,
|
||||
'entity_id': 'climate.spare_room',
|
||||
'last_changed': <ANY>,
|
||||
'last_reported': <ANY>,
|
||||
'last_updated': <ANY>,
|
||||
'state': 'heat',
|
||||
})
|
||||
# ---
|
||||
# name: test_setup_platform[botched][climate.bathroom_dn-state]
|
||||
StateSnapshot({
|
||||
'attributes': ReadOnlyDict({
|
||||
|
||||
@@ -5,7 +5,6 @@ All evohome systems have controllers and at least one zone.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import timedelta
|
||||
from unittest.mock import patch
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
@@ -33,8 +32,6 @@ from homeassistant.exceptions import HomeAssistantError
|
||||
from .conftest import setup_evohome
|
||||
from .const import TEST_INSTALLS
|
||||
|
||||
from tests.common import async_fire_time_changed
|
||||
|
||||
|
||||
@pytest.mark.parametrize("install", [*TEST_INSTALLS, "botched"])
|
||||
async def test_setup_platform(
|
||||
@@ -46,7 +43,7 @@ async def test_setup_platform(
|
||||
) -> None:
|
||||
"""Test entities and their states after setup of evohome."""
|
||||
|
||||
# Cannot use the evohome fixture here, as need to set dtm first
|
||||
# Cannot use the evohome fixture, as need to set dtm first
|
||||
# - some extended state attrs are relative the current time
|
||||
freezer.move_to("2024-07-10T12:00:00Z")
|
||||
|
||||
@@ -57,36 +54,6 @@ async def test_setup_platform(
|
||||
assert x == snapshot(name=f"{x.entity_id}-state")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("install", ["default"])
|
||||
async def test_entities_update_over_time(
|
||||
hass: HomeAssistant,
|
||||
config: dict[str, str],
|
||||
install: str,
|
||||
snapshot: SnapshotAssertion,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test extended attributes update as time passes.
|
||||
|
||||
Verifies that time-dependent state attrs (e.g. schedules) vary as time advances.
|
||||
"""
|
||||
|
||||
# Cannot use the evohome fixture here, as need to set dtm first
|
||||
# - some extended state attrs are relative the current time
|
||||
freezer.move_to("2024-07-10T05:30:00Z")
|
||||
|
||||
# stay inside this context to have the mocked RESTful API
|
||||
async for _ in setup_evohome(hass, config, install=install):
|
||||
for x in hass.states.async_all(Platform.CLIMATE):
|
||||
assert x == snapshot(name=f"{x.entity_id}-state-initial")
|
||||
|
||||
freezer.tick(timedelta(hours=12))
|
||||
async_fire_time_changed(hass)
|
||||
await hass.async_block_till_done(wait_background_tasks=True)
|
||||
|
||||
for x in hass.states.async_all(Platform.CLIMATE):
|
||||
assert x == snapshot(name=f"{x.entity_id}-state-updated")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("install", TEST_INSTALLS)
|
||||
async def test_ctl_set_hvac_mode(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -35,38 +35,6 @@ MOCK_SERIAL_NUMBER = "ABC123456"
|
||||
MOCK_DEVICE_TOKEN = "mock_device_token"
|
||||
|
||||
|
||||
def create_mock_tracker() -> Tracker:
|
||||
"""Create a fresh mock Tracker instance."""
|
||||
return Tracker(
|
||||
name="Fluffy",
|
||||
battery=85,
|
||||
charging=False,
|
||||
position=Position(
|
||||
lat=52.520008,
|
||||
lng=13.404954,
|
||||
accuracy=10,
|
||||
timestamp="2024-01-15T12:00:00Z",
|
||||
),
|
||||
tracker_settings=TrackerSettings(
|
||||
generation="GPS Tracker 2.0",
|
||||
features=TrackerFeatures(
|
||||
flash_light=True, energy_saving_mode=True, live_tracking=True
|
||||
),
|
||||
),
|
||||
led_brightness=LedBrightness(status="ok", value=50),
|
||||
energy_saving=EnergySaving(status="ok", value=1),
|
||||
deep_sleep=None,
|
||||
led_activatable=LedActivatable(
|
||||
has_led=True,
|
||||
seen_recently=True,
|
||||
nonempty_battery=True,
|
||||
not_charging=True,
|
||||
overall=True,
|
||||
),
|
||||
icon="http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg",
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock]:
|
||||
"""Override async_setup_entry."""
|
||||
@@ -134,26 +102,42 @@ def mock_auth_client(mock_device: Device) -> Generator[MagicMock]:
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_client_init() -> Generator[MagicMock]:
|
||||
"""Mock the ApiClient used by _tracker_is_valid in __init__.py."""
|
||||
def mock_api_client() -> Generator[MagicMock]:
|
||||
"""Mock the ApiClient."""
|
||||
with patch(
|
||||
"homeassistant.components.fressnapf_tracker.ApiClient",
|
||||
autospec=True,
|
||||
) as mock_client:
|
||||
client = mock_client.return_value
|
||||
client.get_tracker = AsyncMock(return_value=create_mock_tracker())
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_client_coordinator() -> Generator[MagicMock]:
|
||||
"""Mock the ApiClient used by the coordinator."""
|
||||
with patch(
|
||||
"homeassistant.components.fressnapf_tracker.coordinator.ApiClient",
|
||||
autospec=True,
|
||||
) as mock_client:
|
||||
client = mock_client.return_value
|
||||
client.get_tracker = AsyncMock(return_value=create_mock_tracker())
|
||||
"homeassistant.components.fressnapf_tracker.coordinator.ApiClient"
|
||||
) as mock_api_client:
|
||||
client = mock_api_client.return_value
|
||||
client.get_tracker = AsyncMock(
|
||||
return_value=Tracker(
|
||||
name="Fluffy",
|
||||
battery=85,
|
||||
charging=False,
|
||||
position=Position(
|
||||
lat=52.520008,
|
||||
lng=13.404954,
|
||||
accuracy=10,
|
||||
timestamp="2024-01-15T12:00:00Z",
|
||||
),
|
||||
tracker_settings=TrackerSettings(
|
||||
generation="GPS Tracker 2.0",
|
||||
features=TrackerFeatures(
|
||||
flash_light=True, energy_saving_mode=True, live_tracking=True
|
||||
),
|
||||
),
|
||||
led_brightness=LedBrightness(status="ok", value=50),
|
||||
energy_saving=EnergySaving(status="ok", value=1),
|
||||
deep_sleep=None,
|
||||
led_activatable=LedActivatable(
|
||||
has_led=True,
|
||||
seen_recently=True,
|
||||
nonempty_battery=True,
|
||||
not_charging=True,
|
||||
overall=True,
|
||||
),
|
||||
icon="http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg",
|
||||
)
|
||||
)
|
||||
client.set_led_brightness = AsyncMock(return_value=None)
|
||||
client.set_energy_saving = AsyncMock(return_value=None)
|
||||
yield client
|
||||
@@ -178,8 +162,7 @@ def mock_config_entry() -> MockConfigEntry:
|
||||
async def init_integration(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client_init: MagicMock,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
mock_auth_client: MagicMock,
|
||||
) -> MockConfigEntry:
|
||||
"""Set up the integration for testing."""
|
||||
|
||||
@@ -216,9 +216,7 @@ async def test_user_flow_duplicate_phone_number(
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures(
|
||||
"mock_api_client_init", "mock_api_client_coordinator", "mock_auth_client"
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_api_client", "mock_auth_client")
|
||||
async def test_reauth_reconfigure_flow(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
@@ -272,7 +270,7 @@ async def test_reauth_reconfigure_flow(
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_api_client_init", "mock_api_client_coordinator")
|
||||
@pytest.mark.usefixtures("mock_api_client")
|
||||
async def test_reauth_reconfigure_flow_invalid_phone_number(
|
||||
hass: HomeAssistant,
|
||||
mock_auth_client: MagicMock,
|
||||
@@ -335,7 +333,7 @@ async def test_reauth_reconfigure_flow_invalid_phone_number(
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_api_client_init", "mock_api_client_coordinator")
|
||||
@pytest.mark.usefixtures("mock_api_client")
|
||||
async def test_reauth_reconfigure_flow_invalid_sms_code(
|
||||
hass: HomeAssistant,
|
||||
mock_auth_client: MagicMock,
|
||||
@@ -395,7 +393,7 @@ async def test_reauth_reconfigure_flow_invalid_sms_code(
|
||||
),
|
||||
],
|
||||
)
|
||||
@pytest.mark.usefixtures("mock_api_client_init", "mock_api_client_coordinator")
|
||||
@pytest.mark.usefixtures("mock_api_client")
|
||||
async def test_reauth_reconfigure_flow_invalid_user_id(
|
||||
hass: HomeAssistant,
|
||||
mock_auth_client: MagicMock,
|
||||
|
||||
@@ -40,12 +40,12 @@ async def test_device_tracker_no_position(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_tracker_no_position: Tracker,
|
||||
mock_api_client_init: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test device tracker is unavailable when position is None."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
mock_api_client_init.get_tracker = AsyncMock(return_value=mock_tracker_no_position)
|
||||
mock_api_client.get_tracker = AsyncMock(return_value=mock_tracker_no_position)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
@@ -1,40 +1,19 @@
|
||||
"""Test the Fressnapf Tracker integration init."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from fressnapftracker import (
|
||||
FressnapfTrackerError,
|
||||
FressnapfTrackerInvalidTrackerResponseError,
|
||||
)
|
||||
import pytest
|
||||
from syrupy.assertion import SnapshotAssertion
|
||||
|
||||
from homeassistant.components.fressnapf_tracker.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import device_registry as dr, issue_registry as ir
|
||||
|
||||
from .conftest import MOCK_SERIAL_NUMBER
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_api_client_malformed_tracker() -> Generator[MagicMock]:
|
||||
"""Mock the ApiClient for a malformed tracker response in _tracker_is_valid."""
|
||||
with patch(
|
||||
"homeassistant.components.fressnapf_tracker.ApiClient",
|
||||
autospec=True,
|
||||
) as mock_api_client:
|
||||
client = mock_api_client.return_value
|
||||
client.get_tracker = AsyncMock(
|
||||
side_effect=FressnapfTrackerInvalidTrackerResponseError("Invalid tracker")
|
||||
)
|
||||
yield client
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
|
||||
@pytest.mark.usefixtures("mock_auth_client")
|
||||
@pytest.mark.usefixtures("mock_api_client")
|
||||
async def test_setup_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
@@ -48,7 +27,8 @@ async def test_setup_entry(
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
|
||||
@pytest.mark.usefixtures("mock_auth_client")
|
||||
@pytest.mark.usefixtures("mock_api_client")
|
||||
async def test_unload_entry(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
@@ -68,18 +48,15 @@ async def test_unload_entry(
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_auth_client")
|
||||
async def test_setup_entry_tracker_is_valid_api_error(
|
||||
async def test_setup_entry_api_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client_init: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test setup retries when API returns error during _tracker_is_valid."""
|
||||
"""Test setup fails when API returns error."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
mock_api_client_init.get_tracker = AsyncMock(
|
||||
side_effect=FressnapfTrackerError("API Error")
|
||||
)
|
||||
|
||||
mock_api_client.get_tracker = AsyncMock(side_effect=Exception("API Error"))
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
@@ -101,48 +78,3 @@ async def test_state_entity_device_snapshots(
|
||||
assert device_entry == snapshot(name=f"{device_entry.name}-entry"), (
|
||||
f"device entry snapshot failed for {device_entry.name}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_malformed_tracker")
|
||||
async def test_invalid_tracker(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test that an issue is created when an invalid tracker is detected."""
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
issue_id = f"invalid_fressnapf_tracker_{MOCK_SERIAL_NUMBER}"
|
||||
assert issue_registry.async_get_issue(DOMAIN, issue_id)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_malformed_tracker")
|
||||
async def test_invalid_tracker_already_exists(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
issue_registry: ir.IssueRegistry,
|
||||
) -> None:
|
||||
"""Test that an existing issue is not duplicated."""
|
||||
ir.async_create_issue(
|
||||
hass,
|
||||
DOMAIN,
|
||||
f"invalid_fressnapf_tracker_{MOCK_SERIAL_NUMBER}",
|
||||
is_fixable=False,
|
||||
severity=ir.IssueSeverity.WARNING,
|
||||
translation_key="invalid_fressnapf_tracker",
|
||||
translation_placeholders={"tracker_id": MOCK_SERIAL_NUMBER},
|
||||
)
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert mock_config_entry.state is ConfigEntryState.LOADED
|
||||
assert len(issue_registry.issues) == 1
|
||||
|
||||
@@ -63,10 +63,10 @@ async def test_not_added_when_no_led(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client_init: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test light entity is created correctly."""
|
||||
mock_api_client_init.get_tracker.return_value = TRACKER_NO_LED
|
||||
mock_api_client.get_tracker.return_value = TRACKER_NO_LED
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
@@ -81,7 +81,7 @@ async def test_not_added_when_no_led(
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_turn_on(
|
||||
hass: HomeAssistant,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test turning the light on."""
|
||||
entity_id = "light.fluffy_flashlight"
|
||||
@@ -97,13 +97,13 @@ async def test_turn_on(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api_client_coordinator.set_led_brightness.assert_called_once_with(100)
|
||||
mock_api_client.set_led_brightness.assert_called_once_with(100)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_turn_on_with_brightness(
|
||||
hass: HomeAssistant,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test turning the light on with brightness."""
|
||||
entity_id = "light.fluffy_flashlight"
|
||||
@@ -116,13 +116,13 @@ async def test_turn_on_with_brightness(
|
||||
)
|
||||
|
||||
# 128/255 * 100 = 50
|
||||
mock_api_client_coordinator.set_led_brightness.assert_called_once_with(50)
|
||||
mock_api_client.set_led_brightness.assert_called_once_with(50)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_turn_off(
|
||||
hass: HomeAssistant,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test turning the light off."""
|
||||
entity_id = "light.fluffy_flashlight"
|
||||
@@ -138,7 +138,7 @@ async def test_turn_off(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api_client_coordinator.set_led_brightness.assert_called_once_with(0)
|
||||
mock_api_client.set_led_brightness.assert_called_once_with(0)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -153,13 +153,12 @@ async def test_turn_off(
|
||||
async def test_turn_on_led_not_activatable(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client_init: MagicMock,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
activatable_parameter: str,
|
||||
) -> None:
|
||||
"""Test turning on the light when LED is not activatable raises."""
|
||||
setattr(
|
||||
mock_api_client_init.get_tracker.return_value.led_activatable,
|
||||
mock_api_client.get_tracker.return_value.led_activatable,
|
||||
activatable_parameter,
|
||||
False,
|
||||
)
|
||||
@@ -178,7 +177,7 @@ async def test_turn_on_led_not_activatable(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api_client_coordinator.set_led_brightness.assert_not_called()
|
||||
mock_api_client.set_led_brightness.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -192,11 +191,11 @@ async def test_turn_on_led_not_activatable(
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF])
|
||||
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
|
||||
@pytest.mark.usefixtures("mock_auth_client")
|
||||
async def test_turn_on_off_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
api_exception: FressnapfTrackerError,
|
||||
expected_exception: type[HomeAssistantError],
|
||||
service: str,
|
||||
@@ -209,7 +208,7 @@ async def test_turn_on_off_error(
|
||||
|
||||
entity_id = "light.fluffy_flashlight"
|
||||
|
||||
mock_api_client_coordinator.set_led_brightness.side_effect = api_exception
|
||||
mock_api_client.set_led_brightness.side_effect = api_exception
|
||||
with pytest.raises(expected_exception):
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN,
|
||||
|
||||
@@ -62,10 +62,10 @@ async def test_not_added_when_no_energy_saving_mode(
|
||||
hass: HomeAssistant,
|
||||
entity_registry: er.EntityRegistry,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client_init: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test switch entity is created correctly."""
|
||||
mock_api_client_init.get_tracker.return_value = TRACKER_NO_ENERGY_SAVING_MODE
|
||||
mock_api_client.get_tracker.return_value = TRACKER_NO_ENERGY_SAVING_MODE
|
||||
|
||||
mock_config_entry.add_to_hass(hass)
|
||||
await hass.config_entries.async_setup(mock_config_entry.entry_id)
|
||||
@@ -80,7 +80,7 @@ async def test_not_added_when_no_energy_saving_mode(
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_turn_on(
|
||||
hass: HomeAssistant,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test turning the switch on."""
|
||||
entity_id = "switch.fluffy_sleep_mode"
|
||||
@@ -96,13 +96,13 @@ async def test_turn_on(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api_client_coordinator.set_energy_saving.assert_called_once_with(True)
|
||||
mock_api_client.set_energy_saving.assert_called_once_with(True)
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_turn_off(
|
||||
hass: HomeAssistant,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
) -> None:
|
||||
"""Test turning the switch off."""
|
||||
entity_id = "switch.fluffy_sleep_mode"
|
||||
@@ -118,7 +118,7 @@ async def test_turn_off(
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
mock_api_client_coordinator.set_energy_saving.assert_called_once_with(False)
|
||||
mock_api_client.set_energy_saving.assert_called_once_with(False)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@@ -132,11 +132,11 @@ async def test_turn_off(
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF])
|
||||
@pytest.mark.usefixtures("mock_auth_client", "mock_api_client_init")
|
||||
@pytest.mark.usefixtures("mock_auth_client")
|
||||
async def test_turn_on_off_error(
|
||||
hass: HomeAssistant,
|
||||
mock_config_entry: MockConfigEntry,
|
||||
mock_api_client_coordinator: MagicMock,
|
||||
mock_api_client: MagicMock,
|
||||
api_exception: FressnapfTrackerError,
|
||||
expected_exception: type[HomeAssistantError],
|
||||
service: str,
|
||||
@@ -149,7 +149,7 @@ async def test_turn_on_off_error(
|
||||
|
||||
entity_id = "switch.fluffy_sleep_mode"
|
||||
|
||||
mock_api_client_coordinator.set_energy_saving.side_effect = api_exception
|
||||
mock_api_client.set_energy_saving.side_effect = api_exception
|
||||
with pytest.raises(expected_exception):
|
||||
await hass.services.async_call(
|
||||
SWITCH_DOMAIN,
|
||||
|
||||
1
tests/components/infrared/__init__.py
Normal file
1
tests/components/infrared/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tests for the Infrared integration."""
|
||||
37
tests/components/infrared/conftest.py
Normal file
37
tests/components/infrared/conftest.py
Normal file
@@ -0,0 +1,37 @@
|
||||
"""Common fixtures for the Infrared tests."""
|
||||
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
|
||||
from homeassistant.components.infrared.const import DOMAIN
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def init_integration(hass: HomeAssistant) -> None:
|
||||
"""Set up the Infrared integration for testing."""
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
|
||||
class MockInfraredEntity(InfraredEntity):
|
||||
"""Mock infrared entity for testing."""
|
||||
|
||||
_attr_has_entity_name = True
|
||||
_attr_name = "Test IR transmitter"
|
||||
|
||||
def __init__(self, unique_id: str) -> None:
|
||||
"""Initialize mock entity."""
|
||||
self._attr_unique_id = unique_id
|
||||
self.send_command_calls: list[InfraredCommand] = []
|
||||
|
||||
async def async_send_command(self, command: InfraredCommand) -> None:
|
||||
"""Mock send command."""
|
||||
self.send_command_calls.append(command)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_infrared_entity() -> MockInfraredEntity:
|
||||
"""Return a mock infrared entity."""
|
||||
return MockInfraredEntity("test_ir_transmitter")
|
||||
146
tests/components/infrared/test_init.py
Normal file
146
tests/components/infrared/test_init.py
Normal file
@@ -0,0 +1,146 @@
|
||||
"""Tests for the Infrared integration setup."""
|
||||
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from freezegun.api import FrozenDateTimeFactory
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.infrared import (
|
||||
DATA_COMPONENT,
|
||||
DOMAIN,
|
||||
NECInfraredCommand,
|
||||
async_get_emitters,
|
||||
async_send_command,
|
||||
)
|
||||
from homeassistant.const import STATE_UNKNOWN
|
||||
from homeassistant.core import HomeAssistant, State
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.setup import async_setup_component
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .conftest import MockInfraredEntity
|
||||
|
||||
from tests.common import mock_restore_cache
|
||||
|
||||
|
||||
async def test_get_entities_integration_setup(hass: HomeAssistant) -> None:
|
||||
"""Test getting entities when the integration is not setup."""
|
||||
assert async_get_emitters(hass) == []
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_get_entities_empty(hass: HomeAssistant) -> None:
|
||||
"""Test getting entities when none are registered."""
|
||||
assert async_get_emitters(hass) == []
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_infrared_entity_initial_state(
|
||||
hass: HomeAssistant, mock_infrared_entity: MockInfraredEntity
|
||||
) -> None:
|
||||
"""Test infrared entity has no state before any command is sent."""
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([mock_infrared_entity])
|
||||
|
||||
state = hass.states.get("infrared.test_ir_transmitter")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_async_send_command_success(
|
||||
hass: HomeAssistant,
|
||||
mock_infrared_entity: MockInfraredEntity,
|
||||
freezer: FrozenDateTimeFactory,
|
||||
) -> None:
|
||||
"""Test sending command via async_send_command helper."""
|
||||
# Add the mock entity to the component
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([mock_infrared_entity])
|
||||
|
||||
# Freeze time so we can verify the state update
|
||||
now = dt_util.utcnow()
|
||||
freezer.move_to(now)
|
||||
|
||||
command = NECInfraredCommand(address=0x04FB, command=0x08F7, modulation=38000)
|
||||
await async_send_command(hass, mock_infrared_entity.entity_id, command)
|
||||
|
||||
assert len(mock_infrared_entity.send_command_calls) == 1
|
||||
assert mock_infrared_entity.send_command_calls[0] is command
|
||||
|
||||
state = hass.states.get("infrared.test_ir_transmitter")
|
||||
assert state is not None
|
||||
assert state.state == now.isoformat(timespec="milliseconds")
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_async_send_command_error_does_not_update_state(
|
||||
hass: HomeAssistant,
|
||||
mock_infrared_entity: MockInfraredEntity,
|
||||
) -> None:
|
||||
"""Test that state is not updated when async_send_command raises an error."""
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([mock_infrared_entity])
|
||||
|
||||
state = hass.states.get("infrared.test_ir_transmitter")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
command = NECInfraredCommand(address=0x04FB, command=0x08F7, modulation=38000)
|
||||
|
||||
mock_infrared_entity.async_send_command = AsyncMock(
|
||||
side_effect=HomeAssistantError("Transmission failed")
|
||||
)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="Transmission failed"):
|
||||
await async_send_command(hass, mock_infrared_entity.entity_id, command)
|
||||
|
||||
# Verify state was not updated after the error
|
||||
state = hass.states.get("infrared.test_ir_transmitter")
|
||||
assert state is not None
|
||||
assert state.state == STATE_UNKNOWN
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("init_integration")
|
||||
async def test_async_send_command_entity_not_found(hass: HomeAssistant) -> None:
|
||||
"""Test async_send_command raises error when entity not found."""
|
||||
command = NECInfraredCommand(
|
||||
address=0x04FB, command=0x08F7, modulation=38000, repeat_count=1
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
HomeAssistantError,
|
||||
match="Infrared entity `infrared.nonexistent_entity` not found",
|
||||
):
|
||||
await async_send_command(hass, "infrared.nonexistent_entity", command)
|
||||
|
||||
|
||||
async def test_async_send_command_component_not_loaded(hass: HomeAssistant) -> None:
|
||||
"""Test async_send_command raises error when component not loaded."""
|
||||
command = NECInfraredCommand(
|
||||
address=0x04FB, command=0x08F7, modulation=38000, repeat_count=1
|
||||
)
|
||||
|
||||
with pytest.raises(HomeAssistantError, match="component_not_loaded"):
|
||||
await async_send_command(hass, "infrared.some_entity", command)
|
||||
|
||||
|
||||
async def test_infrared_entity_state_restore(
|
||||
hass: HomeAssistant,
|
||||
mock_infrared_entity: MockInfraredEntity,
|
||||
) -> None:
|
||||
"""Test infrared entity restores state from previous session."""
|
||||
previous_timestamp = "2026-01-01T12:00:00.000+00:00"
|
||||
mock_restore_cache(
|
||||
hass, [State("infrared.test_ir_transmitter", previous_timestamp)]
|
||||
)
|
||||
|
||||
assert await async_setup_component(hass, DOMAIN, {})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
component = hass.data[DATA_COMPONENT]
|
||||
await component.async_add_entities([mock_infrared_entity])
|
||||
|
||||
state = hass.states.get("infrared.test_ir_transmitter")
|
||||
assert state is not None
|
||||
assert state.state == previous_timestamp
|
||||
128
tests/components/infrared/test_protocols.py
Normal file
128
tests/components/infrared/test_protocols.py
Normal file
@@ -0,0 +1,128 @@
|
||||
"""Tests for the Infrared protocol definitions."""
|
||||
|
||||
from homeassistant.components.infrared import NECInfraredCommand, Timing
|
||||
|
||||
|
||||
def test_nec_command_get_raw_timings_standard() -> None:
|
||||
"""Test NEC command raw timings generation for standard 8-bit address."""
|
||||
expected_raw_timings = [
|
||||
Timing(high_us=9000, low_us=4500),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=0),
|
||||
]
|
||||
command = NECInfraredCommand(
|
||||
address=0x04, command=0x08, modulation=38000, repeat_count=0
|
||||
)
|
||||
timings = command.get_raw_timings()
|
||||
assert timings == expected_raw_timings
|
||||
|
||||
# Same command now with 2 repeats
|
||||
command_with_repeats = NECInfraredCommand(
|
||||
address=command.address,
|
||||
command=command.command,
|
||||
modulation=command.modulation,
|
||||
repeat_count=2,
|
||||
)
|
||||
timings_with_repeats = command_with_repeats.get_raw_timings()
|
||||
assert timings_with_repeats == [
|
||||
*expected_raw_timings[:-1],
|
||||
Timing(high_us=562, low_us=96000),
|
||||
Timing(high_us=9000, low_us=2250),
|
||||
Timing(high_us=562, low_us=96000),
|
||||
Timing(high_us=9000, low_us=2250),
|
||||
Timing(high_us=562, low_us=0),
|
||||
]
|
||||
|
||||
|
||||
def test_nec_command_get_raw_timings_extended() -> None:
|
||||
"""Test NEC command raw timings generation for extended 16-bit address."""
|
||||
expected_raw_timings = [
|
||||
Timing(high_us=9000, low_us=4500),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=562),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=1687),
|
||||
Timing(high_us=562, low_us=0),
|
||||
]
|
||||
|
||||
command = NECInfraredCommand(
|
||||
address=0x04FB, command=0x08, modulation=38000, repeat_count=0
|
||||
)
|
||||
timings = command.get_raw_timings()
|
||||
assert timings == expected_raw_timings
|
||||
|
||||
# Same command now with 2 repeats
|
||||
command_with_repeats = NECInfraredCommand(
|
||||
address=command.address,
|
||||
command=command.command,
|
||||
modulation=command.modulation,
|
||||
repeat_count=2,
|
||||
)
|
||||
timings_with_repeats = command_with_repeats.get_raw_timings()
|
||||
assert timings_with_repeats == [
|
||||
*expected_raw_timings[:-1],
|
||||
Timing(high_us=562, low_us=96000),
|
||||
Timing(high_us=9000, low_us=2250),
|
||||
Timing(high_us=562, low_us=96000),
|
||||
Timing(high_us=9000, low_us=2250),
|
||||
Timing(high_us=562, low_us=0),
|
||||
]
|
||||
Reference in New Issue
Block a user