Skip to content

Add event listener to smartcam #1388

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 8 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions kasa/cli/listen.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
"""Module for cli listen commands."""

import asyncio
from contextlib import suppress
from typing import cast

import asyncclick as click

from kasa import (
Credentials,
Device,
)
from kasa.eventtype import EventType

from .common import echo, error, pass_dev_or_child


async def wait_on_keyboard_interrupt(msg: str):
"""Non loop blocking get input."""
echo(msg + ", press Ctrl-C to cancel\n")

Check warning on line 20 in kasa/cli/listen.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/listen.py#L20

Added line #L20 was not covered by tests

with suppress(asyncio.CancelledError):
await asyncio.Event().wait()

Check warning on line 23 in kasa/cli/listen.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/listen.py#L22-L23

Added lines #L22 - L23 were not covered by tests


@click.command()
@click.option(
"--cam-username",
required=True,
envvar="KASA_CAMERA_USERNAME",
help="Camera account username address to authenticate to device.",
)
@click.option(
"--cam-password",
required=True,
envvar="KASA_CAMERA_PASSWORD",
help="Camera account password to use to authenticate to device.",
)
@click.option(
"--listen-port",
default=None,
required=False,
envvar="KASA_LISTEN_PORT",
help="Port to listen on for onvif notifications.",
)
@click.option(
"--listen-ip",
default=None,
required=False,
envvar="KASA_LISTEN_IP",
help="Ip address to listen on for onvif notifications.",
)
@click.option(
"-et",
"--event-types",
default=None,
required=False,
multiple=True,
type=click.Choice([et for et in EventType], case_sensitive=False),
help="Event types to listen to.",
)
@pass_dev_or_child
async def listen(
dev: Device,
cam_username: str,
cam_password: str,
listen_port: int | None,
listen_ip: str | None,
event_types: list[EventType] | None,
) -> None:
"""Listen for events like motion, triggers or alarms."""
try:
import onvif # type: ignore[import-untyped] # noqa: F401
except ImportError:
error("python-kasa must be installed with onvif extra for listen.")

Check warning on line 75 in kasa/cli/listen.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/listen.py#L72-L75

Added lines #L72 - L75 were not covered by tests

from kasa.smartcam.modules.onviflisten import OnvifListen

Check warning on line 77 in kasa/cli/listen.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/listen.py#L77

Added line #L77 was not covered by tests

listen: OnvifListen = cast(OnvifListen, dev.modules.get(OnvifListen._module_name()))

Check warning on line 79 in kasa/cli/listen.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/listen.py#L79

Added line #L79 was not covered by tests
if not listen:
error(f"Device {dev.host} does not support listening for events.")

Check warning on line 81 in kasa/cli/listen.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/listen.py#L81

Added line #L81 was not covered by tests

def on_event(event: EventType) -> None:
echo(f"Device {dev.host} received event {event}")

Check warning on line 84 in kasa/cli/listen.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/listen.py#L83-L84

Added lines #L83 - L84 were not covered by tests

creds = Credentials(cam_username, cam_password)
await listen.listen(

Check warning on line 87 in kasa/cli/listen.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/listen.py#L86-L87

Added lines #L86 - L87 were not covered by tests
on_event,
creds,
listen_ip=listen_ip,
listen_port=listen_port,
event_types=event_types,
)

msg = f"Listening for events on {listen.listening_address}"

Check warning on line 95 in kasa/cli/listen.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/listen.py#L95

Added line #L95 was not covered by tests

await wait_on_keyboard_interrupt(msg)

Check warning on line 97 in kasa/cli/listen.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/listen.py#L97

Added line #L97 was not covered by tests

echo("\nStopping listener")
await listen.stop()

Check warning on line 100 in kasa/cli/listen.py

View check run for this annotation

Codecov / codecov/patch

kasa/cli/listen.py#L99-L100

Added lines #L99 - L100 were not covered by tests
1 change: 1 addition & 0 deletions kasa/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@ def _legacy_type_to_class(_type: str) -> Any:
"device": None,
"feature": None,
"light": None,
"listen": None,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How about a new sub group "camera", which would contain this?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above I think we should keep this generic so we can add other listen modules like for trigger logs and motion detection sensors and have them all exposed with the one cli command. Users can provide the EventType options if they want to limit the events.

"wifi": None,
"time": None,
"schedule": None,
Expand Down
12 changes: 12 additions & 0 deletions kasa/eventtype.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""Module for listen event types."""
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We probably want to keep this inside smartcam for now, as the types are very onvif-specific(?)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's better to have this at the top level so we can add other listen modules like for trigger logs and motion detection sensors.


from enum import StrEnum, auto


class EventType(StrEnum):
"""Listen event types."""

MOTION_DETECTED = auto()
PERSON_DETECTED = auto()
TAMPER_DETECTED = auto()
BABY_CRY_DETECTED = auto()
3 changes: 3 additions & 0 deletions kasa/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,9 @@ class Module(ABC):
# SMARTCAM only modules
Camera: Final[ModuleName[smartcam.Camera]] = ModuleName("Camera")
LensMask: Final[ModuleName[smartcam.LensMask]] = ModuleName("LensMask")
MotionDetection: Final[ModuleName[smartcam.MotionDetection]] = ModuleName(
"MotionDetection"
)

def __init__(self, device: Device, module: str) -> None:
self._device = device
Expand Down
182 changes: 182 additions & 0 deletions kasa/smartcam/modules/onviflisten.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
"""Implementation of motion detection module."""

from __future__ import annotations

import asyncio
import logging
import os
import socket
import uuid
from collections.abc import Callable, Iterable
from datetime import timedelta

import onvif # type: ignore[import-untyped]
from aiohttp import web
from onvif.managers import NotificationManager # type: ignore[import-untyped]

from ...credentials import Credentials
from ...eventtype import EventType
from ...exceptions import KasaException
from ..smartcammodule import SmartCamModule

_LOGGER = logging.getLogger(__name__)
logging.getLogger("aiohttp").setLevel(logging.WARNING)
logging.getLogger("httpx").setLevel(logging.WARNING)

DEFAULT_LISTEN_PORT = 28002


TOPIC_EVENT_TYPE = {
"tns1:RuleEngine/CellMotionDetector/Motion": EventType.MOTION_DETECTED,
"tns1:RuleEngine/CellMotionDetector/People": EventType.PERSON_DETECTED,
"tns1:RuleEngine/TamperDetector/Tamper": EventType.TAMPER_DETECTED,
}


class OnvifListen(SmartCamModule):
"""Implementation of lens mask module."""

manager: NotificationManager
callback: Callable[[EventType], None]
event_types: Iterable[EventType] | None
listening = False
site: web.TCPSite
runner: web.AppRunner
instance_id: str
path: str
_listening_address: str | None = None

@property
def listening_address(self) -> str | None:
"""Address the listener is receiving onvif notifications on.

Or None if not listening.
"""
return self._listening_address

Check warning on line 55 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L55

Added line #L55 was not covered by tests

async def _invoke_callback(self, event: EventType) -> None:
self.callback(event)

Check warning on line 58 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L58

Added line #L58 was not covered by tests

async def _handle_event(self, request: web.Request) -> web.Response:
content = await request.read()
result = self.manager.process(content)

Check warning on line 62 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L61-L62

Added lines #L61 - L62 were not covered by tests
for msg in result.NotificationMessage:
_LOGGER.debug(

Check warning on line 64 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L64

Added line #L64 was not covered by tests
"Received notification message for %s: %s",
self._device.host,
msg,
)
if (event := TOPIC_EVENT_TYPE.get(msg.Topic._value_1)) and (
(not self.event_types or event in self.event_types)
and (simple_items := msg.Message._value_1.Data.SimpleItem)
and simple_items[0].Value == "true"
):
asyncio.create_task(self._invoke_callback(event))
return web.Response()

Check warning on line 75 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L74-L75

Added lines #L74 - L75 were not covered by tests

async def listen(
self,
callback: Callable[[EventType], None],
camera_credentials: Credentials,
*,
event_types: Iterable[EventType] | None = None,
listen_ip: str | None = None,
listen_port: int | None = None,
) -> None:
"""Start listening for events."""
self.callback = callback
self.event_types = event_types
self.instance_id = str(uuid.uuid4())
self.path = f"/{self._device.host}/{self.instance_id}/"

Check warning on line 90 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L87-L90

Added lines #L87 - L90 were not covered by tests

if listen_port is None:
listen_port = DEFAULT_LISTEN_PORT

Check warning on line 93 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L93

Added line #L93 was not covered by tests

def subscription_lost() -> None:
_LOGGER.debug("Notification subscription lost for %s", self._device.host)
asyncio.create_task(self.stop())

Check warning on line 97 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L95-L97

Added lines #L95 - L97 were not covered by tests

wsdl = f"{os.path.dirname(onvif.__file__)}/wsdl/"

Check warning on line 99 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L99

Added line #L99 was not covered by tests

mycam = onvif.ONVIFCamera(

Check warning on line 101 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L101

Added line #L101 was not covered by tests
self._device.host,
2020,
camera_credentials.username,
camera_credentials.password,
wsdl,
)
await mycam.update_xaddrs()

Check warning on line 108 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L108

Added line #L108 was not covered by tests

host_port = await self._start_server(listen_ip, listen_port)

Check warning on line 110 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L110

Added line #L110 was not covered by tests

self.manager = await mycam.create_notification_manager(

Check warning on line 112 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L112

Added line #L112 was not covered by tests
address=host_port + self.path,
interval=timedelta(minutes=10),
subscription_lost_callback=subscription_lost,
)

self._listening_address = host_port
self.listening = True
_LOGGER.debug("Listener started for %s", self._device.host)

Check warning on line 120 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L118-L120

Added lines #L118 - L120 were not covered by tests

async def stop(self) -> None:
"""Stop the listener."""
if not self.listening:
_LOGGER.debug("Listener for %s already stopped", self._device.host)
return

Check warning on line 126 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L125-L126

Added lines #L125 - L126 were not covered by tests

_LOGGER.debug("Stopping listener for %s", self._device.host)
self.listening = False
self._listening_address = None
await self.site.stop()
await self.runner.shutdown()

Check warning on line 132 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L128-L132

Added lines #L128 - L132 were not covered by tests

async def _get_host_ip(self) -> str:
def get_ip() -> str:

Check warning on line 135 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L135

Added line #L135 was not covered by tests
# From https://stackoverflow.com/a/28950776
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.settimeout(0)
try:

Check warning on line 139 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L137-L139

Added lines #L137 - L139 were not covered by tests
# doesn't even have to be reachable
s.connect(("10.254.254.254", 1))
ip = s.getsockname()[0]

Check warning on line 142 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L141-L142

Added lines #L141 - L142 were not covered by tests
finally:
s.close()
return ip

Check warning on line 145 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L144-L145

Added lines #L144 - L145 were not covered by tests

loop = asyncio.get_running_loop()
return await loop.run_in_executor(None, get_ip)

Check warning on line 148 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L147-L148

Added lines #L147 - L148 were not covered by tests

async def _start_server(self, listen_ip: str | None, listen_port: int) -> str:
app = web.Application()
app.add_routes([web.post(self.path, self._handle_event)])

Check warning on line 152 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L151-L152

Added lines #L151 - L152 were not covered by tests

self.runner = web.AppRunner(app)
await self.runner.setup()

Check warning on line 155 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L154-L155

Added lines #L154 - L155 were not covered by tests

if not listen_ip:
try:
listen_ip = await self._get_host_ip()
except Exception as ex:
raise KasaException(

Check warning on line 161 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L158-L161

Added lines #L158 - L161 were not covered by tests
"Unable to determine listen ip starting "
f"listener for {self._device.host}",
ex,
) from ex

self.site = web.TCPSite(self.runner, listen_ip, listen_port)
try:
await self.site.start()
except Exception:
_LOGGER.exception(

Check warning on line 171 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L167-L171

Added lines #L167 - L171 were not covered by tests
"Error trying to start listener for %s: ", self._device.host
)

_LOGGER.debug(

Check warning on line 175 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L175

Added line #L175 was not covered by tests
"Listen handler for %s running on %s:%s",
self._device.host,
listen_ip,
listen_port,
)

return f"http://{listen_ip}:{listen_port}"

Check warning on line 182 in kasa/smartcam/modules/onviflisten.py

View check run for this annotation

Codecov / codecov/patch

kasa/smartcam/modules/onviflisten.py#L182

Added line #L182 was not covered by tests
39 changes: 28 additions & 11 deletions kasa/smartcam/smartcamdevice.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from ..protocols.smartcamprotocol import _ChildCameraProtocolWrapper
from ..smart import SmartChildDevice, SmartDevice
from ..smart.smartdevice import ComponentsRaw
from .modules import ChildDevice, DeviceModule
from .modules import Camera, ChildDevice, DeviceModule, Time
from .smartcammodule import SmartCamModule

_LOGGER = logging.getLogger(__name__)
Expand Down Expand Up @@ -128,22 +128,39 @@ async def _initialize_children(self) -> None:

self._children = children

def _try_add_listen_module(self) -> None:
try:
import onvif # type: ignore[import-untyped] # noqa: F401
except ImportError:
return
from .modules.onviflisten import OnvifListen

self._modules[OnvifListen._module_name()] = OnvifListen(
self, OnvifListen._module_name()
)

async def _initialize_modules(self) -> None:
"""Initialize modules based on component negotiation response."""
for mod in SmartCamModule.REGISTERED_MODULES.values():
required_component = cast(str, mod.REQUIRED_COMPONENT)
if (
mod.REQUIRED_COMPONENT
and mod.REQUIRED_COMPONENT not in self._components
# Always add Camera module to cameras
and (
mod._module_name() != Module.Camera
or self._device_type is not DeviceType.Camera
required_component in self._components
or any(
self.sys_info.get(key) is not None
for key in mod.SYSINFO_LOOKUP_KEYS
)
or mod in self.FIRST_UPDATE_MODULES
or mod is Time
):
continue
module = mod(self, mod._module_name())
if await module._check_supported():
self._modules[module.name] = module
module = mod(self, mod._module_name())
if await module._check_supported():
self._modules[module.name] = module

if self._device_type is DeviceType.Camera:
self._modules[Camera._module_name()] = Camera(self, Camera._module_name())

if Module.MotionDetection in self._modules:
self._try_add_listen_module()

async def _initialize_features(self) -> None:
"""Initialize device features."""
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ docs = [
"sphinx>=7.4.7",
]
shell = ["ptpython", "rich"]
onvif = [
"onvif-zeep-async>=3.1.13",
]

[project.urls]
"Homepage" = "https://github.com/python-kasa/python-kasa"
Expand Down
Loading
Loading