Compare commits

..

1 Commits

Author SHA1 Message Date
abmantis
5f798795c0 Add PR review Claude skill 2026-03-02 19:51:09 +00:00
19 changed files with 1702 additions and 555 deletions

View File

@@ -0,0 +1,46 @@
---
name: github-pr-reviewer
description: Review a GitHub pull request and provide feedback comments. Use when the user says "review the current PR" or asks to review a specific PR.
---
# Review GitHub Pull Request
## Preparation:
- Check if the local commit matches the last one in the PR. If not, checkout the PR locally using 'gh pr checkout'.
- CRITICAL: If 'gh pr checkout' fails for ANY reason, you MUST immediately STOP.
- Do NOT attempt any workarounds.
- Do NOT proceed with the review.
- ALERT about the failure and WAIT for instructions.
- This is a hard requirement - no exceptions.
## Follow these steps:
1. Use 'gh pr view' to get the PR details and description.
2. Use 'gh pr diff' to see all the changes in the PR.
3. Analyze the code changes for:
- Code quality and style consistency
- Potential bugs or issues
- Performance implications
- Security concerns
- Test coverage
- Documentation updates if needed
4. Ensure any existing review comments have been addressed.
5. Generate constructive review comments in the CONSOLE. DO NOT POST TO GITHUB YOURSELF.
## IMPORTANT:
- Just review. DO NOT make any changes
- Be constructive and specific in your comments
- Suggest improvements where appropriate
- Only provide review feedback in the CONSOLE. DO NOT ACT ON GITHUB.
- No need to run tests or linters, just review the code changes.
- No need to highlight things that are already good.
## Output format:
- List specific comments for each file/line that needs attention
- In the end, summarize with an overall assessment (approve, request changes, or comment) and bullet point list of changes suggested, if any.
- Example output:
```
Overall assessment: request changes.
- [CRITICAL] Memory leak in homeassistant/components/sensor/my_sensor.py:143
- [PROBLEM] Inefficient algorithm in homeassistant/helpers/data_processing.py:87
- [SUGGESTION] Improve variable naming in homeassistant/helpers/config_validation.py:45
```

View File

@@ -189,7 +189,6 @@ async def platform_async_setup_entry(
info_type: type[_InfoT],
entity_type: type[_EntityT],
state_type: type[_StateT],
info_filter: Callable[[_InfoT], bool] | None = None,
) -> None:
"""Set up an esphome platform.
@@ -209,22 +208,10 @@ async def platform_async_setup_entry(
entity_type,
state_type,
)
if info_filter is not None:
def on_filtered_update(infos: list[EntityInfo]) -> None:
on_static_info_update(
[info for info in infos if info_filter(cast(_InfoT, info))]
)
info_callback = on_filtered_update
else:
info_callback = on_static_info_update
entry_data.cleanup_callbacks.append(
entry_data.async_register_static_info_callback(
info_type,
info_callback,
on_static_info_update,
)
)

View File

@@ -29,7 +29,6 @@ from aioesphomeapi import (
Event,
EventInfo,
FanInfo,
InfraredInfo,
LightInfo,
LockInfo,
MediaPlayerInfo,
@@ -86,7 +85,6 @@ 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,

View File

@@ -1,59 +0,0 @@
"""Infrared platform for ESPHome."""
from __future__ import annotations
from functools import partial
import logging
from aioesphomeapi import EntityState, InfraredCapability, InfraredInfo
from homeassistant.components.infrared import InfraredCommand, InfraredEntity
from homeassistant.core import callback
from .entity import (
EsphomeEntity,
convert_api_error_ha_error,
platform_async_setup_entry,
)
_LOGGER = logging.getLogger(__name__)
PARALLEL_UPDATES = 0
class EsphomeInfraredEntity(EsphomeEntity[InfraredInfo, EntityState], InfraredEntity):
"""ESPHome infrared entity using native API."""
@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()
@convert_api_error_ha_error
async def async_send_command(self, command: InfraredCommand) -> None:
"""Send an IR command."""
timings = [
interval
for timing in command.get_raw_timings()
for interval in (timing.high_us, -timing.low_us)
]
_LOGGER.debug("Sending command: %s", timings)
self._client.infrared_rf_transmit_raw_timings(
self._static_info.key,
carrier_frequency=command.modulation,
timings=timings,
device_id=self._static_info.device_id,
)
async_setup_entry = partial(
platform_async_setup_entry,
info_type=InfraredInfo,
entity_type=EsphomeInfraredEntity,
state_type=EntityState,
info_filter=lambda info: bool(info.capabilities & InfraredCapability.TRANSMITTER),
)

View File

@@ -54,10 +54,6 @@
"connectable": false,
"local_name": "GVH5110*"
},
{
"connectable": false,
"local_name": "GV5140*"
},
{
"connectable": false,
"manufacturer_id": 1,

View File

@@ -21,7 +21,6 @@ from homeassistant.components.sensor import (
)
from homeassistant.const import (
CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
CONCENTRATION_PARTS_PER_MILLION,
PERCENTAGE,
SIGNAL_STRENGTH_DECIBELS_MILLIWATT,
UnitOfTemperature,
@@ -73,12 +72,6 @@ SENSOR_DESCRIPTIONS = {
native_unit_of_measurement=CONCENTRATION_MICROGRAMS_PER_CUBIC_METER,
state_class=SensorStateClass.MEASUREMENT,
),
(DeviceClass.CO2, Units.CONCENTRATION_PARTS_PER_MILLION): SensorEntityDescription(
key=f"{DeviceClass.CO2}_{Units.CONCENTRATION_PARTS_PER_MILLION}",
device_class=SensorDeviceClass.CO2,
native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION,
state_class=SensorStateClass.MEASUREMENT,
),
}

View File

@@ -16,30 +16,23 @@ from onvif.client import (
)
from onvif.exceptions import ONVIFError
from onvif.util import stringify_onvif_error
import onvif_parsers
from zeep.exceptions import Fault, TransportError, ValidationError, XMLParseError
from homeassistant.components import webhook
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import EntityCategory
from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback
from homeassistant.helpers.device_registry import format_mac
from homeassistant.helpers.event import async_call_later
from homeassistant.helpers.network import NoURLAvailableError, get_url
from homeassistant.util import dt as dt_util
from .const import DOMAIN, LOGGER
from .models import Event, PullPointManagerState, WebHookManagerState
from .parsers import PARSERS
# Topics in this list are ignored because we do not want to create
# entities for them.
UNHANDLED_TOPICS: set[str] = {"tns1:MediaControl/VideoEncoderConfiguration"}
ENTITY_CATEGORY_MAPPING: dict[str, EntityCategory] = {
"diagnostic": EntityCategory.DIAGNOSTIC,
"config": EntityCategory.CONFIG,
}
SUBSCRIPTION_ERRORS = (Fault, TimeoutError, TransportError)
CREATE_ERRORS = (
ONVIFError,
@@ -88,18 +81,6 @@ PULLPOINT_MESSAGE_LIMIT = 100
PULLPOINT_COOLDOWN_TIME = 0.75
def _local_datetime_or_none(value: str) -> dt.datetime | None:
"""Convert strings to datetimes, if invalid, return None."""
# Handle cameras that return times like '0000-00-00T00:00:00Z' (e.g. Hikvision)
try:
ret = dt_util.parse_datetime(value)
except ValueError:
return None
if ret is not None:
return dt_util.as_local(ret)
return None
class EventManager:
"""ONVIF Event Manager."""
@@ -195,10 +176,7 @@ class EventManager:
# tns1:RuleEngine/CellMotionDetector/Motion
topic = msg.Topic._value_1.rstrip("/.") # noqa: SLF001
try:
event = await onvif_parsers.parse(topic, unique_id, msg)
error = None
except onvif_parsers.errors.UnknownTopicError:
if not (parser := PARSERS.get(topic)):
if topic not in UNHANDLED_TOPICS:
LOGGER.warning(
"%s: No registered handler for event from %s: %s",
@@ -208,6 +186,10 @@ class EventManager:
)
UNHANDLED_TOPICS.add(topic)
continue
try:
event = await parser(unique_id, msg)
error = None
except (AttributeError, KeyError) as e:
event = None
error = e
@@ -220,26 +202,10 @@ class EventManager:
error,
msg,
)
continue
return
value = event.value
if event.device_class == "timestamp" and isinstance(value, str):
value = _local_datetime_or_none(value)
ha_event = Event(
uid=event.uid,
name=event.name,
platform=event.platform,
device_class=event.device_class,
unit_of_measurement=event.unit_of_measurement,
value=value,
entity_category=ENTITY_CATEGORY_MAPPING.get(
event.entity_category or ""
),
entity_enabled=event.entity_enabled,
)
self.get_uids_by_platform(ha_event.platform).add(ha_event.uid)
self._events[ha_event.uid] = ha_event
self.get_uids_by_platform(event.platform).add(event.uid)
self._events[event.uid] = event
def get_uid(self, uid: str) -> Event | None:
"""Retrieve event for given id."""

View File

@@ -13,9 +13,5 @@
"integration_type": "device",
"iot_class": "local_push",
"loggers": ["onvif", "wsdiscovery", "zeep"],
"requirements": [
"onvif-zeep-async==4.0.4",
"onvif_parsers==1.2.2",
"WSDiscovery==2.1.2"
]
"requirements": ["onvif-zeep-async==4.0.4", "WSDiscovery==2.1.2"]
}

View File

@@ -0,0 +1,755 @@
"""ONVIF event parsers."""
from __future__ import annotations
from collections.abc import Callable, Coroutine
import dataclasses
import datetime
from typing import Any
from homeassistant.const import EntityCategory
from homeassistant.util import dt as dt_util
from homeassistant.util.decorator import Registry
from .models import Event
PARSERS: Registry[str, Callable[[str, Any], Coroutine[Any, Any, Event | None]]] = (
Registry()
)
VIDEO_SOURCE_MAPPING = {
"vsconf": "VideoSourceToken",
}
def extract_message(msg: Any) -> tuple[str, Any]:
"""Extract the message content and the topic."""
return msg.Topic._value_1, msg.Message._value_1 # noqa: SLF001
def _normalize_video_source(source: str) -> str:
"""Normalize video source.
Some cameras do not set the VideoSourceToken correctly so we get duplicate
sensors, so we need to normalize it to the correct value.
"""
return VIDEO_SOURCE_MAPPING.get(source, source)
def local_datetime_or_none(value: str) -> datetime.datetime | None:
"""Convert strings to datetimes, if invalid, return None."""
# To handle cameras that return times like '0000-00-00T00:00:00Z' (e.g. hikvision)
try:
ret = dt_util.parse_datetime(value)
except ValueError:
return None
if ret is not None:
return dt_util.as_local(ret)
return None
@PARSERS.register("tns1:VideoSource/MotionAlarm")
@PARSERS.register("tns1:Device/Trigger/tnshik:AlarmIn")
async def async_parse_motion_alarm(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:VideoSource/MotionAlarm
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Motion Alarm",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:VideoSource/ImageTooBlurry/AnalyticsService")
@PARSERS.register("tns1:VideoSource/ImageTooBlurry/ImagingService")
@PARSERS.register("tns1:VideoSource/ImageTooBlurry/RecordingService")
async def async_parse_image_too_blurry(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:VideoSource/ImageTooBlurry/*
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Image Too Blurry",
"binary_sensor",
"problem",
None,
payload.Data.SimpleItem[0].Value == "true",
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:VideoSource/ImageTooDark/AnalyticsService")
@PARSERS.register("tns1:VideoSource/ImageTooDark/ImagingService")
@PARSERS.register("tns1:VideoSource/ImageTooDark/RecordingService")
async def async_parse_image_too_dark(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:VideoSource/ImageTooDark/*
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Image Too Dark",
"binary_sensor",
"problem",
None,
payload.Data.SimpleItem[0].Value == "true",
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:VideoSource/ImageTooBright/AnalyticsService")
@PARSERS.register("tns1:VideoSource/ImageTooBright/ImagingService")
@PARSERS.register("tns1:VideoSource/ImageTooBright/RecordingService")
async def async_parse_image_too_bright(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:VideoSource/ImageTooBright/*
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Image Too Bright",
"binary_sensor",
"problem",
None,
payload.Data.SimpleItem[0].Value == "true",
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:VideoSource/GlobalSceneChange/AnalyticsService")
@PARSERS.register("tns1:VideoSource/GlobalSceneChange/ImagingService")
@PARSERS.register("tns1:VideoSource/GlobalSceneChange/RecordingService")
async def async_parse_scene_change(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:VideoSource/GlobalSceneChange/*
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Global Scene Change",
"binary_sensor",
"problem",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:AudioAnalytics/Audio/DetectedSound")
async def async_parse_detected_sound(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:AudioAnalytics/Audio/DetectedSound
"""
audio_source = ""
audio_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "AudioSourceConfigurationToken":
audio_source = source.Value
if source.Name == "AudioAnalyticsConfigurationToken":
audio_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{audio_source}_{audio_analytics}_{rule}",
"Detected Sound",
"binary_sensor",
"sound",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/FieldDetector/ObjectsInside")
async def async_parse_field_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/FieldDetector/ObjectsInside
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
"Field Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Motion")
async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/CellMotionDetector/Motion
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
"Cell Motion Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/MotionRegionDetector/Motion")
async def async_parse_motion_region_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MotionRegionDetector/Motion
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
"Motion Region Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value in ["1", "true"],
)
@PARSERS.register("tns1:RuleEngine/TamperDetector/Tamper")
async def async_parse_tamper_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/TamperDetector/Tamper
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
"Tamper Detection",
"binary_sensor",
"problem",
None,
payload.Data.SimpleItem[0].Value == "true",
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/DogCatDetect")
async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MyRuleDetector/DogCatDetect
"""
video_source = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "Source":
video_source = _normalize_video_source(source.Value)
return Event(
f"{uid}_{topic}_{video_source}",
"Pet Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/VehicleDetect")
async def async_parse_vehicle_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MyRuleDetector/VehicleDetect
"""
video_source = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "Source":
video_source = _normalize_video_source(source.Value)
return Event(
f"{uid}_{topic}_{video_source}",
"Vehicle Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
_TAPO_EVENT_TEMPLATES: dict[str, Event] = {
"IsVehicle": Event(
uid="",
name="Vehicle Detection",
platform="binary_sensor",
device_class="motion",
),
"IsPeople": Event(
uid="", name="Person Detection", platform="binary_sensor", device_class="motion"
),
"IsPet": Event(
uid="", name="Pet Detection", platform="binary_sensor", device_class="motion"
),
"IsLineCross": Event(
uid="",
name="Line Detector Crossed",
platform="binary_sensor",
device_class="motion",
),
"IsTamper": Event(
uid="", name="Tamper Detection", platform="binary_sensor", device_class="tamper"
),
"IsIntrusion": Event(
uid="",
name="Intrusion Detection",
platform="binary_sensor",
device_class="safety",
),
}
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Intrusion")
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/LineCross")
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/People")
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/Tamper")
@PARSERS.register("tns1:RuleEngine/CellMotionDetector/TpSmartEvent")
@PARSERS.register("tns1:RuleEngine/PeopleDetector/People")
@PARSERS.register("tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent")
async def async_parse_tplink_detector(uid: str, msg) -> Event | None:
"""Handle parsing tplink smart event messages.
Topic: tns1:RuleEngine/CellMotionDetector/Intrusion
Topic: tns1:RuleEngine/CellMotionDetector/LineCross
Topic: tns1:RuleEngine/CellMotionDetector/People
Topic: tns1:RuleEngine/CellMotionDetector/Tamper
Topic: tns1:RuleEngine/CellMotionDetector/TpSmartEvent
Topic: tns1:RuleEngine/PeopleDetector/People
Topic: tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
for item in payload.Data.SimpleItem:
event_template = _TAPO_EVENT_TEMPLATES.get(item.Name)
if event_template is None:
continue
return dataclasses.replace(
event_template,
uid=f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
value=item.Value == "true",
)
return None
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/PeopleDetect")
async def async_parse_person_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MyRuleDetector/PeopleDetect
"""
video_source = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "Source":
video_source = _normalize_video_source(source.Value)
return Event(
f"{uid}_{topic}_{video_source}",
"Person Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/FaceDetect")
async def async_parse_face_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MyRuleDetector/FaceDetect
"""
video_source = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "Source":
video_source = _normalize_video_source(source.Value)
return Event(
f"{uid}_{topic}_{video_source}",
"Face Detection",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Visitor")
async def async_parse_visitor_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MyRuleDetector/Visitor
"""
video_source = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "Source":
video_source = _normalize_video_source(source.Value)
return Event(
f"{uid}_{topic}_{video_source}",
"Visitor Detection",
"binary_sensor",
"occupancy",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Package")
async def async_parse_package_detector(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/MyRuleDetector/Package
"""
video_source = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "Source":
video_source = _normalize_video_source(source.Value)
return Event(
f"{uid}_{topic}_{video_source}",
"Package Detection",
"binary_sensor",
"occupancy",
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:Device/Trigger/DigitalInput")
async def async_parse_digital_input(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Device/Trigger/DigitalInput
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Digital Input",
"binary_sensor",
None,
None,
payload.Data.SimpleItem[0].Value == "true",
)
@PARSERS.register("tns1:Device/Trigger/Relay")
async def async_parse_relay(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Device/Trigger/Relay
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Relay Triggered",
"binary_sensor",
None,
None,
payload.Data.SimpleItem[0].Value == "active",
)
@PARSERS.register("tns1:Device/HardwareFailure/StorageFailure")
async def async_parse_storage_failure(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Device/HardwareFailure/StorageFailure
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Storage Failure",
"binary_sensor",
"problem",
None,
payload.Data.SimpleItem[0].Value == "true",
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:Monitoring/ProcessorUsage")
async def async_parse_processor_usage(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Monitoring/ProcessorUsage
"""
topic, payload = extract_message(msg)
usage = float(payload.Data.SimpleItem[0].Value)
if usage <= 1:
usage *= 100
return Event(
f"{uid}_{topic}",
"Processor Usage",
"sensor",
None,
"percent",
int(usage),
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:Monitoring/OperatingTime/LastReboot")
async def async_parse_last_reboot(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Monitoring/OperatingTime/LastReboot
"""
topic, payload = extract_message(msg)
date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
return Event(
f"{uid}_{topic}",
"Last Reboot",
"sensor",
"timestamp",
None,
date_time,
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:Monitoring/OperatingTime/LastReset")
async def async_parse_last_reset(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Monitoring/OperatingTime/LastReset
"""
topic, payload = extract_message(msg)
date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
return Event(
f"{uid}_{topic}",
"Last Reset",
"sensor",
"timestamp",
None,
date_time,
EntityCategory.DIAGNOSTIC,
entity_enabled=False,
)
@PARSERS.register("tns1:Monitoring/Backup/Last")
async def async_parse_backup_last(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Monitoring/Backup/Last
"""
topic, payload = extract_message(msg)
date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
return Event(
f"{uid}_{topic}",
"Last Backup",
"sensor",
"timestamp",
None,
date_time,
EntityCategory.DIAGNOSTIC,
entity_enabled=False,
)
@PARSERS.register("tns1:Monitoring/OperatingTime/LastClockSynchronization")
async def async_parse_last_clock_sync(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:Monitoring/OperatingTime/LastClockSynchronization
"""
topic, payload = extract_message(msg)
date_time = local_datetime_or_none(payload.Data.SimpleItem[0].Value)
return Event(
f"{uid}_{topic}",
"Last Clock Synchronization",
"sensor",
"timestamp",
None,
date_time,
EntityCategory.DIAGNOSTIC,
entity_enabled=False,
)
@PARSERS.register("tns1:RecordingConfig/JobState")
async def async_parse_jobstate(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RecordingConfig/JobState
"""
topic, payload = extract_message(msg)
source = payload.Source.SimpleItem[0].Value
return Event(
f"{uid}_{topic}_{source}",
"Recording Job State",
"binary_sensor",
None,
None,
payload.Data.SimpleItem[0].Value == "Active",
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:RuleEngine/LineDetector/Crossed")
async def async_parse_linedetector_crossed(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/LineDetector/Crossed
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = source.Value
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
"Line Detector Crossed",
"sensor",
None,
None,
payload.Data.SimpleItem[0].Value,
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:RuleEngine/CountAggregation/Counter")
async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:RuleEngine/CountAggregation/Counter
"""
video_source = ""
video_analytics = ""
rule = ""
topic, payload = extract_message(msg)
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
if source.Name == "VideoAnalyticsConfigurationToken":
video_analytics = source.Value
if source.Name == "Rule":
rule = source.Value
return Event(
f"{uid}_{topic}_{video_source}_{video_analytics}_{rule}",
"Count Aggregation Counter",
"sensor",
None,
None,
payload.Data.SimpleItem[0].Value,
EntityCategory.DIAGNOSTIC,
)
@PARSERS.register("tns1:UserAlarm/IVA/HumanShapeDetect")
async def async_parse_human_shape_detect(uid: str, msg) -> Event | None:
"""Handle parsing event message.
Topic: tns1:UserAlarm/IVA/HumanShapeDetect
"""
topic, payload = extract_message(msg)
video_source = ""
for source in payload.Source.SimpleItem:
if source.Name == "VideoSourceConfigurationToken":
video_source = _normalize_video_source(source.Value)
break
return Event(
f"{uid}_{topic}_{video_source}",
"Human Shape Detect",
"binary_sensor",
"motion",
None,
payload.Data.SimpleItem[0].Value == "true",
)

View File

@@ -212,11 +212,6 @@ BLUETOOTH: Final[list[dict[str, bool | str | int | list[int]]]] = [
"domain": "govee_ble",
"local_name": "GVH5110*",
},
{
"connectable": False,
"domain": "govee_ble",
"local_name": "GV5140*",
},
{
"connectable": False,
"domain": "govee_ble",

View File

@@ -3,6 +3,7 @@
from __future__ import annotations
import asyncio
from contextlib import suppress
import importlib
import logging
import sys
@@ -52,10 +53,11 @@ async def async_import_module(hass: HomeAssistant, name: str) -> ModuleType:
if isinstance(ex, ModuleNotFoundError):
failure_cache[name] = True
import_future.set_exception(ex)
# Set the exception retrieved flag on the future since
# it will never be retrieved unless there
# are concurrent calls
import_future.exception()
with suppress(BaseException):
# Set the exception retrieved flag on the future since
# it will never be retrieved unless there
# are concurrent calls
import_future.result()
raise
finally:
del import_futures[name]

3
requirements_all.txt generated
View File

@@ -1681,9 +1681,6 @@ onedrive-personal-sdk==0.1.4
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
# homeassistant.components.onvif
onvif_parsers==1.2.2
# homeassistant.components.opengarage
open-garage==0.2.0

View File

@@ -1467,9 +1467,6 @@ onedrive-personal-sdk==0.1.4
# homeassistant.components.onvif
onvif-zeep-async==4.0.4
# homeassistant.components.onvif
onvif_parsers==1.2.2
# homeassistant.components.opengarage
open-garage==0.2.0

View File

@@ -1,173 +0,0 @@
"""Test ESPHome infrared platform."""
from aioesphomeapi import (
APIClient,
APIConnectionError,
InfraredCapability,
InfraredInfo,
)
from infrared_protocols import NECCommand
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 MockESPHomeDevice, MockESPHomeDeviceType
ENTITY_ID = "infrared.test_ir"
async def _mock_ir_device(
mock_esphome_device: MockESPHomeDeviceType,
mock_client: APIClient,
capabilities: InfraredCapability = InfraredCapability.TRANSMITTER,
) -> MockESPHomeDevice:
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),
(InfraredCapability(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 = NECCommand(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
assert call_args[1]["device_id"] == 0
# 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 APIConnectionError raises HomeAssistantError."""
await _mock_ir_device(mock_esphome_device, mock_client)
mock_client.infrared_rf_transmit_raw_timings.side_effect = APIConnectionError(
"Connection lost"
)
command = NECCommand(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_communicating_with_device"
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

View File

@@ -84,6 +84,7 @@ GVH5106_SERVICE_INFO = BluetoothServiceInfo(
source="local",
)
GV5125_BUTTON_0_SERVICE_INFO = BluetoothServiceInfo(
name="GV51255367",
address="C1:37:37:32:0F:45",
@@ -162,16 +163,6 @@ GV5123_CLOSED_SERVICE_INFO = BluetoothServiceInfo(
source="24:4C:AB:03:E6:B8",
)
# Encodes: temperature=21.6°C, humidity=67.8%, CO2=531 ppm, no error
GV5140_SERVICE_INFO = BluetoothServiceInfo(
name="GV5140EEFF",
address="AA:BB:CC:DD:EE:FF",
rssi=-63,
manufacturer_data={1: b"\x01\x01\x03\x4e\x66\x02\x13\x00"},
service_uuids=["0000ec88-0000-1000-8000-00805f9b34fb"],
service_data={},
source="local",
)
GVH5124_SERVICE_INFO = BluetoothServiceInfo(
name="GV51242F68",

View File

@@ -17,7 +17,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.util import dt as dt_util
from . import (
GV5140_SERVICE_INFO,
GVH5075_SERVICE_INFO,
GVH5106_SERVICE_INFO,
GVH5178_PRIMARY_SERVICE_INFO,
@@ -164,33 +163,6 @@ async def test_gvh5178_multi_sensor(hass: HomeAssistant) -> None:
assert primary_temp_sensor.state == STATE_UNAVAILABLE
async def test_gv5140(hass: HomeAssistant) -> None:
"""Test setting up creates the sensors for a device with CO2."""
entry = MockConfigEntry(
domain=DOMAIN,
unique_id="AA:BB:CC:DD:EE:FF",
)
entry.add_to_hass(hass)
assert await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 0
inject_bluetooth_service_info(hass, GV5140_SERVICE_INFO)
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 3
co2_sensor = hass.states.get("sensor.5140eeff_carbon_dioxide")
co2_sensor_attributes = co2_sensor.attributes
assert co2_sensor.state == "531"
assert co2_sensor_attributes[ATTR_FRIENDLY_NAME] == "5140EEFF Carbon Dioxide"
assert co2_sensor_attributes[ATTR_UNIT_OF_MEASUREMENT] == "ppm"
assert co2_sensor_attributes[ATTR_STATE_CLASS] == "measurement"
assert await hass.config_entries.async_unload(entry.entry_id)
await hass.async_block_till_done()
async def test_gvh5106(hass: HomeAssistant) -> None:
"""Test setting up creates the sensors for a device with PM25."""
entry = MockConfigEntry(

View File

@@ -1,22 +1,16 @@
"""Tests for the ONVIF integration."""
from __future__ import annotations
from collections import defaultdict
from unittest.mock import AsyncMock, MagicMock, patch
from onvif.exceptions import ONVIFError
from onvif_parsers.model import EventEntity
from zeep.exceptions import Fault
from homeassistant import config_entries
from homeassistant.components.onvif import config_flow
from homeassistant.components.onvif.const import CONF_SNAPSHOT_AUTH
from homeassistant.components.onvif.event import EventManager
from homeassistant.components.onvif.models import (
Capabilities,
DeviceInfo,
Event,
Profile,
PullPointManagerState,
Resolution,
@@ -129,7 +123,7 @@ def setup_mock_onvif_camera(
mock_onvif_camera.side_effect = mock_constructor
def setup_mock_device(mock_device, capabilities=None, profiles=None, events=None):
def setup_mock_device(mock_device, capabilities=None, profiles=None):
"""Prepare mock ONVIFDevice."""
mock_device.async_setup = AsyncMock(return_value=True)
mock_device.port = 80
@@ -155,11 +149,7 @@ def setup_mock_device(mock_device, capabilities=None, profiles=None, events=None
mock_device.events = MagicMock(
webhook_manager=MagicMock(state=WebHookManagerState.STARTED),
pullpoint_manager=MagicMock(state=PullPointManagerState.PAUSED),
async_stop=AsyncMock(),
)
mock_device.device.close = AsyncMock()
if events:
_setup_mock_events(mock_device.events, events)
def mock_constructor(
hass: HomeAssistant, config: config_entries.ConfigEntry
@@ -170,23 +160,6 @@ def setup_mock_device(mock_device, capabilities=None, profiles=None, events=None
mock_device.side_effect = mock_constructor
def _setup_mock_events(mock_events: MagicMock, events: list[Event]) -> None:
"""Configure mock events to return proper Event objects."""
events_by_platform: dict[str, list[Event]] = defaultdict(list)
events_by_uid: dict[str, Event] = {}
uids_by_platform: dict[str, set[str]] = defaultdict(set)
for event in events:
events_by_platform[event.platform].append(event)
events_by_uid[event.uid] = event
uids_by_platform[event.platform].add(event.uid)
mock_events.get_platform.side_effect = lambda p: list(events_by_platform.get(p, []))
mock_events.get_uid.side_effect = events_by_uid.get
mock_events.get_uids_by_platform.side_effect = lambda p: set(
uids_by_platform.get(p, set())
)
async def setup_onvif_integration(
hass: HomeAssistant,
config=None,
@@ -195,8 +168,6 @@ async def setup_onvif_integration(
entry_id="1",
source=config_entries.SOURCE_USER,
capabilities=None,
events=None,
raw_events: list[tuple[str, EventEntity]] | None = None,
) -> tuple[MockConfigEntry, MagicMock, MagicMock]:
"""Create an ONVIF config entry."""
if not config:
@@ -231,35 +202,8 @@ async def setup_onvif_integration(
setup_mock_onvif_camera(mock_onvif_camera, two_profiles=True)
# no discovery
mock_discovery.return_value = []
setup_mock_device(mock_device, capabilities=capabilities, events=events)
setup_mock_device(mock_device, capabilities=capabilities)
mock_device.device = mock_onvif_camera
if raw_events:
# Process raw library events through a real EventManager
# to test the full parsing pipeline including conversions
event_manager = EventManager(hass, mock_onvif_camera, config_entry, NAME)
mock_messages = []
event_by_topic: dict[str, EventEntity] = {}
for topic, raw_event in raw_events:
mock_msg = MagicMock()
mock_msg.Topic._value_1 = topic
mock_messages.append(mock_msg)
event_by_topic[topic] = raw_event
async def mock_parse(topic, unique_id, msg):
return event_by_topic.get(topic)
with patch(
"homeassistant.components.onvif.event.onvif_parsers"
) as mock_parsers:
mock_parsers.parse = mock_parse
mock_parsers.errors.UnknownTopicError = type(
"UnknownTopicError", (Exception,), {}
)
await event_manager.async_parse_messages(mock_messages)
mock_device.events = event_manager
await hass.config_entries.async_setup(config_entry.entry_id)
await hass.async_block_till_done()
return config_entry, mock_onvif_camera, mock_device

View File

@@ -1,137 +0,0 @@
"""Test ONVIF event handling end-to-end."""
from onvif_parsers.model import EventEntity
from homeassistant.components.onvif.models import Capabilities, Event
from homeassistant.const import ATTR_DEVICE_CLASS, STATE_OFF, STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
from . import MAC, setup_onvif_integration
MOTION_ALARM_UID = f"{MAC}_tns1:VideoSource/MotionAlarm_VideoSourceToken"
IMAGE_TOO_BLURRY_UID = (
f"{MAC}_tns1:VideoSource/ImageTooBlurry/AnalyticsService_VideoSourceToken"
)
LAST_RESET_UID = f"{MAC}_tns1:Monitoring/LastReset_0"
async def test_motion_alarm_event(hass: HomeAssistant) -> None:
"""Test that a motion alarm event creates a binary sensor."""
await setup_onvif_integration(
hass,
capabilities=Capabilities(events=True, imaging=True, ptz=True),
events=[
Event(
uid=MOTION_ALARM_UID,
name="Motion Alarm",
platform="binary_sensor",
device_class="motion",
value=True,
),
],
)
state = hass.states.get("binary_sensor.testcamera_motion_alarm")
assert state is not None
assert state.state == STATE_ON
assert state.attributes[ATTR_DEVICE_CLASS] == "motion"
async def test_motion_alarm_event_off(hass: HomeAssistant) -> None:
"""Test that a motion alarm event with false value is off."""
await setup_onvif_integration(
hass,
capabilities=Capabilities(events=True, imaging=True, ptz=True),
events=[
Event(
uid=MOTION_ALARM_UID,
name="Motion Alarm",
platform="binary_sensor",
device_class="motion",
value=False,
),
],
)
state = hass.states.get("binary_sensor.testcamera_motion_alarm")
assert state is not None
assert state.state == STATE_OFF
async def test_diagnostic_event_entity_category(
hass: HomeAssistant, entity_registry: er.EntityRegistry
) -> None:
"""Test that a diagnostic event gets the correct entity category."""
await setup_onvif_integration(
hass,
capabilities=Capabilities(events=True, imaging=True, ptz=True),
events=[
Event(
uid=IMAGE_TOO_BLURRY_UID,
name="Image Too Blurry",
platform="binary_sensor",
device_class="problem",
value=True,
entity_category=EntityCategory.DIAGNOSTIC,
),
],
)
state = hass.states.get("binary_sensor.testcamera_image_too_blurry")
assert state is not None
assert state.state == STATE_ON
entry = entity_registry.async_get("binary_sensor.testcamera_image_too_blurry")
assert entry is not None
assert entry.entity_category is EntityCategory.DIAGNOSTIC
async def test_timestamp_event_conversion(hass: HomeAssistant) -> None:
"""Test that timestamp sensor events get string values converted to datetime."""
await setup_onvif_integration(
hass,
capabilities=Capabilities(events=True, imaging=True, ptz=True),
raw_events=[
(
"tns1:Monitoring/LastReset",
EventEntity(
uid=LAST_RESET_UID,
name="Last Reset",
platform="sensor",
device_class="timestamp",
value="2023-10-01T12:00:00Z",
),
),
],
)
state = hass.states.get("sensor.testcamera_last_reset")
assert state is not None
# Verify the string was converted to a datetime (raw string would end
# with "Z", converted datetime rendered by SensorEntity has "+00:00")
assert state.state == "2023-10-01T12:00:00+00:00"
async def test_timestamp_event_invalid_value(hass: HomeAssistant) -> None:
"""Test that invalid timestamp values result in unknown state."""
await setup_onvif_integration(
hass,
capabilities=Capabilities(events=True, imaging=True, ptz=True),
raw_events=[
(
"tns1:Monitoring/LastReset",
EventEntity(
uid=LAST_RESET_UID,
name="Last Reset",
platform="sensor",
device_class="timestamp",
value="0000-00-00T00:00:00Z",
),
),
],
)
state = hass.states.get("sensor.testcamera_last_reset")
assert state is not None
assert state.state == "unknown"

View File

@@ -0,0 +1,881 @@
"""Test ONVIF parsers."""
import datetime
import os
import onvif
import onvif.settings
import pytest
from zeep import Client
from zeep.transports import Transport
from homeassistant.components.onvif import models, parsers
from homeassistant.core import HomeAssistant
TEST_UID = "test-unique-id"
async def get_event(notification_data: dict) -> models.Event:
"""Take in a zeep dict, run it through the parser, and return an Event.
When the parser encounters an unknown topic that it doesn't know how to parse,
it outputs a message 'No registered handler for event from ...' along with a
print out of the serialized xml message from zeep. If it tries to parse and
can't, it prints out 'Unable to parse event from ...' along with the same
serialized message. This method can take the output directly from these log
messages and run them through the parser, which makes it easy to add new unit
tests that verify the message can now be parsed.
"""
zeep_client = Client(
f"{os.path.dirname(onvif.__file__)}/wsdl/events.wsdl",
wsse=None,
transport=Transport(),
)
notif_msg_type = zeep_client.get_type("ns5:NotificationMessageHolderType")
assert notif_msg_type is not None
notif_msg = notif_msg_type(**notification_data)
assert notif_msg is not None
# The xsd:any type embedded inside the message doesn't parse, so parse it manually.
msg_elem = zeep_client.get_element("ns8:Message")
assert msg_elem is not None
msg_data = msg_elem(**notification_data["Message"]["_value_1"])
assert msg_data is not None
notif_msg.Message._value_1 = msg_data
parser = parsers.PARSERS.get(notif_msg.Topic._value_1)
assert parser is not None
return await parser(TEST_UID, notif_msg)
async def test_line_detector_crossed(hass: HomeAssistant) -> None:
"""Tests tns1:RuleEngine/LineDetector/Crossed."""
event = await get_event(
{
"SubscriptionReference": {
"Address": {"_value_1": None, "_attr_1": None},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Topic": {
"_value_1": "tns1:RuleEngine/LineDetector/Crossed",
"Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet",
"_attr_1": {},
},
"ProducerReference": {
"Address": {
"_value_1": "xx.xx.xx.xx/onvif/event/alarm",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Message": {
"_value_1": {
"Source": {
"SimpleItem": [
{
"Name": "VideoSourceConfigurationToken",
"Value": "video_source_config1",
},
{
"Name": "VideoAnalyticsConfigurationToken",
"Value": "analytics_video_source",
},
{"Name": "Rule", "Value": "MyLineDetectorRule"},
],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Key": None,
"Data": {
"SimpleItem": [{"Name": "ObjectId", "Value": "0"}],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Extension": None,
"UtcTime": datetime.datetime(2020, 5, 24, 7, 24, 47),
"PropertyOperation": "Initialized",
"_attr_1": {},
}
},
}
)
assert event is not None
assert event.name == "Line Detector Crossed"
assert event.platform == "sensor"
assert event.value == "0"
assert event.uid == (
f"{TEST_UID}_tns1:RuleEngine/LineDetector/"
"Crossed_video_source_config1_analytics_video_source_MyLineDetectorRule"
)
async def test_tapo_line_crossed(hass: HomeAssistant) -> None:
"""Tests tns1:RuleEngine/CellMotionDetector/LineCross."""
event = await get_event(
{
"SubscriptionReference": {
"Address": {
"_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Topic": {
"_value_1": "tns1:RuleEngine/CellMotionDetector/LineCross",
"Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet",
"_attr_1": {},
},
"ProducerReference": {
"Address": {
"_value_1": "http://CAMERA_LOCAL_IP:5656/event",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Message": {
"_value_1": {
"Source": {
"SimpleItem": [
{
"Name": "VideoSourceConfigurationToken",
"Value": "vsconf",
},
{
"Name": "VideoAnalyticsConfigurationToken",
"Value": "VideoAnalyticsToken",
},
{"Name": "Rule", "Value": "MyLineCrossDetectorRule"},
],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Key": None,
"Data": {
"SimpleItem": [{"Name": "IsLineCross", "Value": "true"}],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Extension": None,
"UtcTime": datetime.datetime(
2025, 1, 3, 21, 5, 14, tzinfo=datetime.UTC
),
"PropertyOperation": "Changed",
"_attr_1": {},
}
},
}
)
assert event is not None
assert event.name == "Line Detector Crossed"
assert event.platform == "binary_sensor"
assert event.device_class == "motion"
assert event.value
assert event.uid == (
f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/"
"LineCross_VideoSourceToken_VideoAnalyticsToken_MyLineCrossDetectorRule"
)
async def test_tapo_tpsmartevent_vehicle(hass: HomeAssistant) -> None:
"""Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - vehicle."""
event = await get_event(
{
"Message": {
"_value_1": {
"Data": {
"ElementItem": [],
"Extension": None,
"SimpleItem": [{"Name": "IsVehicle", "Value": "true"}],
"_attr_1": None,
},
"Extension": None,
"Key": None,
"PropertyOperation": "Changed",
"Source": {
"ElementItem": [],
"Extension": None,
"SimpleItem": [
{
"Name": "VideoSourceConfigurationToken",
"Value": "vsconf",
},
{
"Name": "VideoAnalyticsConfigurationToken",
"Value": "VideoAnalyticsToken",
},
{
"Name": "Rule",
"Value": "MyTPSmartEventDetectorRule",
},
],
"_attr_1": None,
},
"UtcTime": datetime.datetime(
2024, 11, 2, 0, 33, 11, tzinfo=datetime.UTC
),
"_attr_1": {},
}
},
"ProducerReference": {
"Address": {
"_attr_1": None,
"_value_1": "http://192.168.56.127:5656/event",
},
"Metadata": None,
"ReferenceParameters": None,
"_attr_1": None,
"_value_1": None,
},
"SubscriptionReference": {
"Address": {
"_attr_1": None,
"_value_1": "http://192.168.56.127:2020/event-0_2020",
},
"Metadata": None,
"ReferenceParameters": None,
"_attr_1": None,
"_value_1": None,
},
"Topic": {
"Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet",
"_attr_1": {},
"_value_1": "tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent",
},
}
)
assert event is not None
assert event.name == "Vehicle Detection"
assert event.platform == "binary_sensor"
assert event.device_class == "motion"
assert event.value
assert event.uid == (
f"{TEST_UID}_tns1:RuleEngine/TPSmartEventDetector/"
"TPSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule"
)
async def test_tapo_cellmotiondetector_vehicle(hass: HomeAssistant) -> None:
"""Tests tns1:RuleEngine/CellMotionDetector/TpSmartEvent - vehicle."""
event = await get_event(
{
"SubscriptionReference": {
"Address": {
"_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Topic": {
"_value_1": "tns1:RuleEngine/CellMotionDetector/TpSmartEvent",
"Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet",
"_attr_1": {},
},
"ProducerReference": {
"Address": {
"_value_1": "http://CAMERA_LOCAL_IP:5656/event",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Message": {
"_value_1": {
"Source": {
"SimpleItem": [
{
"Name": "VideoSourceConfigurationToken",
"Value": "vsconf",
},
{
"Name": "VideoAnalyticsConfigurationToken",
"Value": "VideoAnalyticsToken",
},
{"Name": "Rule", "Value": "MyTPSmartEventDetectorRule"},
],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Key": None,
"Data": {
"SimpleItem": [{"Name": "IsVehicle", "Value": "true"}],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Extension": None,
"UtcTime": datetime.datetime(
2025, 1, 5, 14, 2, 9, tzinfo=datetime.UTC
),
"PropertyOperation": "Changed",
"_attr_1": {},
}
},
}
)
assert event is not None
assert event.name == "Vehicle Detection"
assert event.platform == "binary_sensor"
assert event.device_class == "motion"
assert event.value
assert event.uid == (
f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/"
"TpSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule"
)
async def test_tapo_tpsmartevent_person(hass: HomeAssistant) -> None:
"""Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - person."""
event = await get_event(
{
"Message": {
"_value_1": {
"Data": {
"ElementItem": [],
"Extension": None,
"SimpleItem": [{"Name": "IsPeople", "Value": "true"}],
"_attr_1": None,
},
"Extension": None,
"Key": None,
"PropertyOperation": "Changed",
"Source": {
"ElementItem": [],
"Extension": None,
"SimpleItem": [
{
"Name": "VideoSourceConfigurationToken",
"Value": "vsconf",
},
{
"Name": "VideoAnalyticsConfigurationToken",
"Value": "VideoAnalyticsToken",
},
{"Name": "Rule", "Value": "MyPeopleDetectorRule"},
],
"_attr_1": None,
},
"UtcTime": datetime.datetime(
2024, 11, 3, 18, 40, 43, tzinfo=datetime.UTC
),
"_attr_1": {},
}
},
"ProducerReference": {
"Address": {
"_attr_1": None,
"_value_1": "http://192.168.56.127:5656/event",
},
"Metadata": None,
"ReferenceParameters": None,
"_attr_1": None,
"_value_1": None,
},
"SubscriptionReference": {
"Address": {
"_attr_1": None,
"_value_1": "http://192.168.56.127:2020/event-0_2020",
},
"Metadata": None,
"ReferenceParameters": None,
"_attr_1": None,
"_value_1": None,
},
"Topic": {
"Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet",
"_attr_1": {},
"_value_1": "tns1:RuleEngine/PeopleDetector/People",
},
}
)
assert event is not None
assert event.name == "Person Detection"
assert event.platform == "binary_sensor"
assert event.device_class == "motion"
assert event.value
assert event.uid == (
f"{TEST_UID}_tns1:RuleEngine/PeopleDetector/"
"People_VideoSourceToken_VideoAnalyticsToken_MyPeopleDetectorRule"
)
async def test_tapo_tpsmartevent_pet(hass: HomeAssistant) -> None:
"""Tests tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent - pet."""
event = await get_event(
{
"SubscriptionReference": {
"Address": {
"_value_1": "http://192.168.56.63:2020/event-0_2020",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Topic": {
"_value_1": "tns1:RuleEngine/TPSmartEventDetector/TPSmartEvent",
"Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet",
"_attr_1": {},
},
"ProducerReference": {
"Address": {
"_value_1": "http://192.168.56.63:5656/event",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Message": {
"_value_1": {
"Source": {
"SimpleItem": [
{
"Name": "VideoSourceConfigurationToken",
"Value": "vsconf",
},
{
"Name": "VideoAnalyticsConfigurationToken",
"Value": "VideoAnalyticsToken",
},
{"Name": "Rule", "Value": "MyTPSmartEventDetectorRule"},
],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Key": None,
"Data": {
"SimpleItem": [{"Name": "IsPet", "Value": "true"}],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Extension": None,
"UtcTime": datetime.datetime(
2025, 1, 22, 13, 24, 57, tzinfo=datetime.UTC
),
"PropertyOperation": "Changed",
"_attr_1": {},
}
},
}
)
assert event is not None
assert event.name == "Pet Detection"
assert event.platform == "binary_sensor"
assert event.device_class == "motion"
assert event.value
assert event.uid == (
f"{TEST_UID}_tns1:RuleEngine/TPSmartEventDetector/"
"TPSmartEvent_VideoSourceToken_VideoAnalyticsToken_MyTPSmartEventDetectorRule"
)
async def test_tapo_cellmotiondetector_person(hass: HomeAssistant) -> None:
"""Tests tns1:RuleEngine/CellMotionDetector/People - person."""
event = await get_event(
{
"SubscriptionReference": {
"Address": {
"_value_1": "http://192.168.56.63:2020/event-0_2020",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Topic": {
"_value_1": "tns1:RuleEngine/CellMotionDetector/People",
"Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet",
"_attr_1": {},
},
"ProducerReference": {
"Address": {
"_value_1": "http://192.168.56.63:5656/event",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Message": {
"_value_1": {
"Source": {
"SimpleItem": [
{
"Name": "VideoSourceConfigurationToken",
"Value": "vsconf",
},
{
"Name": "VideoAnalyticsConfigurationToken",
"Value": "VideoAnalyticsToken",
},
{"Name": "Rule", "Value": "MyPeopleDetectorRule"},
],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Key": None,
"Data": {
"SimpleItem": [{"Name": "IsPeople", "Value": "true"}],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Extension": None,
"UtcTime": datetime.datetime(
2025, 1, 3, 20, 9, 22, tzinfo=datetime.UTC
),
"PropertyOperation": "Changed",
"_attr_1": {},
}
},
}
)
assert event is not None
assert event.name == "Person Detection"
assert event.platform == "binary_sensor"
assert event.device_class == "motion"
assert event.value
assert event.uid == (
f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/"
"People_VideoSourceToken_VideoAnalyticsToken_MyPeopleDetectorRule"
)
async def test_tapo_tamper(hass: HomeAssistant) -> None:
"""Tests tns1:RuleEngine/CellMotionDetector/Tamper - tamper."""
event = await get_event(
{
"SubscriptionReference": {
"Address": {
"_value_1": "http://CAMERA_LOCAL_IP:2020/event-0_2020",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Topic": {
"_value_1": "tns1:RuleEngine/CellMotionDetector/Tamper",
"Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet",
"_attr_1": {},
},
"ProducerReference": {
"Address": {
"_value_1": "http://CAMERA_LOCAL_IP:5656/event",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Message": {
"_value_1": {
"Source": {
"SimpleItem": [
{
"Name": "VideoSourceConfigurationToken",
"Value": "vsconf",
},
{
"Name": "VideoAnalyticsConfigurationToken",
"Value": "VideoAnalyticsToken",
},
{"Name": "Rule", "Value": "MyTamperDetectorRule"},
],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Key": None,
"Data": {
"SimpleItem": [{"Name": "IsTamper", "Value": "true"}],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Extension": None,
"UtcTime": datetime.datetime(
2025, 1, 5, 21, 1, 5, tzinfo=datetime.UTC
),
"PropertyOperation": "Changed",
"_attr_1": {},
}
},
}
)
assert event is not None
assert event.name == "Tamper Detection"
assert event.platform == "binary_sensor"
assert event.device_class == "tamper"
assert event.value
assert event.uid == (
f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/"
"Tamper_VideoSourceToken_VideoAnalyticsToken_MyTamperDetectorRule"
)
async def test_tapo_intrusion(hass: HomeAssistant) -> None:
"""Tests tns1:RuleEngine/CellMotionDetector/Intrusion - intrusion."""
event = await get_event(
{
"SubscriptionReference": {
"Address": {
"_value_1": "http://192.168.100.155:2020/event-0_2020",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Topic": {
"_value_1": "tns1:RuleEngine/CellMotionDetector/Intrusion",
"Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet",
"_attr_1": {},
},
"ProducerReference": {
"Address": {
"_value_1": "http://192.168.100.155:5656/event",
"_attr_1": None,
},
"ReferenceParameters": None,
"Metadata": None,
"_value_1": None,
"_attr_1": None,
},
"Message": {
"_value_1": {
"Source": {
"SimpleItem": [
{
"Name": "VideoSourceConfigurationToken",
"Value": "vsconf",
},
{
"Name": "VideoAnalyticsConfigurationToken",
"Value": "VideoAnalyticsToken",
},
{"Name": "Rule", "Value": "MyIntrusionDetectorRule"},
],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Key": None,
"Data": {
"SimpleItem": [{"Name": "IsIntrusion", "Value": "true"}],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Extension": None,
"UtcTime": datetime.datetime(
2025, 1, 11, 10, 40, 45, tzinfo=datetime.UTC
),
"PropertyOperation": "Changed",
"_attr_1": {},
}
},
}
)
assert event is not None
assert event.name == "Intrusion Detection"
assert event.platform == "binary_sensor"
assert event.device_class == "safety"
assert event.value
assert event.uid == (
f"{TEST_UID}_tns1:RuleEngine/CellMotionDetector/"
"Intrusion_VideoSourceToken_VideoAnalyticsToken_MyIntrusionDetectorRule"
)
async def test_tapo_missing_attributes(hass: HomeAssistant) -> None:
"""Tests async_parse_tplink_detector with missing fields."""
with pytest.raises(AttributeError, match="SimpleItem"):
await get_event(
{
"Message": {
"_value_1": {
"Data": {
"ElementItem": [],
"Extension": None,
"SimpleItem": [{"Name": "IsPeople", "Value": "true"}],
"_attr_1": None,
},
}
},
"Topic": {
"_value_1": "tns1:RuleEngine/PeopleDetector/People",
},
}
)
async def test_tapo_unknown_type(hass: HomeAssistant) -> None:
"""Tests async_parse_tplink_detector with unknown event type."""
event = await get_event(
{
"Message": {
"_value_1": {
"Data": {
"ElementItem": [],
"Extension": None,
"SimpleItem": [{"Name": "IsNotPerson", "Value": "true"}],
"_attr_1": None,
},
"Source": {
"ElementItem": [],
"Extension": None,
"SimpleItem": [
{
"Name": "VideoSourceConfigurationToken",
"Value": "vsconf",
},
{
"Name": "VideoAnalyticsConfigurationToken",
"Value": "VideoAnalyticsToken",
},
{"Name": "Rule", "Value": "MyPeopleDetectorRule"},
],
},
}
},
"Topic": {
"_value_1": "tns1:RuleEngine/PeopleDetector/People",
},
}
)
assert event is None
async def test_reolink_package(hass: HomeAssistant) -> None:
"""Tests reolink package event."""
event = await get_event(
{
"SubscriptionReference": None,
"Topic": {
"_value_1": "tns1:RuleEngine/MyRuleDetector/Package",
"Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet",
"_attr_1": {},
},
"ProducerReference": None,
"Message": {
"_value_1": {
"Source": {
"SimpleItem": [{"Name": "Source", "Value": "000"}],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Key": None,
"Data": {
"SimpleItem": [{"Name": "State", "Value": "true"}],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Extension": None,
"UtcTime": datetime.datetime(
2025, 3, 12, 9, 54, 27, tzinfo=datetime.UTC
),
"PropertyOperation": "Initialized",
"_attr_1": {},
}
},
}
)
assert event is not None
assert event.name == "Package Detection"
assert event.platform == "binary_sensor"
assert event.device_class == "occupancy"
assert event.value
assert event.uid == (f"{TEST_UID}_tns1:RuleEngine/MyRuleDetector/Package_000")
async def test_hikvision_alarm(hass: HomeAssistant) -> None:
"""Tests hikvision camera alarm event."""
event = await get_event(
{
"SubscriptionReference": None,
"Topic": {
"_value_1": "tns1:Device/Trigger/tnshik:AlarmIn",
"Dialect": "http://www.onvif.org/ver10/tev/topicExpression/ConcreteSet",
"_attr_1": {},
},
"ProducerReference": None,
"Message": {
"_value_1": {
"Source": {
"SimpleItem": [{"Name": "AlarmInToken", "Value": "AlarmIn_1"}],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Key": None,
"Data": {
"SimpleItem": [{"Name": "State", "Value": "true"}],
"ElementItem": [],
"Extension": None,
"_attr_1": None,
},
"Extension": None,
"UtcTime": datetime.datetime(
2025, 3, 13, 22, 57, 26, tzinfo=datetime.UTC
),
"PropertyOperation": "Initialized",
"_attr_1": {},
}
},
}
)
assert event is not None
assert event.name == "Motion Alarm"
assert event.platform == "binary_sensor"
assert event.device_class == "motion"
assert event.value
assert event.uid == (f"{TEST_UID}_tns1:Device/Trigger/tnshik:AlarmIn_AlarmIn_1")