From 6b411700b7501e5dfab889174dbc598a5f493ea8 Mon Sep 17 00:00:00 2001 From: Steven B <51370195+sdb9696@users.noreply.github.com> Date: Thu, 19 Dec 2024 11:29:03 +0000 Subject: [PATCH] Use specific listen port and add cli parameters --- kasa/cli/listen.py | 41 ++++++++++++++++- kasa/eventtype.py | 12 +++++ kasa/smartcam/modules/listen.py | 81 +++++++++++++++++++-------------- 3 files changed, 99 insertions(+), 35 deletions(-) create mode 100644 kasa/eventtype.py diff --git a/kasa/cli/listen.py b/kasa/cli/listen.py index 71bd8518..3dcf80f6 100644 --- a/kasa/cli/listen.py +++ b/kasa/cli/listen.py @@ -10,6 +10,7 @@ from kasa import ( Credentials, Device, ) +from kasa.eventtype import EventType from .common import echo, error, pass_dev_or_child @@ -35,8 +36,38 @@ async def aioinput(string: str): 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) -> None: +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: """Commands to control light settings.""" try: import onvif # type: ignore[import-untyped] # noqa: F401 @@ -53,7 +84,13 @@ async def listen(dev: Device, cam_username: str, cam_password: str) -> None: echo(f"Device {dev.host} received event {event}") creds = Credentials(cam_username, cam_password) - await listen.listen(on_event, creds) + await listen.listen( + on_event, + creds, + listen_ip=listen_ip, + listen_port=listen_port, + event_types=event_types, + ) await aioinput("Listening, press enter to cancel\n") diff --git a/kasa/eventtype.py b/kasa/eventtype.py new file mode 100644 index 00000000..2cfd8d1c --- /dev/null +++ b/kasa/eventtype.py @@ -0,0 +1,12 @@ +"""Module for listen event types.""" + +from enum import StrEnum, auto + + +class EventType(StrEnum): + """Listen event types.""" + + MOTION_DETECTED = auto() + PERSON_DETECTED = auto() + TAMPER_DETECTED = auto() + BABY_CRY_DETECTED = auto() diff --git a/kasa/smartcam/modules/listen.py b/kasa/smartcam/modules/listen.py index c3f67a0a..8400a10a 100644 --- a/kasa/smartcam/modules/listen.py +++ b/kasa/smartcam/modules/listen.py @@ -6,9 +6,10 @@ import asyncio import logging import os import socket -from collections.abc import Callable +import sys +import uuid +from collections.abc import Callable, Iterable from datetime import timedelta -from enum import StrEnum, auto from subprocess import check_output import onvif # type: ignore[import-untyped] @@ -16,18 +17,12 @@ from aiohttp import web from onvif.managers import NotificationManager # type: ignore[import-untyped] from ...credentials import Credentials +from ...eventtype import EventType from ..smartcammodule import SmartCamModule _LOGGER = logging.getLogger(__name__) - -class EventType(StrEnum): - """Listen event types.""" - - MOTION_DETECTED = auto() - PERSON_DETECTED = auto() - TAMPER_DETECTED = auto() - BABY_CRY_DETECTED = auto() +DEFAULT_LISTEN_PORT = 28002 TOPIC_EVENT_TYPE = { @@ -42,10 +37,11 @@ class Listen(SmartCamModule): manager: NotificationManager callback: Callable[[EventType], None] - topics: set[EventType] | None + event_types: Iterable[EventType] | None listening = False site: web.TCPSite runner: web.AppRunner + instance_id: str async def _invoke_callback(self, event: EventType) -> None: self.callback(event) @@ -55,7 +51,7 @@ class Listen(SmartCamModule): result = self.manager.process(content) for msg in result.NotificationMessage: if (event := TOPIC_EVENT_TYPE.get(msg.Topic._value_1)) and ( - not self.topics or event in self.topics + not self.event_types or event in self.event_types ): asyncio.create_task(self._invoke_callback(event)) return web.Response() @@ -65,15 +61,21 @@ class Listen(SmartCamModule): callback: Callable[[EventType], None], camera_credentials: Credentials, *, - topics: set[EventType] | None = None, + 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.topics = topics + self.event_types = event_types + self.instance_id = str(uuid.uuid4()) + + if listen_port is None: + listen_port = DEFAULT_LISTEN_PORT def subscription_lost() -> None: - pass + _LOGGER.debug("Notification subscription lost for %s", self._device.host) + asyncio.create_task(self.stop()) wsdl = f"{os.path.dirname(onvif.__file__)}/wsdl/" @@ -86,7 +88,7 @@ class Listen(SmartCamModule): ) await mycam.update_xaddrs() - address = await self._start_server(listen_ip) + address = await self._start_server(listen_ip, listen_port) self.manager = await mycam.create_notification_manager( address=address, @@ -100,40 +102,53 @@ class Listen(SmartCamModule): async def stop(self) -> None: """Stop the listener.""" if not self.listening: + _LOGGER.debug("Listener for %s already stopped", self._device.host) return + + _LOGGER.debug("Stopping listener for %s", self._device.host) self.listening = False await self.site.stop() await self.runner.shutdown() - async def _get_host_port(self, listen_ip: str | None) -> tuple[str, int]: - def _create_socket(listen_ip: str | None) -> tuple[str, int]: - if not listen_ip: + async def _get_host_ip(self) -> str: + def _get_host() -> str: + if sys.platform == "win32": + return socket.gethostbyname(socket.gethostname()) + else: res = check_output(["hostname", "-I"]) # noqa: S603, S607 listen_ip, _, _ = res.decode().partition(" ") - - sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) - sock.bind((listen_ip, 0)) - port = sock.getsockname()[1] - sock.close() - return listen_ip, port + return listen_ip loop = asyncio.get_running_loop() - return await loop.run_in_executor(None, _create_socket, listen_ip) + return await loop.run_in_executor(None, _get_host) - async def _start_server(self, listen_ip: str | None) -> str: + async def _start_server(self, listen_ip: str | None, listen_port: int) -> str: app = web.Application() - app.add_routes([web.post("/", self._handle_event)]) + app.add_routes( + [web.post(f"/{self._device.host}/{self.instance_id}/", self._handle_event)] + ) self.runner = web.AppRunner(app) await self.runner.setup() - listen_ip, port = await self._get_host_port(listen_ip) + if not listen_ip: + listen_ip = await self._get_host_ip() - self.site = web.TCPSite(self.runner, listen_ip, port) - await self.site.start() + self.site = web.TCPSite(self.runner, listen_ip, listen_port) + try: + await self.site.start() + except Exception: + _LOGGER.exception( + "Error trying to start listener for %s: ", self._device.host + ) _LOGGER.debug( - "Listen handler for %s running on %s:%s", self._device.host, listen_ip, port + "Listen handler for %s running on %s:%s", + self._device.host, + listen_ip, + listen_port, ) - return f"http://{listen_ip}:{port}" + return ( + f"http://{listen_ip}:{listen_port}/{self._device.host}/{self.instance_id}/" + )