Add Harbor Sleep integration (#176171)

This commit is contained in:
Luke Lashley
2026-07-17 11:44:46 -04:00
committed by GitHub
parent 773553e61b
commit 1917e8a877
21 changed files with 1584 additions and 0 deletions
Generated
+2
View File
@@ -719,6 +719,8 @@ CLAUDE.md @home-assistant/core
/tests/components/habitica/ @tr4nt0r
/homeassistant/components/hanna/ @bestycame
/tests/components/hanna/ @bestycame
/homeassistant/components/harbor/ @Lash-L @afgarcia86
/tests/components/harbor/ @Lash-L @afgarcia86
/homeassistant/components/hardkernel/ @home-assistant/core
/tests/components/hardkernel/ @home-assistant/core
/homeassistant/components/hardware/ @home-assistant/core
@@ -0,0 +1,43 @@
"""The Harbor integration."""
from harbor.config import HarborCameraConfig
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN, PLATFORMS
from .coordinator import HarborConfigEntry, HarborCoordinator
async def async_setup_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool:
"""Set up Harbor from a config entry."""
coordinator = HarborCoordinator(
hass,
entry,
HarborCameraConfig(
serial=entry.data[CONF_SERIAL],
cert_pem=entry.data[CONF_CERT_PEM],
key_pem=entry.data[CONF_KEY_PEM],
ip_address=entry.data[CONF_IP_ADDRESS],
),
)
await coordinator.async_start()
try:
await coordinator.async_wait_until_ready()
except TimeoutError as err:
await coordinator.async_shutdown()
raise ConfigEntryNotReady(
translation_domain=DOMAIN, translation_key="cannot_connect"
) from err
entry.runtime_data = coordinator
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True
async def async_unload_entry(hass: HomeAssistant, entry: HarborConfigEntry) -> bool:
"""Unload a Harbor config entry."""
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
await entry.runtime_data.async_shutdown()
return unload_ok
@@ -0,0 +1,128 @@
"""Config flow for Harbor."""
from typing import Any, override
from harbor.config import HarborCameraConfig
import voluptuous as vol
from homeassistant.config_entries import ConfigFlow, ConfigFlowResult
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.helpers import selector
from .const import CONF_CERT_PEM, CONF_KEY_PEM, CONF_SERIAL, DOMAIN
from .coordinator import async_probe_camera
SERIAL_LENGTH = 10
STEP_USER_SCHEMA = vol.Schema(
{
vol.Required(CONF_SERIAL): selector.TextSelector(selector.TextSelectorConfig()),
vol.Required(CONF_CERT_PEM): selector.TextSelector(
selector.TextSelectorConfig(multiline=True)
),
vol.Required(CONF_KEY_PEM): selector.TextSelector(
selector.TextSelectorConfig(multiline=True)
),
vol.Required(CONF_IP_ADDRESS): selector.TextSelector(
selector.TextSelectorConfig()
),
}
)
def _validate_serial(value: str) -> bool:
"""Validate the Harbor serial number."""
return len(value) == SERIAL_LENGTH and value.isdigit()
def _validate_cert_pem(value: str) -> bool:
"""Validate a Harbor client certificate PEM blob."""
value = value.strip()
return value.startswith("-----BEGIN CERTIFICATE-----") and value.endswith(
"-----END CERTIFICATE-----"
)
def _validate_key_pem(value: str) -> bool:
"""Validate a Harbor private key PEM blob."""
value = value.strip()
return value.startswith("-----BEGIN PRIVATE KEY-----") and value.endswith(
"-----END PRIVATE KEY-----"
)
def _validate_credentials(cert_pem: str, key_pem: str) -> dict[str, str]:
"""Validate cert/key PEM blobs and return any errors."""
errors: dict[str, str] = {}
if not _validate_cert_pem(cert_pem):
errors[CONF_CERT_PEM] = "invalid_cert"
if not _validate_key_pem(key_pem):
errors[CONF_KEY_PEM] = "invalid_key"
return errors
class HarborConfigFlow(ConfigFlow, domain=DOMAIN):
"""Handle a config flow for Harbor."""
VERSION = 1
@override
async def async_step_user(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Handle the initial step."""
if user_input is None:
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_SCHEMA,
errors={},
)
normalized = {
key: value.strip() if isinstance(value, str) else value
for key, value in user_input.items()
}
errors: dict[str, str] = {}
display_name: str | None = None
serial = normalized[CONF_SERIAL]
if not _validate_serial(serial):
errors[CONF_SERIAL] = "invalid_serial"
errors.update(
_validate_credentials(normalized[CONF_CERT_PEM], normalized[CONF_KEY_PEM])
)
if not errors:
await self.async_set_unique_id(serial)
self._abort_if_unique_id_configured()
config = HarborCameraConfig(
serial=serial,
cert_pem=normalized[CONF_CERT_PEM],
key_pem=normalized[CONF_KEY_PEM],
ip_address=normalized[CONF_IP_ADDRESS],
)
try:
display_name = await async_probe_camera(config)
except TimeoutError:
errors["base"] = "cannot_connect"
if errors:
return self.async_show_form(
step_id="user",
data_schema=STEP_USER_SCHEMA,
errors=errors,
)
entry_data: dict[str, Any] = {
CONF_SERIAL: serial,
CONF_CERT_PEM: normalized[CONF_CERT_PEM],
CONF_KEY_PEM: normalized[CONF_KEY_PEM],
CONF_IP_ADDRESS: normalized[CONF_IP_ADDRESS],
}
return self.async_create_entry(
title=display_name or f"Camera {serial}",
data=entry_data,
)
+13
View File
@@ -0,0 +1,13 @@
"""Constants for the Harbor integration."""
from homeassistant.const import Platform
DOMAIN = "harbor"
MANUFACTURER = "Harbor"
MODEL = "Harbor Camera"
PLATFORMS: list[Platform] = [Platform.SENSOR]
CONF_CERT_PEM = "cert_pem"
CONF_KEY_PEM = "key_pem"
CONF_SERIAL = "serial"
@@ -0,0 +1,176 @@
"""Coordinator for Harbor."""
import asyncio
import logging
from typing import Any, override
from uuid import uuid4
from harbor.config import HarborCameraConfig
from harbor.devices.camera import HarborCamera
from harbor.mqtt import DEFAULT_INITIAL_COMMANDS, HarborMQTTClient
from harbor.state import HarborDeviceState
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant
from homeassistant.helpers import instance_id
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
from .const import DOMAIN, MANUFACTURER, MODEL
LOGGER = logging.getLogger(__name__)
type HarborConfigEntry = ConfigEntry[HarborCoordinator]
# How long to wait for the first successful MQTT connection and the first
# device data to arrive before treating the camera as unreachable, both when
# validating the config flow and during setup.
CONNECT_TIMEOUT = 30.0
async def _discard_message(topic: str, payload: Any) -> None:
"""Ignore messages received while probing the connection."""
async def async_probe_camera(config: HarborCameraConfig) -> str | None:
"""Connect to a Harbor camera and return its friendly name, if any.
Raises ``TimeoutError`` when no MQTT session can be established with the
camera. Returns the camera's configured display name, or ``None`` when the
camera is reachable but has no name (or does not answer the settings
request in time).
"""
connected = asyncio.Event()
async def _on_connection_change(is_connected: bool) -> None:
if is_connected:
connected.set()
client = HarborMQTTClient(
config=config,
# Subscribe to the responses topic so the get-settings reply can be
# matched to its pending request; without a subscription the reply
# never reaches the client and the request would time out.
topics=[f"cameras/{config.serial}/responses/#"],
message_handler=_discard_message,
client_id=f"{DOMAIN}-{config.serial}-probe-{uuid4().hex[:8]}",
on_connection_change=_on_connection_change,
connection_grace_period=0,
)
await client.start()
try:
async with asyncio.timeout(CONNECT_TIMEOUT):
await connected.wait()
try:
settings = await client.get_settings()
except TimeoutError, ConnectionError:
return None
if settings.settings is None:
return None
return settings.settings.preference_display_name
finally:
await client.stop()
class HarborCoordinator(DataUpdateCoordinator[HarborDeviceState]):
"""Own the MQTT transport and state for a single Harbor camera."""
config_entry: HarborConfigEntry
def __init__(
self,
hass: HomeAssistant,
entry: HarborConfigEntry,
config: HarborCameraConfig,
) -> None:
"""Initialize the Harbor coordinator."""
super().__init__(
hass,
LOGGER,
config_entry=entry,
name=f"{DOMAIN}_{config.serial}",
)
self._config = config
self.device = HarborCamera(config)
self.data = self.device.state
self.connected = False
self._ssl_context_cache: dict[str, Any] = {}
self._mqtt_client: HarborMQTTClient | None = None
self._connected_event = asyncio.Event()
self._data_event = asyncio.Event()
self._unsubscribe_updates = self.device.subscribe_updates(
self._handle_device_update
)
async def async_start(self) -> None:
"""Start the Harbor MQTT client."""
hass_instance_id = await instance_id.async_get(self.hass)
client_id = (
f"{DOMAIN}-{hass_instance_id[:8]}-"
f"{self.config_entry.entry_id[:8]}-{self._config.serial}"
)
self._mqtt_client = HarborMQTTClient(
config=self._config,
topics=self.device.get_topics(),
message_handler=self.device.handle_message,
client_id=client_id,
ssl_context_cache=self._ssl_context_cache,
on_connection_change=self._async_set_connected,
# Fetch the full settings snapshot on every (re)connection so the
# device name and settings-derived state populate immediately
# instead of waiting for the next heartbeat.
initial_commands=DEFAULT_INITIAL_COMMANDS,
)
await self._mqtt_client.start()
async def async_wait_until_ready(self) -> None:
"""Wait for the first MQTT connection and the first device data.
Registering entities only once the camera's first message has
arrived means the device registry sees the real name and firmware
from the start, instead of a placeholder that would otherwise
persist until the next reload.
Raises ``TimeoutError`` if the camera does not connect and report
data in time.
"""
async with asyncio.timeout(CONNECT_TIMEOUT):
await self._connected_event.wait()
await self._data_event.wait()
@override
async def async_shutdown(self) -> None:
"""Stop the MQTT client and release device resources."""
await super().async_shutdown()
if self._mqtt_client is not None:
await self._mqtt_client.stop()
self._mqtt_client = None
self._unsubscribe_updates()
self.device.shutdown()
@property
def device_info(self) -> DeviceInfo:
"""Return device info for the Harbor camera."""
state = self.data
return DeviceInfo(
identifiers={(DOMAIN, state.serial)},
manufacturer=MANUFACTURER,
model=MODEL,
name=state.display_name or f"{MODEL} {state.serial}",
serial_number=state.serial,
sw_version=state.os_version,
)
def _handle_device_update(self, state: HarborDeviceState) -> None:
"""Mirror a library device update into Home Assistant."""
self._data_event.set()
self.async_set_updated_data(state)
async def _async_set_connected(self, connected: bool) -> None:
"""Propagate the MQTT connection state to entity availability."""
if connected:
self._connected_event.set()
if self.connected == connected:
return
self.connected = connected
self.async_update_listeners()
+37
View File
@@ -0,0 +1,37 @@
"""Base entities for Harbor."""
from typing import override
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .coordinator import HarborCoordinator
class HarborEntity(CoordinatorEntity[HarborCoordinator]):
"""Base Harbor entity."""
_attr_has_entity_name = True
def __init__(
self,
coordinator: HarborCoordinator,
unique_key: str,
) -> None:
"""Initialize the Harbor entity."""
super().__init__(coordinator)
self._attr_unique_id = f"{coordinator.data.serial}_{unique_key}"
@override
@property
def available(self) -> bool:
"""Return if the entity is currently available."""
if not self.coordinator.connected:
return False
return self.coordinator.data.last_seen is not None
@override
@property
def device_info(self) -> DeviceInfo:
"""Return the device info for the backing Harbor device."""
return self.coordinator.device_info
@@ -0,0 +1,15 @@
{
"entity": {
"sensor": {
"num_viewers": {
"default": "mdi:account-eye"
},
"stream_quality": {
"default": "mdi:signal"
},
"wifi_strength": {
"default": "mdi:wifi"
}
}
}
}
@@ -0,0 +1,12 @@
{
"domain": "harbor",
"name": "Harbor Sleep",
"codeowners": ["@Lash-L", "@afgarcia86"],
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/harbor",
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["harbor"],
"quality_scale": "bronze",
"requirements": ["harbor-python==1.2.1"]
}
@@ -0,0 +1,73 @@
rules:
# Bronze
action-setup:
status: exempt
comment: This integration does not provide additional actions.
appropriate-polling:
status: exempt
comment: This integration is push-based via MQTT and does not poll.
brands: done
common-modules: done
config-flow-test-coverage: done
config-flow: done
dependency-transparency: done
docs-actions:
status: exempt
comment: This integration does not provide additional actions.
docs-conditions:
status: exempt
comment: This integration does not have any conditions.
docs-high-level-description: done
docs-installation-instructions: done
docs-removal-instructions: done
docs-triggers:
status: exempt
comment: This integration does not have any triggers.
entity-event-setup:
status: exempt
comment: Entities receive updates via the coordinator and do not subscribe to events directly.
entity-unique-id: done
has-entity-name: done
runtime-data: done
test-before-configure: done
test-before-setup: done
unique-config-entry: done
# Silver
action-exceptions: todo
config-entry-unloading: todo
docs-configuration-parameters: todo
docs-installation-parameters: todo
entity-unavailable: todo
integration-owner: todo
log-when-unavailable: todo
parallel-updates: todo
reauthentication-flow: todo
test-coverage: todo
# Gold
devices: todo
diagnostics: todo
discovery-update-info: todo
discovery: todo
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: todo
entity-category: todo
entity-device-class: todo
entity-disabled-by-default: todo
entity-translations: todo
exception-translations: todo
icon-translations: todo
reconfiguration-flow: todo
repair-issues: todo
stale-devices: todo
# Platinum
async-dependency: todo
inject-websession: todo
strict-typing: todo
+97
View File
@@ -0,0 +1,97 @@
"""Sensor entities for Harbor."""
from typing import override
from homeassistant.components.sensor import (
SensorDeviceClass,
SensorEntity,
SensorEntityDescription,
SensorStateClass,
)
from homeassistant.const import EntityCategory, UnitOfDataRate, UnitOfTemperature
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from homeassistant.helpers.typing import StateType
from .coordinator import HarborConfigEntry, HarborCoordinator
from .entity import HarborEntity
PARALLEL_UPDATES = 0
CAMERA_SENSORS: tuple[SensorEntityDescription, ...] = (
SensorEntityDescription(
key="num_viewers",
translation_key="num_viewers",
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="bitrate",
translation_key="bitrate",
device_class=SensorDeviceClass.DATA_RATE,
native_unit_of_measurement=UnitOfDataRate.KILOBITS_PER_SECOND,
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="wifi_strength",
translation_key="wifi_strength",
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
state_class=SensorStateClass.MEASUREMENT,
),
SensorEntityDescription(
key="stream_quality",
translation_key="stream_quality",
device_class=SensorDeviceClass.ENUM,
options=["excellent", "fair", "good", "poor"],
entity_category=EntityCategory.DIAGNOSTIC,
entity_registry_enabled_default=False,
),
SensorEntityDescription(
key="temperature",
device_class=SensorDeviceClass.TEMPERATURE,
native_unit_of_measurement=UnitOfTemperature.FAHRENHEIT,
state_class=SensorStateClass.MEASUREMENT,
),
)
async def async_setup_entry(
hass: HomeAssistant,
entry: HarborConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Harbor sensors from a config entry."""
coordinator = entry.runtime_data
async_add_entities(
HarborSensor(coordinator, description) for description in CAMERA_SENSORS
)
class HarborSensor(HarborEntity, SensorEntity):
"""A Harbor sensor entity."""
def __init__(
self,
coordinator: HarborCoordinator,
description: SensorEntityDescription,
) -> None:
"""Initialize the Harbor sensor."""
self.entity_description = description
super().__init__(coordinator, description.key)
@override
@property
def native_value(self) -> StateType:
"""Return the current sensor value."""
value = self.coordinator.data.values.get(self.entity_description.key)
if (
self.entity_description.device_class == SensorDeviceClass.ENUM
and value == "unknown"
):
# The library falls back to the literal string "unknown" for any
# enum value it doesn't recognize; surface that as no value
# rather than a bogus member of the options list.
return None
return value
@@ -0,0 +1,59 @@
{
"config": {
"abort": {
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]"
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"invalid_cert": "The client certificate must be a valid PEM certificate",
"invalid_key": "The private key must be a valid PEM private key",
"invalid_serial": "The serial number must be exactly 10 digits"
},
"step": {
"user": {
"data": {
"cert_pem": "Client certificate",
"ip_address": "[%key:common::config_flow::data::ip%]",
"key_pem": "Private key",
"serial": "Serial number"
},
"data_description": {
"cert_pem": "Paste the client certificate from the Harbor app.",
"ip_address": "The local IP address of the Harbor device.",
"key_pem": "Paste the private key that matches the client certificate.",
"serial": "The 10-digit serial number printed on the Harbor device."
},
"title": "Set up Harbor"
}
}
},
"entity": {
"sensor": {
"bitrate": {
"name": "Bitrate"
},
"num_viewers": {
"name": "Viewers",
"unit_of_measurement": "viewers"
},
"stream_quality": {
"name": "Stream quality",
"state": {
"excellent": "Excellent",
"fair": "Fair",
"good": "Good",
"poor": "Poor"
}
},
"wifi_strength": {
"name": "Wi-Fi strength",
"unit_of_measurement": "bars"
}
}
},
"exceptions": {
"cannot_connect": {
"message": "Could not connect to the Harbor camera. It may be offline or unreachable."
}
}
}
+1
View File
@@ -307,6 +307,7 @@ FLOWS = {
"guntamatic",
"habitica",
"hanna",
"harbor",
"harman_luxury",
"harmony",
"hdfury",
@@ -2758,6 +2758,12 @@
"config_flow": true,
"iot_class": "cloud_polling"
},
"harbor": {
"name": "Harbor Sleep",
"integration_type": "device",
"config_flow": true,
"iot_class": "local_push"
},
"hardkernel": {
"name": "Hardkernel",
"integration_type": "hardware",
+3
View File
@@ -1230,6 +1230,9 @@ habluetooth==6.26.5
# homeassistant.components.hanna
hanna-cloud==0.0.7
# homeassistant.components.harbor
harbor-python==1.2.1
# homeassistant.components.cloud
hass-nabucasa==2.2.0
+12
View File
@@ -0,0 +1,12 @@
"""Tests for the Harbor integration."""
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry
async def setup_integration(hass: HomeAssistant, entry: MockConfigEntry) -> None:
"""Set up the Harbor integration in Home Assistant."""
entry.add_to_hass(hass)
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
+118
View File
@@ -0,0 +1,118 @@
"""Common fixtures for the Harbor tests."""
from collections.abc import Awaitable, Callable, Generator
from types import SimpleNamespace
from typing import Any
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant.components.harbor.const import (
CONF_CERT_PEM,
CONF_KEY_PEM,
CONF_SERIAL,
DOMAIN,
)
from homeassistant.const import CONF_IP_ADDRESS
from tests.common import MockConfigEntry
SERIAL = "1234567890"
CERT_PEM = "-----BEGIN CERTIFICATE-----\nMIIBdummy\n-----END CERTIFICATE-----"
KEY_PEM = "-----BEGIN PRIVATE KEY-----\nMIIBdummy\n-----END PRIVATE KEY-----"
HEARTBEAT_TOPIC = f"cameras/{SERIAL}/events/heartbeat"
LIVEKIT_TOPIC = f"cameras/{SERIAL}/events/local_livekit_heartbeat"
HEARTBEAT_PAYLOAD: dict[str, Any] = {
"temperature": 98.6,
"os_version": "1.2.3",
"settings": {"preference_display_name": "Nursery"},
}
LIVEKIT_PAYLOAD: dict[str, Any] = {
"bitrate": 1234.5,
"network_bars": 3,
"stream_quality": "GOOD",
"viewers_by_identity_full": {
"viewer-1": {"identity": "alice"},
"viewer-2": {"identity": "bob"},
},
"os_version": "1.2.3",
"app_version": "4.5.6",
}
def connection_callback(
mock_mqtt_client: AsyncMock,
) -> Callable[[bool], Awaitable[None]]:
"""Return the on_connection_change callback the integration registered."""
return mock_mqtt_client.call_args.kwargs["on_connection_change"]
async def emit_message(
mock_mqtt_client: AsyncMock, topic: str, payload: dict[str, Any]
) -> None:
"""Deliver an MQTT message through the handler the integration registered."""
await mock_mqtt_client.call_args.kwargs["message_handler"](topic, payload)
async def set_connected(mock_mqtt_client: AsyncMock, connected: bool) -> None:
"""Drive the MQTT connection state the integration observes."""
await connection_callback(mock_mqtt_client)(connected)
@pytest.fixture(autouse=True)
def mock_connect_timeout() -> Generator[None]:
"""Patch the connect timeout so unreachable-camera tests run quickly."""
with patch("homeassistant.components.harbor.coordinator.CONNECT_TIMEOUT", 0):
yield
@pytest.fixture
def mock_setup_entry() -> Generator[AsyncMock]:
"""Override async_setup_entry."""
with patch(
"homeassistant.components.harbor.async_setup_entry", return_value=True
) as mock_setup_entry:
yield mock_setup_entry
@pytest.fixture
def mock_mqtt_client() -> Generator[AsyncMock]:
"""Mock the Harbor MQTT client, reporting a successful connection on start."""
with patch(
"homeassistant.components.harbor.coordinator.HarborMQTTClient",
autospec=True,
) as mock_client:
async def _start() -> None:
await set_connected(mock_client, True)
# Setup waits for the first device message too; simulate the
# initial-commands response landing right after connect, the
# same way a real camera answers before any explicit test
# message. Empty so it doesn't set values tests don't expect.
await mock_client.call_args.kwargs["message_handler"](HEARTBEAT_TOPIC, {})
mock_client.return_value.start.side_effect = _start
# The config flow probes get-settings for the camera's friendly name;
# default to an unnamed camera so the title falls back to the serial.
mock_client.return_value.get_settings.return_value = SimpleNamespace(
settings=SimpleNamespace(preference_display_name=None)
)
yield mock_client
@pytest.fixture
def mock_config_entry() -> MockConfigEntry:
"""Return a mock Harbor config entry."""
return MockConfigEntry(
domain=DOMAIN,
unique_id=SERIAL,
title=f"Camera {SERIAL}",
data={
CONF_SERIAL: SERIAL,
CONF_CERT_PEM: CERT_PEM,
CONF_KEY_PEM: KEY_PEM,
CONF_IP_ADDRESS: "192.168.1.10",
},
)
@@ -0,0 +1,32 @@
# serializer version: 1
# name: test_device_registry
DeviceRegistryEntrySnapshot({
'area_id': None,
'config_entries': <ANY>,
'config_entries_subentries': <ANY>,
'configuration_url': None,
'connections': set({
}),
'disabled_by': None,
'entry_type': None,
'hw_version': None,
'id': <ANY>,
'identifiers': set({
tuple(
'harbor',
'1234567890',
),
}),
'labels': set({
}),
'manufacturer': 'Harbor',
'model': 'Harbor Camera',
'model_id': None,
'name': 'Nursery',
'name_by_user': None,
'primary_config_entry': <ANY>,
'serial_number': '1234567890',
'sw_version': '1.2.3',
'via_device_id': None,
})
# ---
@@ -0,0 +1,289 @@
# serializer version: 1
# name: test_sensors[sensor.harbor_camera_1234567890_bitrate-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.harbor_camera_1234567890_bitrate',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Bitrate',
'options': dict({
'sensor': dict({
'suggested_display_precision': 0,
}),
}),
'original_device_class': <SensorDeviceClass.DATA_RATE: 'data_rate'>,
'original_icon': None,
'original_name': 'Bitrate',
'platform': 'harbor',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'bitrate',
'unique_id': '1234567890_bitrate',
'unit_of_measurement': <UnitOfDataRate.KILOBITS_PER_SECOND: 'kbit/s'>,
})
# ---
# name: test_sensors[sensor.harbor_camera_1234567890_bitrate-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'data_rate',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Harbor Camera 1234567890 Bitrate',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfDataRate.KILOBITS_PER_SECOND: 'kbit/s'>,
}),
'context': <ANY>,
'entity_id': 'sensor.harbor_camera_1234567890_bitrate',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '1234.5',
})
# ---
# name: test_sensors[sensor.harbor_camera_1234567890_stream_quality-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'excellent',
'fair',
'good',
'poor',
]),
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.harbor_camera_1234567890_stream_quality',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Stream quality',
'options': dict({
}),
'original_device_class': <SensorDeviceClass.ENUM: 'enum'>,
'original_icon': None,
'original_name': 'Stream quality',
'platform': 'harbor',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'stream_quality',
'unique_id': '1234567890_stream_quality',
'unit_of_measurement': None,
})
# ---
# name: test_sensors[sensor.harbor_camera_1234567890_stream_quality-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'enum',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Harbor Camera 1234567890 Stream quality',
<SensorEntityCapabilityAttribute.OPTIONS: 'options'>: list([
'excellent',
'fair',
'good',
'poor',
]),
}),
'context': <ANY>,
'entity_id': 'sensor.harbor_camera_1234567890_stream_quality',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': 'good',
})
# ---
# name: test_sensors[sensor.harbor_camera_1234567890_temperature-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.harbor_camera_1234567890_temperature',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Temperature',
'options': dict({
'sensor': dict({
'suggested_display_precision': 1,
}),
}),
'original_device_class': <SensorDeviceClass.TEMPERATURE: 'temperature'>,
'original_icon': None,
'original_name': 'Temperature',
'platform': 'harbor',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': None,
'unique_id': '1234567890_temperature',
'unit_of_measurement': <UnitOfTemperature.CELSIUS: '°C'>,
})
# ---
# name: test_sensors[sensor.harbor_camera_1234567890_temperature-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.DEVICE_CLASS: 'device_class'>: 'temperature',
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Harbor Camera 1234567890 Temperature',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: <UnitOfTemperature.CELSIUS: '°C'>,
}),
'context': <ANY>,
'entity_id': 'sensor.harbor_camera_1234567890_temperature',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '37.0',
})
# ---
# name: test_sensors[sensor.harbor_camera_1234567890_viewers-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': None,
'entity_id': 'sensor.harbor_camera_1234567890_viewers',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Viewers',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Viewers',
'platform': 'harbor',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'num_viewers',
'unique_id': '1234567890_num_viewers',
'unit_of_measurement': 'viewers',
})
# ---
# name: test_sensors[sensor.harbor_camera_1234567890_viewers-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Harbor Camera 1234567890 Viewers',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: 'viewers',
}),
'context': <ANY>,
'entity_id': 'sensor.harbor_camera_1234567890_viewers',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '2',
})
# ---
# name: test_sensors[sensor.harbor_camera_1234567890_wi_fi_strength-entry]
EntityRegistryEntrySnapshot({
'aliases': list([
None,
]),
'area_id': None,
'capabilities': dict({
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
}),
'config_entry_id': <ANY>,
'config_subentry_id': <ANY>,
'device_class': None,
'device_id': <ANY>,
'disabled_by': None,
'domain': 'sensor',
'entity_category': <EntityCategory.DIAGNOSTIC: 'diagnostic'>,
'entity_id': 'sensor.harbor_camera_1234567890_wi_fi_strength',
'has_entity_name': True,
'hidden_by': None,
'icon': None,
'id': <ANY>,
'labels': set({
}),
'name': None,
'object_id_base': 'Wi-Fi strength',
'options': dict({
}),
'original_device_class': None,
'original_icon': None,
'original_name': 'Wi-Fi strength',
'platform': 'harbor',
'previous_unique_id': None,
'suggested_object_id': None,
'supported_features': 0,
'translation_key': 'wifi_strength',
'unique_id': '1234567890_wifi_strength',
'unit_of_measurement': 'bars',
})
# ---
# name: test_sensors[sensor.harbor_camera_1234567890_wi_fi_strength-state]
StateSnapshot({
'attributes': ReadOnlyDict({
<EntityStateAttribute.FRIENDLY_NAME: 'friendly_name'>: 'Harbor Camera 1234567890 Wi-Fi strength',
<SensorEntityCapabilityAttribute.STATE_CLASS: 'state_class'>: <SensorStateClass.MEASUREMENT: 'measurement'>,
<EntityStateAttribute.UNIT_OF_MEASUREMENT: 'unit_of_measurement'>: 'bars',
}),
'context': <ANY>,
'entity_id': 'sensor.harbor_camera_1234567890_wi_fi_strength',
'last_changed': <ANY>,
'last_reported': <ANY>,
'last_updated': <ANY>,
'state': '3',
})
# ---
+231
View File
@@ -0,0 +1,231 @@
"""Test the Harbor config flow."""
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from homeassistant.components.harbor.const import (
CONF_CERT_PEM,
CONF_KEY_PEM,
CONF_SERIAL,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.const import CONF_IP_ADDRESS
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
from .conftest import CERT_PEM, KEY_PEM, SERIAL, set_connected
from tests.common import MockConfigEntry
@pytest.mark.usefixtures("mock_mqtt_client")
async def test_user_flow(
hass: HomeAssistant,
mock_setup_entry: AsyncMock,
mock_mqtt_client: AsyncMock,
) -> None:
"""Test the full user flow creates an entry."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
assert result["step_id"] == "user"
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_SERIAL: SERIAL,
CONF_CERT_PEM: CERT_PEM,
CONF_KEY_PEM: KEY_PEM,
CONF_IP_ADDRESS: "192.168.1.10",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == f"Camera {SERIAL}"
assert result["result"].unique_id == SERIAL
assert result["data"] == {
CONF_SERIAL: SERIAL,
CONF_CERT_PEM: CERT_PEM,
CONF_KEY_PEM: KEY_PEM,
CONF_IP_ADDRESS: "192.168.1.10",
}
client_id = mock_mqtt_client.call_args.kwargs["client_id"]
assert client_id.startswith(f"{DOMAIN}-{SERIAL}-probe-")
assert client_id != f"{DOMAIN}-{SERIAL}-probe"
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_uses_friendly_name(
hass: HomeAssistant, mock_mqtt_client: AsyncMock
) -> None:
"""Test the entry is titled with the camera's friendly name when set."""
mock_mqtt_client.return_value.get_settings.return_value = SimpleNamespace(
settings=SimpleNamespace(preference_display_name="Nursery")
)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_SERIAL: SERIAL,
CONF_CERT_PEM: CERT_PEM,
CONF_KEY_PEM: KEY_PEM,
CONF_IP_ADDRESS: "192.168.1.10",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
assert result["title"] == "Nursery"
@pytest.mark.parametrize(
("user_input", "error_field", "error"),
[
(
{
CONF_SERIAL: "123",
CONF_CERT_PEM: CERT_PEM,
CONF_KEY_PEM: KEY_PEM,
CONF_IP_ADDRESS: "192.168.1.10",
},
CONF_SERIAL,
"invalid_serial",
),
(
{
CONF_SERIAL: "abcdefghij",
CONF_CERT_PEM: CERT_PEM,
CONF_KEY_PEM: KEY_PEM,
CONF_IP_ADDRESS: "192.168.1.10",
},
CONF_SERIAL,
"invalid_serial",
),
(
{
CONF_SERIAL: SERIAL,
CONF_CERT_PEM: "not a cert",
CONF_KEY_PEM: KEY_PEM,
CONF_IP_ADDRESS: "192.168.1.10",
},
CONF_CERT_PEM,
"invalid_cert",
),
(
{
CONF_SERIAL: SERIAL,
CONF_CERT_PEM: CERT_PEM,
CONF_KEY_PEM: "not a key",
CONF_IP_ADDRESS: "192.168.1.10",
},
CONF_KEY_PEM,
"invalid_key",
),
],
ids=["short_serial", "non_digit_serial", "bad_cert", "bad_key"],
)
@pytest.mark.usefixtures("mock_mqtt_client", "mock_setup_entry")
async def test_user_flow_validation_errors(
hass: HomeAssistant,
user_input: dict[str, str],
error_field: str,
error: str,
) -> None:
"""Test validation errors are surfaced and recoverable."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"], user_input
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {error_field: error}
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_SERIAL: SERIAL,
CONF_CERT_PEM: CERT_PEM,
CONF_KEY_PEM: KEY_PEM,
CONF_IP_ADDRESS: "192.168.1.10",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_already_configured(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test the flow aborts when the serial is already configured."""
mock_config_entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_SERIAL: SERIAL,
CONF_CERT_PEM: CERT_PEM,
CONF_KEY_PEM: KEY_PEM,
CONF_IP_ADDRESS: "192.168.1.10",
},
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "already_configured"
@pytest.mark.usefixtures("mock_setup_entry")
async def test_user_flow_cannot_connect(
hass: HomeAssistant,
mock_mqtt_client: AsyncMock,
) -> None:
"""Test the flow shows an error and recovers when the camera is unreachable."""
# Start the probe client without ever reporting a successful connection.
mock_mqtt_client.return_value.start.side_effect = None
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": SOURCE_USER}
)
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_SERIAL: SERIAL,
CONF_CERT_PEM: CERT_PEM,
CONF_KEY_PEM: KEY_PEM,
CONF_IP_ADDRESS: "192.168.1.10",
},
)
assert result["type"] is FlowResultType.FORM
assert result["errors"] == {"base": "cannot_connect"}
# A subsequent connection succeeds and the entry is created.
async def _start() -> None:
await set_connected(mock_mqtt_client, True)
mock_mqtt_client.return_value.start.side_effect = _start
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
{
CONF_SERIAL: SERIAL,
CONF_CERT_PEM: CERT_PEM,
CONF_KEY_PEM: KEY_PEM,
CONF_IP_ADDRESS: "192.168.1.10",
},
)
assert result["type"] is FlowResultType.CREATE_ENTRY
+144
View File
@@ -0,0 +1,144 @@
"""Test the Harbor integration setup and coordinator."""
from unittest.mock import AsyncMock
from syrupy.assertion import SnapshotAssertion
from homeassistant.components.harbor.const import DOMAIN
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import device_registry as dr
from . import setup_integration
from .conftest import (
HEARTBEAT_PAYLOAD,
HEARTBEAT_TOPIC,
SERIAL,
emit_message,
set_connected,
)
from tests.common import MockConfigEntry
# The default test fixture reports no device data on connect, so the device
# keeps its placeholder name and the entity id derives from that.
_SENSOR = "sensor.harbor_camera_1234567890_temperature"
async def test_setup_and_unload(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_mqtt_client: AsyncMock,
) -> None:
"""Test a config entry loads, starts the client, and unloads cleanly."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
assert mock_mqtt_client.return_value.start.called
assert await hass.config_entries.async_unload(mock_config_entry.entry_id)
await hass.async_block_till_done()
assert mock_config_entry.state is ConfigEntryState.NOT_LOADED
assert mock_mqtt_client.return_value.stop.called
async def test_setup_uses_instance_scoped_mqtt_client_id(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_mqtt_client: AsyncMock,
) -> None:
"""Test setup uses an MQTT client id unique to this HA instance."""
await setup_integration(hass, mock_config_entry)
client_id = mock_mqtt_client.call_args.kwargs["client_id"]
assert client_id.startswith(f"{DOMAIN}-")
assert client_id.endswith(f"-{SERIAL}")
assert client_id != f"{DOMAIN}-{SERIAL}"
async def test_setup_retry_when_unreachable(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_mqtt_client: AsyncMock,
) -> None:
"""Test setup is retried when the camera never connects."""
# Start the client without ever reporting a successful connection.
mock_mqtt_client.return_value.start.side_effect = None
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert mock_mqtt_client.return_value.stop.called
async def test_setup_retry_when_no_data_arrives(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_mqtt_client: AsyncMock,
) -> None:
"""Test setup is retried when the camera connects but never sends data."""
async def _start() -> None:
await set_connected(mock_mqtt_client, True)
mock_mqtt_client.return_value.start.side_effect = _start
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY
assert mock_mqtt_client.return_value.stop.called
async def test_availability_follows_connection(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_mqtt_client: AsyncMock,
) -> None:
"""Test entity availability tracks the MQTT connection."""
await setup_integration(hass, mock_config_entry)
# Setup waits for the first device message, so entities start available.
assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE
# A repeated connected signal is a no-op and keeps entities available.
await set_connected(mock_mqtt_client, True)
await hass.async_block_till_done()
assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE
# Losing the connection flips entities back to unavailable.
await set_connected(mock_mqtt_client, False)
await hass.async_block_till_done()
assert hass.states.get(_SENSOR).state == STATE_UNAVAILABLE
# Reconnecting restores availability without needing fresh device data.
await set_connected(mock_mqtt_client, True)
await hass.async_block_till_done()
assert hass.states.get(_SENSOR).state != STATE_UNAVAILABLE
async def test_device_registry(
hass: HomeAssistant,
device_registry: dr.DeviceRegistry,
mock_config_entry: MockConfigEntry,
mock_mqtt_client: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test the device adopts the name and firmware from the first message.
Setup waits for that first message before registering entities, so the
device is correct from the start instead of needing a later reload.
"""
async def _start() -> None:
await set_connected(mock_mqtt_client, True)
await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD)
mock_mqtt_client.return_value.start.side_effect = _start
await setup_integration(hass, mock_config_entry)
device = device_registry.async_get_device(identifiers={(DOMAIN, SERIAL)})
assert device == snapshot
+93
View File
@@ -0,0 +1,93 @@
"""Test the Harbor sensors."""
from unittest.mock import AsyncMock
import pytest
from syrupy.assertion import SnapshotAssertion
from homeassistant.config_entries import ConfigEntryState
from homeassistant.const import STATE_UNKNOWN
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import setup_integration
from .conftest import (
HEARTBEAT_PAYLOAD,
HEARTBEAT_TOPIC,
LIVEKIT_PAYLOAD,
LIVEKIT_TOPIC,
emit_message,
)
from tests.common import MockConfigEntry, snapshot_platform
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_sensors(
hass: HomeAssistant,
entity_registry: er.EntityRegistry,
mock_config_entry: MockConfigEntry,
mock_mqtt_client: AsyncMock,
snapshot: SnapshotAssertion,
) -> None:
"""Test the Harbor sensors report their values."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.LOADED
await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD)
await emit_message(mock_mqtt_client, LIVEKIT_TOPIC, LIVEKIT_PAYLOAD)
await hass.async_block_till_done()
await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_missing_values_are_unknown(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_mqtt_client: AsyncMock,
) -> None:
"""Test sensors without a value in the payload report unknown."""
await setup_integration(hass, mock_config_entry)
# Only the heartbeat arrives; sensors fed by the LiveKit message stay unknown.
await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD)
await hass.async_block_till_done()
assert (
hass.states.get("sensor.harbor_camera_1234567890_temperature").state == "37.0"
)
assert (
hass.states.get("sensor.harbor_camera_1234567890_bitrate").state
== STATE_UNKNOWN
)
@pytest.mark.usefixtures("entity_registry_enabled_by_default")
async def test_unexpected_enum_value_stays_valid(
hass: HomeAssistant,
mock_config_entry: MockConfigEntry,
mock_mqtt_client: AsyncMock,
) -> None:
"""Test a stream quality outside the declared options surfaces as unknown.
The library maps unrecognized enum values onto its own "unknown" member;
the sensor treats that as no value rather than exposing "unknown" as a
literal enum option.
"""
await setup_integration(hass, mock_config_entry)
entity_id = "sensor.harbor_camera_1234567890_stream_quality"
await emit_message(mock_mqtt_client, HEARTBEAT_TOPIC, HEARTBEAT_PAYLOAD)
await emit_message(mock_mqtt_client, LIVEKIT_TOPIC, LIVEKIT_PAYLOAD)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "good"
# The camera reports a stream quality outside the known set.
await emit_message(
mock_mqtt_client,
LIVEKIT_TOPIC,
{**LIVEKIT_PAYLOAD, "stream_quality": "DEGRADED"},
)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == STATE_UNKNOWN