From 1d5218567329174286d03770fdfe248a25576a28 Mon Sep 17 00:00:00 2001 From: Jeff Culverhouse Date: Wed, 29 Oct 2025 01:23:43 -0600 Subject: [PATCH] feature: move things around, cleaner code --- .github/workflows/deploy.yaml | 8 +- .gitignore | 3 + .trivyignore | 3 + CHANGELOG | 43 ---- pyproject.toml | 33 ++- src/amcrest2mqtt/app.py | 16 +- src/amcrest2mqtt/base.py | 74 +++--- src/amcrest2mqtt/core.py | 4 +- src/amcrest2mqtt/interface.py | 121 ++++++++++ src/amcrest2mqtt/mixins/amcrest.py | 320 +++++++++---------------- src/amcrest2mqtt/mixins/amcrest_api.py | 95 ++++---- src/amcrest2mqtt/mixins/events.py | 19 +- src/amcrest2mqtt/mixins/helpers.py | 129 +++++----- src/amcrest2mqtt/mixins/loops.py | 24 +- src/amcrest2mqtt/mixins/mqtt.py | 132 +++++----- src/amcrest2mqtt/mixins/publish.py | 255 ++++++++++++++++++++ src/amcrest2mqtt/mixins/refresh.py | 12 +- src/amcrest2mqtt/mixins/service.py | 185 -------------- src/amcrest2mqtt/mixins/topics.py | 134 +++-------- src/interface.py | 89 ------- uv.lock | 132 ++++------ 21 files changed, 862 insertions(+), 969 deletions(-) create mode 100644 .trivyignore delete mode 100644 CHANGELOG create mode 100644 src/amcrest2mqtt/interface.py create mode 100644 src/amcrest2mqtt/mixins/publish.py delete mode 100644 src/amcrest2mqtt/mixins/service.py delete mode 100644 src/interface.py diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index c43a4bb..69763fd 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -17,14 +17,14 @@ permissions: jobs: lint: - name: Lint (ruff/black) + name: Lint (ruff/black/mypy) runs-on: ubuntu-latest if: github.event_name != 'schedule' strategy: fail-fast: false max-parallel: 2 matrix: - python-version: ['3.9', '3.10', '3.11', '3.12'] + python-version: ['3.14'] steps: - uses: actions/checkout@v5 @@ -51,6 +51,10 @@ jobs: uv run black --version uv run black --check --color --diff . + - name: Mypy + run: | + uv run mypy . + release: name: Semantic Release runs-on: ubuntu-latest diff --git a/.gitignore b/.gitignore index f8ee84b..8e95f74 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,8 @@ # Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] +*.egg +*.egg-info/ # Distribution / packaging .Python @@ -14,6 +16,7 @@ node_modules/ .env .venv venv/ +.vscode/ # Local testing and notes config diff --git a/.trivyignore b/.trivyignore new file mode 100644 index 0000000..8188d8b --- /dev/null +++ b/.trivyignore @@ -0,0 +1,3 @@ +# libexpat is only pulled in via apt-get install git during build. +# It is not used in the final runtime image or by blink2mqtt at all. +CVE-2025-59375 diff --git a/CHANGELOG b/CHANGELOG deleted file mode 100644 index 78dbe8e..0000000 --- a/CHANGELOG +++ /dev/null @@ -1,43 +0,0 @@ -🚀 Version 3.0.0 — Major Refactor and Rearchitecture - -This release represents a complete modernization of amcrest2mqtt, bringing cleaner structure, better MQTT handling, and richer event data. - -Highlights - - - Modularized codebase under src/amcrest2mqtt/ - - - Brand-new MqttMixin with resilient reconnect, structured logs, and HA rediscovery support - - - HelpersMixin for device-state building and service-level control commands - - - AmcrestApiMixin replaces direct device calls with consolidated error handling - - - New sensor.event_time (timestamp) and sensor.event_text entities for human-readable event tracking - - - Added doorbell and human detection binary sensors for supported models (AD110/AD410) - - - Proper Home Assistant schema compliance: ISO 8601 timestamps, availability templates, and via-device linkage - - - Clean shutdown on SIGTERM/SIGINT and improved signal management - - - Full developer environment setup (black, ruff, pytest, coverage settings) - - - Utility script tools/clear_mqtt.sh for clearing retained topics - - - Docker image metadata updated with links, license, and version labels - -Breaking Changes - - - Moved all code to src/ package layout — update imports and mount paths if using bind mounts. - - - MQTT topics slightly restructured for consistency across entities. - - - Deprecated util.py; its helpers are now integrated into mixins. - -1.0.1 - - lookup camera hostnames to get ip at setup time, so we aren't doing - 100k lookups every day (in my 4 camera setup, for example) - -1.0.0 - - initial release - diff --git a/pyproject.toml b/pyproject.toml index b4c7b07..f80c25d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,15 +7,16 @@ name = "amcrest2mqtt" dynamic = ["version"] license = "MIT" license-files = ["LICENSE"] -requires-python = ">=3.13" +requires-python = ">=3.14" dependencies = [ "deepmerge==2.0", "paho-mqtt>=2.1.0", "pyyaml>=6.0.3", "requests>=2.32.5", - "json-logging-graystorm", "pathlib>=1.0.1", "amcrest>=1.9.9", + "json-logging-graystorm", + "mqtt-helper-graystorm", ] [project.scripts] @@ -141,11 +142,37 @@ extend-exclude = ''' ''' [tool.uv.sources] -json-logging-graystorm = { url = "https://github.com/weirdtangent/json_logging/archive/refs/tags/0.1.3.tar.gz" } +json-logging-graystorm = { git = "https://github.com/weirdtangent/json_logging.git", branch = "main" } +mqtt-helper-graystorm = { git = "https://github.com/weirdtangent/mqtt-helper.git", branch = "main" } [dependency-groups] dev = [ "black>=25.9.0", + "mypy>=1.18.2", "pytest>=8.4.2", "ruff>=0.14.1", + "types-pyyaml>=6.0.12.20250915", +] + +[tool.mypy] +python_version = "3.12" +ignore_missing_imports = true +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_untyped_defs = true +warn_unused_ignores = true +warn_return_any = true +warn_redundant_casts = true +warn_unreachable = true +strict_equality = true +allow_untyped_globals = false +allow_redefinition = false + +# Temporarily quiet noisy files or folders +[[tool.mypy.overrides]] +module = [ + "blinkpy.*", + "aiohttp.*", + "paho.*", ] +ignore_missing_imports = true \ No newline at end of file diff --git a/src/amcrest2mqtt/app.py b/src/amcrest2mqtt/app.py index fb3902b..763114e 100644 --- a/src/amcrest2mqtt/app.py +++ b/src/amcrest2mqtt/app.py @@ -9,6 +9,8 @@ import asyncio import argparse from json_logging import setup_logging, get_logger from .core import Amcrest2Mqtt +from .mixins.helpers import ConfigError +from .mixins.mqtt import MqttError def build_parser() -> argparse.ArgumentParser: @@ -22,12 +24,12 @@ def build_parser() -> argparse.ArgumentParser: return p -def main(argv=None): +def main() -> int: setup_logging() logger = get_logger(__name__) parser = build_parser() - args = parser.parse_args(argv) + args = parser.parse_args() try: with Amcrest2Mqtt(args=args) as amcrest2mqtt: @@ -40,11 +42,21 @@ def main(argv=None): loop.run_until_complete(amcrest2mqtt.main_loop()) else: raise + except ConfigError as e: + logger.error(f"Fatal config error was found: {e}") + return 1 + except MqttError as e: + logger.error(f"MQTT service problems: {e}") + return 1 except KeyboardInterrupt: logger.warning("Shutdown requested (Ctrl+C). Exiting gracefully...") + return 1 except asyncio.CancelledError: logger.warning("Main loop cancelled.") + return 1 except Exception as e: logger.error(f"unhandled exception: {e}", exc_info=True) + return 1 finally: logger.info("amcrest2mqtt stopped.") + return 0 diff --git a/src/amcrest2mqtt/base.py b/src/amcrest2mqtt/base.py index d32f537..673f6c6 100644 --- a/src/amcrest2mqtt/base.py +++ b/src/amcrest2mqtt/base.py @@ -1,33 +1,25 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Jeff Culverhouse import argparse +from datetime import datetime import logging +from mqtt_helper import MqttHelper from json_logging import get_logger +from paho.mqtt.client import Client +from types import TracebackType -from typing import TYPE_CHECKING +from typing import Any, cast, Self -if TYPE_CHECKING: - from amcrest2mqtt.interface import AmcrestServiceProtocol +from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt class Base: - if TYPE_CHECKING: - self: "AmcrestServiceProtocol" - - def __init__(self, *, args: argparse.Namespace | None = None, **kwargs): + def __init__(self: Amcrest2Mqtt, args: argparse.Namespace | None = None, **kwargs: Any): super().__init__(**kwargs) self.args = args self.logger = get_logger(__name__) - # and quiet down some others - logging.getLogger("httpx").setLevel(logging.WARNING) - logging.getLogger("httpcore.http11").setLevel(logging.WARNING) - logging.getLogger("httpcore.connection").setLevel(logging.WARNING) - logging.getLogger("amcrest.http").setLevel(logging.ERROR) - logging.getLogger("amcrest.event").setLevel(logging.WARNING) - logging.getLogger("urllib3.connectionpool").setLevel(logging.WARNING) - # now load self.config right away cfg_arg = getattr(args, "config", None) self.config = self.load_config(cfg_arg) @@ -39,70 +31,68 @@ class Base: if self.config.get("debug"): self.logger.setLevel(logging.DEBUG) - self.running = False - self.discovery_complete = False - self.mqtt_config = self.config["mqtt"] self.amcrest_config = self.config["amcrest"] - self.devices = {} - self.states = {} - self.boosted = [] - self.amcrest_devices = {} - self.events = [] - - self.mqttc = None - self.mqtt_connect_time = None - self.client_id = self.get_new_client_id() - self.service = self.mqtt_config["prefix"] self.service_name = f"{self.service} service" - self.service_slug = self.service + + self.mqtt_helper = MqttHelper(self.service) + + self.running = False + self.discovery_complete = False + + self.devices: dict[str, Any] = {} + self.states: dict[str, Any] = {} + self.amcrest_devices: dict[str, Any] = {} + self.events: list[str] = [] + + self.mqttc: Client + self.mqtt_connect_time: datetime + self.client_id = self.mqtt_helper.client_id() self.qos = self.mqtt_config["qos"] - self.storage_update_interval = self.config["amcrest"].get("storage_update_interval", 900) + self.storage_update_interval = self.amcrest_config.get("storage_update_interval", 900) self.snapshot_update_interval = self.config["amcrest"].get("snapshot_update_interval", 300) self.device_interval = self.config["amcrest"].get("device_interval", 30) - self.device_boost_interval = self.config["amcrest"].get("device_boost_interval", 5) self.device_list_interval = self.config["amcrest"].get("device_list_interval", 300) self.last_call_date = "" self.timezone = self.config["timezone"] - self.count = len(self.amcrest_config["hosts"]) - self.api_calls = 0 - self.last_call_date = None + self.last_call_date = "" self.rate_limited = False - def __enter__(self): + def __enter__(self: Self) -> Amcrest2Mqtt: super_enter = getattr(super(), "__enter__", None) if callable(super_enter): super_enter() - self.mqttc_create() + cast(Any, self).mqttc_create() self.running = True - return self + return cast(Amcrest2Mqtt, self) - def __exit__(self, exc_type, exc_val, exc_tb): + def __exit__(self: Self, exc_type: BaseException | None, exc_val: BaseException | None, exc_tb: TracebackType) -> None: super_exit = getattr(super(), "__exit__", None) if callable(super_exit): super_exit(exc_type, exc_val, exc_tb) self.running = False - if self.mqttc is not None: + if cast(Any, self).mqttc is not None: try: - self.mqttc.loop_stop() + cast(Any, self).publish_service_availability("offline") + cast(Any, self).mqttc.loop_stop() except Exception as e: self.logger.debug(f"MQTT loop_stop failed: {e}") - if self.mqttc.is_connected(): + if cast(Any, self).mqttc.is_connected(): try: - self.mqttc.disconnect() + cast(Any, self).mqttc.disconnect() self.logger.info("Disconnected from MQTT broker") except Exception as e: self.logger.warning(f"Error during MQTT disconnect: {e}") diff --git a/src/amcrest2mqtt/core.py b/src/amcrest2mqtt/core.py index c97845e..222c982 100644 --- a/src/amcrest2mqtt/core.py +++ b/src/amcrest2mqtt/core.py @@ -2,7 +2,7 @@ from .mixins.helpers import HelpersMixin from .mixins.mqtt import MqttMixin from .mixins.topics import TopicsMixin from .mixins.events import EventsMixin -from .mixins.service import ServiceMixin +from .mixins.publish import PublishMixin from .mixins.amcrest import AmcrestMixin from .mixins.amcrest_api import AmcrestAPIMixin from .mixins.refresh import RefreshMixin @@ -14,7 +14,7 @@ class Amcrest2Mqtt( HelpersMixin, EventsMixin, TopicsMixin, - ServiceMixin, + PublishMixin, AmcrestMixin, AmcrestAPIMixin, RefreshMixin, diff --git a/src/amcrest2mqtt/interface.py b/src/amcrest2mqtt/interface.py new file mode 100644 index 0000000..9552ef6 --- /dev/null +++ b/src/amcrest2mqtt/interface.py @@ -0,0 +1,121 @@ +from amcrest import AmcrestCamera +from argparse import Namespace +from asyncio import AbstractEventLoop +from datetime import datetime, timezone +from logging import Logger +from mqtt_helper import MqttHelper +from paho.mqtt.client import Client, MQTTMessage, ConnectFlags, DisconnectFlags +from paho.mqtt.reasoncodes import ReasonCode +from paho.mqtt.properties import Properties +from types import FrameType +from typing import Protocol, Any + + +class AmcrestServiceProtocol(Protocol): + api_calls: int + args: Namespace | None + amcrest_config: dict[str, Any] + amcrest_devices: dict[str, dict[str, Any]] + client_id: str + config: dict[str, Any] + device_interval: int + device_list_interval: int + devices: dict[str, Any] + discovery_complete: bool + events: list + last_call_date: str + logger: Logger + loop: AbstractEventLoop + mqtt_config: dict[str, Any] + mqtt_connect_time: datetime + mqtt_helper: MqttHelper + mqttc: Client + qos: int + rate_limited: bool + running: bool + service_name: str + service: str + storage_update_interval: int + snapshot_update_interval: int + states: dict[str, Any] + timezone: timezone + + async def build_camera(self, device: dict) -> str: ... + async def build_component(self, device: dict) -> str: ... + async def check_event_queue_loop(self) -> None: ... + async def check_for_events(self) -> None: ... + async def collect_all_device_events(self) -> None: ... + async def collect_all_device_snapshots(self) -> None: ... + async def collect_events_loop(self) -> None: ... + async def collect_snapshots_loop(self) -> None: ... + async def connect_to_devices(self) -> dict[str, Any]: ... + async def device_loop(self) -> None: ... + async def get_events_from_device(self, device_id: str) -> None: ... + async def get_snapshot_from_device(self, device_id: str) -> str | None: ... + async def main_loop(self) -> None: ... + async def process_device_event(self, device_id: str, code: str, payload: Any) -> None: ... + async def refresh_all_devices(self) -> None: ... + async def setup_device_list(self) -> None: ... + async def store_recording_in_media(self, device_id: str, amcrest_file: str) -> str | None: ... + + def _csv(self, env_name: str) -> list[str] | None: ... + def _assert_no_tuples(self, data: Any, path: str = "root") -> None: ... + def _decode_payload(self, raw: bytes) -> Any: ... + def _handle_device_topic(self, components: list[str], payload: str) -> None: ... + def _handle_homeassistant_message(self, payload: str) -> None: ... + def _handle_signal(self, signum: int, frame: FrameType | None) -> Any: ... + def _parse_device_topic(self, components: list[str]) -> list[str | None] | None: ... + + def b_to_gb(self, total: int) -> float: ... + def b_to_mb(self, total: int) -> float: ... + def build_device_states(self, device_id: str) -> None: ... + def classify_device(self, device: dict) -> str: ... + def get_api_calls(self) -> int: ... + def get_camera(self, host: str) -> AmcrestCamera: ... + def get_component_type(self, device_id: str) -> str: ... + def get_component(self, device_id: str) -> dict[str, Any]: ... + def get_device_availability_topic(self, device_id: str) -> str: ... + def get_device_image_topic(self, device_id: str) -> str: ... + def get_device_name(self, device_id: str) -> str: ... + def get_device_name_slug(self, device_id: str) -> str: ... + def get_device_state_topic(self, device_id: str, mode_name: str = "") -> str: ... + def get_device(self, host: str, device_name: str, index: int) -> None: ... + def get_ip_address(self, string: str) -> str: ... + def get_last_call_date(self) -> str: ... + def get_mode(self, device_id: str, mode_name: str) -> dict[str, Any]: ... + def get_modes(self, device_id: str) -> dict[str, Any]: ... + def get_motion_detection(self, device_id: str) -> bool: ... + def get_next_event(self) -> dict[str, Any] | None: ... + def get_privacy_mode(self, device_id: str) -> bool: ... + def get_recorded_file(self, device_id: str, file: str, encode: bool = True) -> str | None: ... + def get_snapshot(self, device_id: str) -> str | None: ... + def get_storage_stats(self, device_id: str) -> dict[str, str | float]: ... + def handle_device_command(self, device_id: str, handler: str, message: str) -> None: ... + def handle_service_command(self, handler: str, message: str) -> None: ... + def is_discovered(self, device_id: str) -> bool: ... + def is_ipv4(self, string: str) -> bool: ... + def is_rate_limited(self) -> bool: ... + def load_config(self, config_arg: Any | None) -> dict[str, Any]: ... + def mb_to_b(self, total: int) -> int: ... + def mqtt_on_connect( + self, client: Client, userdata: dict[str, Any], flags: ConnectFlags, reason_code: ReasonCode, properties: Properties | None + ) -> None: ... + def mqtt_on_disconnect(self, client: Client, userdata: Any, flags: DisconnectFlags, reason_code: ReasonCode, properties: Properties | None) -> None: ... + def mqtt_on_message(self, client: Client, userdata: Any, msg: MQTTMessage) -> None: ... + def mqtt_on_subscribe(self, client: Client, userdata: Any, mid: int, reason_code_list: list[ReasonCode], properties: Properties) -> None: ... + def mqtt_on_log(self, client: Client, userdata: Any, paho_log_level: int, msg: str) -> None: ... + def mqtt_safe_publish(self, topic: str, payload: str | bool | int | dict, **kwargs: Any) -> None: ... + def mqttc_create(self) -> None: ... + def publish_device_availability(self, device_id: str, online: bool = True) -> None: ... + def publish_device_discovery(self, device_id: str) -> None: ... + def publish_device_state(self, device_id: str) -> None: ... + def publish_service_availability(self, avail: str = "online") -> None: ... + def publish_service_discovery(self) -> None: ... + def publish_service_state(self) -> None: ... + def read_file(self, file_name: str) -> str: ... + def rediscover_all(self) -> None: ... + def safe_split_device(self, topic: str, segment: str) -> list[str]: ... + def set_motion_detection(self, device_id: str, switch: bool) -> str: ... + def set_privacy_mode(self, device_id: str, switch: bool) -> str: ... + def upsert_device(self, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> None: ... + def upsert_state(self, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> None: ... diff --git a/src/amcrest2mqtt/mixins/amcrest.py b/src/amcrest2mqtt/mixins/amcrest.py index 24a3d61..a74d754 100644 --- a/src/amcrest2mqtt/mixins/amcrest.py +++ b/src/amcrest2mqtt/mixins/amcrest.py @@ -1,18 +1,13 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Jeff Culverhouse import asyncio -import json -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast if TYPE_CHECKING: - from amcrest2mqtt.core import Amcrest2Mqtt - from amcrest2mqtt.interface import AmcrestServiceProtocol + from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt class AmcrestMixin: - if TYPE_CHECKING: - self: "AmcrestServiceProtocol" - async def setup_device_list(self: Amcrest2Mqtt) -> None: self.logger.info("Setting up device list from config") @@ -39,32 +34,52 @@ class AmcrestMixin: self.discovery_complete = True # convert Amcrest device capabilities into MQTT components - async def build_component(self: Amcrest2Mqtt, device: dict) -> str | None: + async def build_component(self: Amcrest2Mqtt, device: dict) -> str: device_class = self.classify_device(device) match device_class: case "camera": return await self.build_camera(device) - - def classify_device(self: Amcrest2Mqtt, device: dict) -> str | None: - return "camera" + return "" + + def classify_device(self: Amcrest2Mqtt, device: dict) -> str: + if device["device_type"].upper() in [ + "IPM-721", + "IPM-HX1", + "IP2M-841", + "IP2M-842", + "IP3M-941", + "IP3M-943", + "IP3M-956", + "IP3M-956E", + "IP3M-HX2", + "IP4M-1026B", + "IP4M-1041B", + "IP4M-1051B", + "IP5M-1176EB", + "IP8M-2496EB", + "IP8M-T2499EW-28M", + "XVR DAHUA 5104S", + ]: + return "camera" + else: + self.logger.error(f"Device you specified is not a supported model: {device["device_type"]}") + return "" async def build_camera(self: Amcrest2Mqtt, device: dict) -> str: - raw_id = device["serial_number"] + raw_id = cast(str, device["serial_number"]) device_id = raw_id component = { "component_type": "camera", "name": device["device_name"], - "uniq_id": f"{self.get_device_slug(device_id, 'video')}", - "topic": self.get_state_topic(device_id, "video"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", - "json_attributes_topic": self.get_state_topic(device_id, "attributes"), + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "video"), + "topic": self.mqtt_helper.stat_t(device_id, "video"), + "avty_t": self.mqtt_helper.avty_t(device_id), "icon": "mdi:video", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": { "name": device["device_name"], - "identifiers": [self.get_device_slug(device_id)], + "identifiers": [self.mqtt_helper.device_slug(device_id)], "manufacturer": device["vendor"], "model": device["device_type"], "sw_version": device["software_version"], @@ -80,222 +95,208 @@ class AmcrestMixin: } if "webrtc" in self.amcrest_config: webrtc_config = self.amcrest_config["webrtc"] + rtc_host = webrtc_config["host"] rtc_port = webrtc_config["port"] rtc_link = webrtc_config["link"] - rtc_source = webrtc_config["sources"].pop(0) - rtc_url = f"http://{rtc_host}:{rtc_port}/{rtc_link}?src={rtc_source}" - component["url_topic"] = rtc_url + rtc_source = self.amcrest_config["webrtc"]["sources"][self.amcrest_devices[device_id]["config"]["index"]] + + if rtc_source: + rtc_url = f"http://{rtc_host}:{rtc_port}/{rtc_link}?src={rtc_source}" + component["url_topic"] = rtc_url + modes = {} - device_block = self.get_device_block( - self.get_device_slug(device_id), + device_block = self.mqtt_helper.device_block( device["device_name"], + self.mqtt_helper.device_slug(device_id), device["vendor"], - device["device_type"], + device["software_version"], ) modes["snapshot"] = { "component_type": "image", "name": "Timed snapshot", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'snapshot')}", - "topic": self.get_state_topic(device_id, "snapshot"), + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "snapshot"), + "image_topic": self.mqtt_helper.stat_t(device_id, "snapshot"), + "avty_t": self.mqtt_helper.avty_t(device_id), "image_encoding": "b64", "content_type": "image/jpeg", - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", - "json_attributes_topic": self.get_state_topic(device_id, "attributes"), "icon": "mdi:camera", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["recording_time"] = { "component_type": "sensor", "name": "Recording time", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'recording_time')}", - "stat_t": self.get_state_topic(device_id, "recording_time"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", - "json_attributes_topic": self.get_state_topic(device_id, "attributes"), + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "recording_time"), + "stat_t": self.mqtt_helper.stat_t(device_id, "recording_time"), + "avty_t": self.mqtt_helper.avty_t(device_id), "device_class": "timestamp", "icon": "mdi:clock", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["recording_url"] = { "component_type": "sensor", "name": "Recording url", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'recording_url')}", - "stat_t": self.get_state_topic(device_id, "recording_url"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "recording_url"), + "stat_t": self.mqtt_helper.stat_t(device_id, "recording_url"), + "avty_t": self.mqtt_helper.avty_t(device_id), "clip_url": f"media-source://media_source/local/Videos/amcrest/{device["device_name"]}-latest.mp4", - "json_attributes_topic": self.get_state_topic(device_id, "attributes"), "icon": "mdi:web", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["privacy"] = { "component_type": "switch", "name": "Privacy mode", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'privacy')}", - "stat_t": self.get_state_topic(device_id, "switch", "privacy"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", - "cmd_t": self.get_command_topic(device_id, "switch", "privacy"), + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "privacy"), + "stat_t": self.mqtt_helper.stat_t(device_id, "switch", "privacy"), + "avty_t": self.mqtt_helper.avty_t(device_id), + "cmd_t": self.mqtt_helper.cmd_t(device_id, "switch", "privacy"), "payload_on": "ON", "payload_off": "OFF", "device_class": "switch", "icon": "mdi:camera-outline", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["motion_detection"] = { "component_type": "switch", "name": "Motion detection", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'motion_detection')}", - "stat_t": self.get_state_topic(device_id, "switch", "motion_detection"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", - "cmd_t": self.get_command_topic(device_id, "switch", "motion_detection"), + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "motion_detection"), + "stat_t": self.mqtt_helper.stat_t(device_id, "switch", "motion_detection"), + "avty_t": self.mqtt_helper.avty_t(device_id), + "cmd_t": self.mqtt_helper.cmd_t(device_id, "switch", "motion_detection"), "payload_on": "ON", "payload_off": "OFF", "device_class": "switch", "icon": "mdi:motion-sensor", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["save_recordings"] = { "component_type": "switch", "name": "Save recordings", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'save_recordings')}", - "stat_t": self.get_state_topic(device_id, "switch", "save_recordings"), - "avty_t": self.get_state_topic(device_id, "internal"), - "avty_tpl": "{{ value_json.media_path }}", - "cmd_t": self.get_command_topic(device_id, "switch", "save_recordings"), + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "save_recordings"), + "stat_t": self.mqtt_helper.stat_t(device_id, "switch", "save_recordings"), + "avty_t": self.mqtt_helper.avty_t(device_id), + "cmd_t": self.mqtt_helper.cmd_t(device_id, "switch", "save_recordings"), "payload_on": "ON", "payload_off": "OFF", "device_class": "switch", "icon": "mdi:content-save-outline", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["motion"] = { "component_type": "binary_sensor", "name": "Motion sensor", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'motion')}", - "stat_t": self.get_state_topic(device_id, "binary_sensor", "motion"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "motion"), + "stat_t": self.mqtt_helper.stat_t(device_id, "binary_sensor", "motion"), + "avty_t": self.mqtt_helper.avty_t(device_id), "payload_on": True, "payload_off": False, "device_class": "motion", "icon": "mdi:eye-outline", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["motion_region"] = { "component_type": "sensor", "name": "Motion region", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'motion_region')}", - "stat_t": self.get_state_topic(device_id, "sensor", "motion_region"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "motion_region"), + "stat_t": self.mqtt_helper.stat_t(device_id, "sensor", "motion_region"), + "avty_t": self.mqtt_helper.avty_t(device_id), "icon": "mdi:map-marker", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["motion_snapshot"] = { "component_type": "image", "name": "Motion snapshot", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'motion_snapshot')}", - "topic": self.get_state_topic(device_id, "motion_snapshot"), + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "motion_snapshot"), + "image_topic": self.mqtt_helper.stat_t(device_id, "motion_snapshot"), + "avty_t": self.mqtt_helper.avty_t(device_id), "image_encoding": "b64", "content_type": "image/jpeg", - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", - "json_attributes_topic": self.get_state_topic(device_id, "attributes"), "icon": "mdi:camera", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["storage_used"] = { "component_type": "sensor", "name": "Storage used", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'storage_used')}", - "stat_t": self.get_state_topic(device_id, "sensor", "storage_used"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "storage_used"), + "stat_t": self.mqtt_helper.stat_t(device_id, "sensor", "storage_used"), + "avty_t": self.mqtt_helper.avty_t(device_id), "device_class": "data_size", "state_class": "measurement", "unit_of_measurement": "GB", "entity_category": "diagnostic", "icon": "mdi:micro-sd", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["storage_used_pct"] = { "component_type": "sensor", "name": "Storage used %", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'storage_used_pct')}", - "stat_t": self.get_state_topic(device_id, "sensor", "storage_used_pct"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "storage_used_pct"), + "stat_t": self.mqtt_helper.stat_t(device_id, "sensor", "storage_used_pct"), + "avty_t": self.mqtt_helper.avty_t(device_id), "state_class": "measurement", "unit_of_measurement": "%", "entity_category": "diagnostic", "icon": "mdi:micro-sd", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["storage_total"] = { "component_type": "sensor", "name": "Storage total", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'storage_total')}", - "stat_t": self.get_state_topic(device_id, "sensor", "storage_total"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "storage_total"), + "stat_t": self.mqtt_helper.stat_t(device_id, "sensor", "storage_total"), + "avty_t": self.mqtt_helper.avty_t(device_id), "device_class": "data_size", "state_class": "measurement", "unit_of_measurement": "GB", "entity_category": "diagnostic", "icon": "mdi:micro-sd", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["event_text"] = { "component_type": "sensor", "name": "Last event", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'event_text')}", - "stat_t": self.get_state_topic(device_id, "sensor", "event_text"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "event_text"), + "stat_t": self.mqtt_helper.stat_t(device_id, "sensor", "event_text"), + "avty_t": self.mqtt_helper.avty_t(device_id), "icon": "mdi:note", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } modes["event_time"] = { "component_type": "sensor", "name": "Last event time", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'event_time')}", - "stat_t": self.get_state_topic(device_id, "sensor", "event_time"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "event_time"), + "stat_t": self.mqtt_helper.stat_t(device_id, "sensor", "event_time"), + "avty_t": self.mqtt_helper.avty_t(device_id), "device_class": "timestamp", "icon": "mdi:clock", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } @@ -303,14 +304,13 @@ class AmcrestMixin: modes["doorbell"] = { "component_type": "binary_sensor", "name": "Doorbell" if device["device_name"] == "Doorbell" else f"{device["device_name"]} Doorbell", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'doorbell')}", - "stat_t": self.get_state_topic(device_id, "binary_sensor", "doorbell"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "doorbell"), + "stat_t": self.mqtt_helper.stat_t(device_id, "binary_sensor", "doorbell"), + "avty_t": self.mqtt_helper.avty_t(device_id), "payload_on": "on", "payload_off": "off", "icon": "mdi:doorbell", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } @@ -318,21 +318,20 @@ class AmcrestMixin: modes["human"] = { "component_type": "binary_sensor", "name": "Human Sensor", - "uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'human')}", - "stat_t": self.get_state_topic(device_id, "binary_sensor", "human"), - "avty_t": self.get_state_topic(device_id, "attributes"), - "avty_tpl": "{{ value_json.camera }}", + "uniq_id": self.mqtt_helper.dev_unique_id(device_id, "human"), + "stat_t": self.mqtt_helper.stat_t(device_id, "binary_sensor", "human"), + "avty_t": self.mqtt_helper.avty_t(device_id), "payload_on": "on", "payload_off": "off", "icon": "mdi:person", - "via_device": self.get_service_device(), + "via_device": self.mqtt_helper.service_slug, "device": device_block, } # defaults - which build_device_states doesn't update (events do) self.upsert_state( device_id, - internal={"discovered": False, "media_path": True if "path" in self.config["media"] else False}, + internal={"discovered": False}, camera={"video": None}, image={"snapshot": None, "motion_snapshot": None}, switch={"save_recordings": "ON" if "path" in self.config["media"] else "OFF"}, @@ -343,10 +342,10 @@ class AmcrestMixin: }, sensor={ "motion_region": "n/a", - "event_text": None, - "event_time": None, - "recording_time": None, - "recording_url": None, + "event_text": "", + "event_time": "unknown", + "recording_time": "unknown", + "recording_url": "", }, ) self.upsert_device(device_id, component=component, modes=modes) @@ -360,90 +359,3 @@ class AmcrestMixin: self.publish_device_state(device_id) return device_id - - def publish_device_discovery(self: Amcrest2Mqtt, device_id: str) -> None: - def _publish_one(dev_id: str, defn: dict, suffix: str | None = None): - # Compute a per-mode device_id for topic namespacing - eff_device_id = dev_id if not suffix else f"{dev_id}_{suffix}" - - # Grab this component's discovery topic - topic = self.get_discovery_topic(defn["component_type"], eff_device_id) - - # Shallow copy to avoid mutating source - payload = {k: v for k, v in defn.items() if k != "component_type"} - - # Publish discovery - self.mqtt_safe_publish(topic, json.dumps(payload), retain=True) - - # Mark discovered in state (per published entity) - self.states.setdefault(eff_device_id, {}).setdefault("internal", {})["discovered"] = 1 - - component = self.get_component(device_id) - _publish_one(device_id, component, suffix=None) - - # Publish any modes (0..n) - modes = self.get_modes(device_id) - for slug, mode in modes.items(): - _publish_one(device_id, mode, suffix=slug) - - def publish_device_state(self: Amcrest2Mqtt, device_id: str) -> None: - def _publish_one(dev_id: str, mode_name: str, defn): - # Grab device states and this component's state topic - topic = self.get_device_state_topic(dev_id, mode_name) - if not topic: - self.logger.error(f"Why is topic emtpy for device {dev_id} and mode {mode_name}") - - # Shallow copy to avoid mutating source - flat = None - if isinstance(defn, dict): - payload = {k: v for k, v in defn.items() if k != "component_type"} - flat = None - - if not payload: - flat = "" - elif not isinstance(payload, dict): - flat = payload - else: - flat = {} - for k, v in payload.items(): - if k == "component_type": - continue - flat[k] = v - - # Add metadata - meta = states.get("meta") - if isinstance(meta, dict) and "last_update" in meta: - flat["last_update"] = meta["last_update"] - self.mqtt_safe_publish(topic, json.dumps(flat), retain=True) - else: - flat = defn - self.mqtt_safe_publish(topic, flat, retain=True) - - if not self.is_discovered(device_id): - self.logger.debug(f"[device state] Discovery not complete for {device_id} yet, holding off on sending state") - return - - states = self.states.get(device_id, None) - - if self.devices[device_id]["component"]["component_type"] != "camera": - _publish_one(device_id, "", states[self.get_component_type(device_id)]) - - # Publish any modes (0..n) - modes = self.get_modes(device_id) - for name, mode in modes.items(): - component_type = mode["component_type"] - type_states = states[component_type][name] if isinstance(states[component_type], dict) else states[component_type] - _publish_one(device_id, name, type_states) - - def publish_device_availability(self: Amcrest2Mqtt, device_id, online: bool = True): - payload = "online" if online else "offline" - - # if state and availability are the SAME, we don't want to - # overwrite the big json state with just online/offline - stat_t = self.get_device_state_topic(device_id) - avty_t = self.get_device_availability_topic(device_id) - if stat_t and avty_t and stat_t == avty_t: - self.logger.info(f"Skipping availability because state_topic and avail_topic are the same: {stat_t}") - return - - self.mqtt_safe_publish(avty_t, payload, retain=True) diff --git a/src/amcrest2mqtt/mixins/amcrest_api.py b/src/amcrest2mqtt/mixins/amcrest_api.py index 68a4bfe..dc988ed 100644 --- a/src/amcrest2mqtt/mixins/amcrest_api.py +++ b/src/amcrest2mqtt/mixins/amcrest_api.py @@ -1,32 +1,29 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Jeff Culverhouse -from amcrest import AmcrestCamera, AmcrestError, CommError, LoginError +from amcrest import AmcrestCamera +from amcrest.exceptions import LoginError, AmcrestError, CommError import asyncio import base64 from datetime import datetime, timezone import random -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: - from amcrest2mqtt.core import Amcrest2Mqtt - from amcrest2mqtt.interface import AmcrestServiceProtocol + from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt SNAPSHOT_TIMEOUT_S = 10 SNAPSHOT_MAX_TRIES = 3 SNAPSHOT_BASE_BACKOFF_S = 0.5 -class AmcrestAPIMixin(object): - if TYPE_CHECKING: - self: "AmcrestServiceProtocol" - - def get_api_calls(self: Amcrest2Mqtt): +class AmcrestAPIMixin: + def get_api_calls(self: Amcrest2Mqtt) -> int: return self.api_calls - def get_last_call_date(self: Amcrest2Mqtt): + def get_last_call_date(self: Amcrest2Mqtt) -> str: return self.last_call_date - def is_rate_limited(self: Amcrest2Mqtt): + def is_rate_limited(self: Amcrest2Mqtt) -> bool: return self.rate_limited # ---------------------------------------------------------------------------------------------- @@ -34,31 +31,34 @@ class AmcrestAPIMixin(object): async def connect_to_devices(self: Amcrest2Mqtt) -> dict[str, Any]: semaphore = asyncio.Semaphore(5) - async def _connect_device(host, name): + async def _connect_device(host: str, name: str, index: int) -> None: async with semaphore: - await asyncio.to_thread(self.get_device, host, name) + await asyncio.to_thread(self.get_device, host, name, index) self.logger.info(f'Connecting to: {self.amcrest_config["hosts"]}') tasks = [] - for host, name in zip(self.amcrest_config["hosts"], self.amcrest_config["names"]): - tasks.append(_connect_device(host, name)) + index = 0 + for host, name in zip(cast(str, self.amcrest_config["hosts"]), cast(str, self.amcrest_config["names"])): + tasks.append(_connect_device(host, name, index)) + index += 1 await asyncio.gather(*tasks) self.logger.info("Connecting to hosts done.") return {d: self.amcrest_devices[d]["config"] for d in self.amcrest_devices.keys()} - def get_camera(self, host: str) -> AmcrestCamera: + def get_camera(self: Amcrest2Mqtt, host: str) -> AmcrestCamera: config = self.amcrest_config - return AmcrestCamera(host, config["port"], config["username"], config["password"], verbose=False).camera + return AmcrestCamera(host, config["port"], config["username"], config["password"], verbose=False) - def get_device(self, host: str, device_name: str) -> None: + def get_device(self: Amcrest2Mqtt, host: str, device_name: str, index: int) -> None: camera = None try: # resolve host and setup camera by ip so we aren't making 100k DNS lookups per day try: host_ip = self.get_ip_address(host) - camera = self.get_camera(host_ip) + device = self.get_camera(host_ip) + camera = device.camera except Exception as err: self.logger.error(f"Error with {host}: {err}") return @@ -69,9 +69,6 @@ class AmcrestAPIMixin(object): is_doorbell = is_ad110 or is_ad410 serial_number = camera.serial_number - if not isinstance(serial_number, str): - self.logger.error(f"Error fetching serial number for {host}: {camera.serial_number}") - exit(1) version = camera.software_information[0].replace("version=", "").strip() build = camera.software_information[1].strip() @@ -89,6 +86,7 @@ class AmcrestAPIMixin(object): "camera": camera, "config": { "host": host, + "index": index, "host_ip": host_ip, "device_name": device_name, "device_type": device_type, @@ -116,15 +114,15 @@ class AmcrestAPIMixin(object): # Storage stats ------------------------------------------------------------------------------- - def get_storage_stats(self, device_id: str) -> dict[str, str]: + def get_storage_stats(self: Amcrest2Mqtt, device_id: str) -> dict[str, str | float]: try: storage = self.amcrest_devices[device_id]["camera"].storage_all except CommError: self.logger.error(f"Failed to communicate with device ({self.get_device_name(device_id)}) for storage stats") + return {} except LoginError: self.logger.error(f"Failed to authenticate with device ({self.get_device_name(device_id)}) for storage stats") - if not storage: - return + return {} return { "used_percent": storage.get("used_percent", "unknown"), @@ -134,7 +132,7 @@ class AmcrestAPIMixin(object): # Privacy config ------------------------------------------------------------------------------ - def get_privacy_mode(self, device_id: str) -> bool: + def get_privacy_mode(self: Amcrest2Mqtt, device_id: str) -> bool: device = self.amcrest_devices[device_id] try: @@ -148,11 +146,11 @@ class AmcrestAPIMixin(object): return privacy_mode - def set_privacy_mode(self, device_id: str, switch: bool) -> str: + def set_privacy_mode(self: Amcrest2Mqtt, device_id: str, switch: bool) -> str: device = self.amcrest_devices[device_id] try: - response = device["camera"].set_privacy(switch).strip() + response = cast(str, device["camera"].set_privacy(switch).strip()) except CommError: self.logger.error(f"Failed to communicate with device ({self.get_device_name(device_id)}) to set privacy mode") except LoginError: @@ -161,27 +159,37 @@ class AmcrestAPIMixin(object): # Motion detection config --------------------------------------------------------------------- - def get_motion_detection(self, device_id: str) -> bool: + def get_motion_detection(self: Amcrest2Mqtt, device_id: str) -> bool: device = self.amcrest_devices[device_id] + if not device["camera"]: + self.logger.warning(f"Cannot get motion_detection, no camera found for {self.get_device_name(device_id)}") + return False try: - motion_detection = device["camera"].is_motion_detector_on() + motion_detection: bool = device["camera"].is_motion_detector_on() except CommError: self.logger.error(f"Failed to communicate with device ({self.get_device_name(device_id)}) to get motion detection") + return False except LoginError: self.logger.error(f"Failed to authenticate with device ({self.get_device_name(device_id)}) to get motion detection") + return False return motion_detection - def set_motion_detection(self, device_id: str, switch: bool) -> str: + def set_motion_detection(self: Amcrest2Mqtt, device_id: str, switch: bool) -> str: device = self.amcrest_devices[device_id] + if not device["camera"]: + self.logger.warning(f"Cannot set motion_detection, no camera found for {self.get_device_name(device_id)}") + return "" try: - response = device["camera"].set_motion_detection(switch) + response = str(device["camera"].set_motion_detection(switch)) except CommError: self.logger.error(f"Failed to communicate with device ({self.get_device_name(device_id)}) to set motion detections") + return "" except LoginError: self.logger.error(f"Failed to authenticate with device ({self.get_device_name(device_id)}) to set motion detections") + return "" return response @@ -191,7 +199,7 @@ class AmcrestAPIMixin(object): tasks = [self.get_snapshot_from_device(device_id) for device_id in self.amcrest_devices] await asyncio.gather(*tasks) - async def get_snapshot_from_device(self, device_id: str) -> str | None: + async def get_snapshot_from_device(self: Amcrest2Mqtt, device_id: str) -> str | None: device = self.amcrest_devices[device_id] # Respect privacy mode (default False if missing) @@ -249,34 +257,34 @@ class AmcrestAPIMixin(object): self.logger.error(f"Snapshot: failed after {SNAPSHOT_MAX_TRIES} tries for {self.get_device_name(device_id)}") return None - def get_snapshot(self, device_id: str) -> str | None: + def get_snapshot(self: Amcrest2Mqtt, device_id: str) -> str | None: return self.amcrest_devices[device_id]["snapshot"] if "snapshot" in self.devices[device_id] else None # Recorded file ------------------------------------------------------------------------------- - def get_recorded_file(self, device_id: str, file: str, encode: bool = True) -> str | None: + def get_recorded_file(self: Amcrest2Mqtt, device_id: str, file: str, encode: bool = True) -> str | None: device = self.amcrest_devices[device_id] tries = 0 while tries < 3: try: - data_raw = device["camera"].download_file(file) + data_raw = cast(bytes, device["camera"].download_file(file)) if data_raw: if not encode: if len(data_raw) < self.mb_to_b(100): - return data_raw + return data_raw.decode("latin-1") else: self.logger.error(f"Raw recording is too large: {self.b_to_mb(len(data_raw))} MB") - return + return None data_base64 = base64.b64encode(data_raw) self.logger.info( f"Processed recording from ({self.get_device_name(device_id)}) {len(data_raw)} bytes raw, and {len(data_base64)} bytes base64" ) if len(data_base64) < self.mb_to_b(100): - return data_base64 + return data_raw.decode("latin-1") else: self.logger.error(f"Encoded recording is too large: {self.b_to_mb(len(data_base64))} MB") - return + return None except CommError: tries += 1 except LoginError: @@ -284,6 +292,7 @@ class AmcrestAPIMixin(object): if tries == 3: self.logger.error(f"Failed to communicate with device ({self.get_device_name(device_id)}) to get recorded file") + return None # Events -------------------------------------------------------------------------------------- @@ -291,7 +300,7 @@ class AmcrestAPIMixin(object): tasks = [self.get_events_from_device(device_id) for device_id in self.amcrest_devices] await asyncio.gather(*tasks) - async def get_events_from_device(self, device_id: str) -> None: + async def get_events_from_device(self: Amcrest2Mqtt, device_id: str) -> None: device = self.amcrest_devices[device_id] tries = 0 @@ -307,7 +316,7 @@ class AmcrestAPIMixin(object): if tries == 3: self.logger.error(f"Failed to communicate for events for device ({self.get_device_name(device_id)})") - async def process_device_event(self, device_id: str, code: str, payload: Any): + async def process_device_event(self: Amcrest2Mqtt, device_id: str, code: str, payload: Any) -> None: try: device = self.amcrest_devices[device_id] config = device["config"] @@ -357,5 +366,5 @@ class AmcrestAPIMixin(object): except Exception as err: self.logger.error(f"Failed to process event from {self.get_device_name(device_id)}: {err}", exc_info=True) - def get_next_event(self: Amcrest2Mqtt) -> str | None: + def get_next_event(self: Amcrest2Mqtt) -> dict[str, Any] | None: return self.events.pop(0) if len(self.events) > 0 else None diff --git a/src/amcrest2mqtt/mixins/events.py b/src/amcrest2mqtt/mixins/events.py index d722504..e1cabeb 100644 --- a/src/amcrest2mqtt/mixins/events.py +++ b/src/amcrest2mqtt/mixins/events.py @@ -1,18 +1,15 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Jeff Culverhouse import asyncio -from typing import TYPE_CHECKING +import json +from typing import TYPE_CHECKING, cast, Any from datetime import datetime, timezone if TYPE_CHECKING: - from amcrest2mqtt.core import Amcrest2Mqtt - from amcrest2mqtt.interface import AmcrestServiceProtocol + from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt class EventsMixin: - if TYPE_CHECKING: - self: "AmcrestServiceProtocol" - async def collect_all_device_events(self: Amcrest2Mqtt) -> None: tasks = [self.get_events_from_device(device_id) for device_id in self.amcrest_devices] await asyncio.gather(*tasks) @@ -20,15 +17,13 @@ class EventsMixin: async def check_for_events(self: Amcrest2Mqtt) -> None: try: while device_event := self.get_next_event(): - if device_event is None: - break if "device_id" not in device_event: - self.logger(f"Got event, but missing device_id: {device_event}") + self.logger.error(f"Got event, but missing device_id: {json.dumps(device_event)}") continue - device_id = device_event["device_id"] - event = device_event["event"] - payload = device_event["payload"] + device_id = str(device_event["device_id"]) + event = cast(str, device_event["event"]) + payload = cast(dict[str, Any], device_event["payload"]) device_states = self.states[device_id] diff --git a/src/amcrest2mqtt/mixins/helpers.py b/src/amcrest2mqtt/mixins/helpers.py index 68402e1..153ae37 100644 --- a/src/amcrest2mqtt/mixins/helpers.py +++ b/src/amcrest2mqtt/mixins/helpers.py @@ -1,28 +1,31 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Jeff Culverhouse -from deepmerge import Merger +from deepmerge.merger import Merger import ipaddress import logging import os import signal import socket import threading +from types import FrameType import yaml from pathlib import Path from datetime import datetime, timezone -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: - from amcrest2mqtt.core import Amcrest2Mqtt - from amcrest2mqtt.interface import AmcrestServiceProtocol + from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt READY_FILE = os.getenv("READY_FILE", "/tmp/amcrest2mqtt.ready") -class HelpersMixin: - if TYPE_CHECKING: - self: "AmcrestServiceProtocol" +class ConfigError(ValueError): + """Raised when the configuration file is invalid.""" + + pass + +class HelpersMixin: def build_device_states(self: Amcrest2Mqtt, device_id: str) -> None: storage = self.get_storage_stats(device_id) privacy = self.get_privacy_mode(device_id) @@ -38,7 +41,6 @@ class HelpersMixin: "storage_used": storage["used"], "storage_total": storage["total"], "storage_used_pct": storage["used_percent"], - "last_update": self.get_last_update(device_id), }, ) @@ -56,11 +58,11 @@ class HelpersMixin: def handle_service_command(self: Amcrest2Mqtt, handler: str, message: str) -> None: match handler: case "storage_refresh": - self.device_interval = message + self.device_interval = int(message) case "device_list_refresh": - self.device_list_interval = message + self.device_list_interval = int(message) case "snapshot_refresh": - self.device_boost_interval = message + self.snapshot_update_interval = int(message) case "refresh_device_list": if message == "refresh": self.rediscover_all() @@ -68,11 +70,11 @@ class HelpersMixin: self.logger.error("[handler] unknown [message]") return case _: - self.logger.error(f"Unrecognized message to {self.service_slug}: {handler} -> {message}") + self.logger.error(f"Unrecognized message to {self.mqtt_helper.service_slug}: {handler} -> {message}") return self.publish_service_state() - def rediscover_all(self: Amcrest2Mqtt): + def rediscover_all(self: Amcrest2Mqtt) -> None: self.publish_service_state() self.publish_service_discovery() for device_id in self.devices: @@ -111,21 +113,21 @@ class HelpersMixin: try: for i in socket.getaddrinfo(string, None): if i[0] == socket.AddressFamily.AF_INET: - return i[4][0] + return str(i[4][0]) except socket.gaierror as e: raise Exception(f"Failed to resolve {string}: {e}") raise Exception(f"Failed to find IP address for {string}") - def _csv(self: Amcrest2Mqtt, env_name): + def _csv(self: Amcrest2Mqtt, env_name: str) -> list[str] | None: v = os.getenv(env_name) if not v: return None return [s.strip() for s in v.split(",") if s.strip()] - def load_config(self: Amcrest2Mqtt, config_arg: str = None) -> list[str, Any]: + def load_config(self: Amcrest2Mqtt, config_arg: Any | None) -> dict[str, Any]: version = os.getenv("BLINK2MQTT_VERSION", self.read_file("VERSION")) config_from = "env" - config = {} + config: dict[str, str | bool | int | dict] = {} # Determine config file path config_path = config_arg or "/config" @@ -156,10 +158,10 @@ class HelpersMixin: logging.warning(f"Config file not found at {config_file}, falling back to environment vars") # Merge with environment vars (env vars override nothing if file exists) - mqtt = config.get("mqtt", {}) - amcrest = config.get("amcrest", {}) - webrtc = amcrest.get("webrtc", {}) - media = config.get("media", {}) + mqtt = cast(dict[str, Any], config.get("mqtt", {})) + amcrest = cast(dict[str, Any], config.get("amcrest", {})) + webrtc = cast(dict[str, Any], amcrest.get("webrtc", {})) + media = cast(dict[str, Any], config.get("media", {})) # Determine media path (optional) media_path = media.get("path", None) @@ -175,17 +177,17 @@ class HelpersMixin: # fmt: off mqtt = { - "host": mqtt.get("host") or os.getenv("MQTT_HOST", "localhost"), - "port": int(mqtt.get("port") or os.getenv("MQTT_PORT", 1883)), - "qos": int(mqtt.get("qos") or os.getenv("MQTT_QOS", 0)), - "username": mqtt.get("username") or os.getenv("MQTT_USERNAME", ""), - "password": mqtt.get("password") or os.getenv("MQTT_PASSWORD", ""), - "tls_enabled": mqtt.get("tls_enabled") or (os.getenv("MQTT_TLS_ENABLED", "false").lower() == "true"), - "tls_ca_cert": mqtt.get("tls_ca_cert") or os.getenv("MQTT_TLS_CA_CERT"), - "tls_cert": mqtt.get("tls_cert") or os.getenv("MQTT_TLS_CERT"), - "tls_key": mqtt.get("tls_key") or os.getenv("MQTT_TLS_KEY"), - "prefix": mqtt.get("prefix") or os.getenv("MQTT_PREFIX", "amcrest2mqtt"), - "discovery_prefix": mqtt.get("discovery_prefix") or os.getenv("MQTT_DISCOVERY_PREFIX", "homeassistant"), + "host": cast(str, mqtt.get("host") or os.getenv("MQTT_HOST", "localhost")), + "port": int(cast(str, mqtt.get("port") or os.getenv("MQTT_PORT", 1883))), + "qos": int(cast(str, mqtt.get("qos") or os.getenv("MQTT_QOS", 0))), + "username": mqtt.get("username") or os.getenv("MQTT_USERNAME", ""), + "password": mqtt.get("password") or os.getenv("MQTT_PASSWORD", ""), + "tls_enabled": mqtt.get("tls_enabled") or (os.getenv("MQTT_TLS_ENABLED", "false").lower() == "true"), + "tls_ca_cert": mqtt.get("tls_ca_cert") or os.getenv("MQTT_TLS_CA_CERT"), + "tls_cert": mqtt.get("tls_cert") or os.getenv("MQTT_TLS_CERT"), + "tls_key": mqtt.get("tls_key") or os.getenv("MQTT_TLS_KEY"), + "prefix": mqtt.get("prefix") or os.getenv("MQTT_PREFIX", "amcrest2mqtt"), + "discovery_prefix": mqtt.get("discovery_prefix") or os.getenv("MQTT_DISCOVERY_PREFIX", "homeassistant"), } hosts = amcrest.get("hosts") or self._csv("AMCREST_HOSTS") or [] @@ -195,16 +197,16 @@ class HelpersMixin: amcrest = { "hosts": hosts, "names": names, - "port": int(amcrest.get("port") or os.getenv("AMCREST_PORT", 80)), - "username": amcrest.get("username") or os.getenv("AMCREST_USERNAME", ""), - "password": amcrest.get("password") or os.getenv("AMCREST_PASSWORD", ""), - "storage_update_interval": int(amcrest.get("storage_update_interval") or os.getenv("AMCREST_STORAGE_UPDATE_INTERVAL", 900)), - "snapshot_update_interval": int(amcrest.get("snapshot_update_interval") or os.getenv("AMCREST_SNAPSHOT_UPDATE_INTERVAL", 60)), + "port": int(cast(str, amcrest.get("port") or os.getenv("AMCREST_PORT", 80))), + "username": amcrest.get("username") or os.getenv("AMCREST_USERNAME", ""), + "password": amcrest.get("password") or os.getenv("AMCREST_PASSWORD", ""), + "storage_update_interval": int(cast(str, amcrest.get("storage_update_interval") or os.getenv("AMCREST_STORAGE_UPDATE_INTERVAL", 900))), + "snapshot_update_interval": int(cast(str, amcrest.get("snapshot_update_interval") or os.getenv("AMCREST_SNAPSHOT_UPDATE_INTERVAL", 60))), "webrtc": { - "host": webrtc.get("host") or os.getenv("AMCREST_WEBRTC_HOST", ""), - "port": int(webrtc.get("port") or os.getenv("AMCREST_WEBRTC_PORT", 1984)), - "link": webrtc.get("link") or os.getenv("AMCREST_WEBRTC_LINK", "webrtc"), - "sources": sources, + "host": webrtc.get("host") or os.getenv("AMCREST_WEBRTC_HOST", ""), + "port": int(cast(str, webrtc.get("port") or os.getenv("AMCREST_WEBRTC_PORT", 1984))), + "link": webrtc.get("link") or os.getenv("AMCREST_WEBRTC_LINK", "webrtc"), + "sources": sources, }, } @@ -222,14 +224,14 @@ class HelpersMixin: # fmt: on # Validate required fields - if not config["amcrest"].get("username") or not config["amcrest"].get("password"): - raise ValueError("`amcrest.username` and `amcrest.password` are required in config file or AMCREST_USERNAME and AMCREST_PASSWORD env vars") + if not cast(dict, config["amcrest"]).get("username") or not cast(dict, config["amcrest"]).get("password"): + raise ConfigError("`amcrest.username` and `amcrest.password` are required in config file or AMCREST_USERNAME and AMCREST_PASSWORD env vars") # Ensure list lengths match (sources is optional) if len(hosts) != len(names): - raise ValueError("`amcrest.hosts` and `amcrest.names` must be the same length") + raise ConfigError("`amcrest.hosts` and `amcrest.names` must be the same length") if sources and len(sources) != len(hosts): - raise ValueError("`amcrest.webrtc.sources` must match the length of `amcrest.hosts`/`amcrest.names` if provided") + raise ConfigError("`amcrest.webrtc.sources` must match the length of `amcrest.hosts`/`amcrest.names` if provided") return config @@ -242,7 +244,7 @@ class HelpersMixin: file_name = f"{name}-{time}.mp4" file_path = Path(f"{path}/{file_name}") try: - file_path.write_bytes(recording) + file_path.write_bytes(recording.encode("latin-1")) self.upsert_state( device_id, @@ -261,17 +263,17 @@ class HelpersMixin: return url except IOError as e: self.logger.error(f"Failed to save recordingt to {path}: {e}") - return + return None self.logger.error(f"Failed to download recording from device {self.get_device_name(device_id)}") + return None - def _handle_signal(self: Amcrest2Mqtt, signum, frame=None): - """Handle SIGTERM/SIGINT and exit cleanly or forcefully.""" + def _handle_signal(self: Amcrest2Mqtt, signum: int, frame: FrameType | None) -> Any: sig_name = signal.Signals(signum).name self.logger.warning(f"{sig_name} received - stopping service loop") self.running = False - def _force_exit(): + def _force_exit() -> None: self.logger.warning("Force-exiting process after signal") os._exit(0) @@ -279,14 +281,7 @@ class HelpersMixin: # Upsert devices and states ------------------------------------------------------------------- - MERGER = Merger( - [(dict, "merge"), (list, "append_unique"), (set, "union")], - ["override"], # type conflicts: new wins - ["override"], # fallback - ) - - def _assert_no_tuples(self: Amcrest2Mqtt, data, path="root"): - """Recursively check for tuples in both keys and values of dicts/lists.""" + def _assert_no_tuples(self: Amcrest2Mqtt, data: Any, path: str = "root") -> None: if isinstance(data, tuple): raise TypeError(f"⚠️ Found tuple at {path}: {data!r}") @@ -299,18 +294,28 @@ class HelpersMixin: for idx, value in enumerate(data): self._assert_no_tuples(value, f"{path}[{idx}]") - def upsert_device(self: Amcrest2Mqtt, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> None: + def upsert_device(self: Amcrest2Mqtt, device_id: str, **kwargs: dict[str, Any] | str | int | bool | None) -> None: + MERGER = Merger( + [(dict, "merge"), (list, "append_unique"), (set, "union")], + ["override"], # type conflicts: new wins + ["override"], # fallback + ) for section, data in kwargs.items(): # Pre-merge check self._assert_no_tuples(data, f"device[{device_id}].{section}") - merged = self.MERGER.merge(self.devices.get(device_id, {}), {section: data}) + merged = MERGER.merge(self.devices.get(device_id, {}), {section: data}) # Post-merge check self._assert_no_tuples(merged, f"device[{device_id}].{section} (post-merge)") self.devices[device_id] = merged - def upsert_state(self: Amcrest2Mqtt, device_id, **kwargs: dict[str, Any] | str | int | bool) -> None: + def upsert_state(self: Amcrest2Mqtt, device_id: str, **kwargs: dict[str, Any] | str | int | bool | None) -> None: + MERGER = Merger( + [(dict, "merge"), (list, "append_unique"), (set, "union")], + ["override"], # type conflicts: new wins + ["override"], # fallback + ) for section, data in kwargs.items(): self._assert_no_tuples(data, f"state[{device_id}].{section}") - merged = self.MERGER.merge(self.states.get(device_id, {}), {section: data}) + merged = MERGER.merge(self.states.get(device_id, {}), {section: data}) self._assert_no_tuples(merged, f"state[{device_id}].{section} (post-merge)") self.states[device_id] = merged diff --git a/src/amcrest2mqtt/mixins/loops.py b/src/amcrest2mqtt/mixins/loops.py index 98eae56..185e7a4 100644 --- a/src/amcrest2mqtt/mixins/loops.py +++ b/src/amcrest2mqtt/mixins/loops.py @@ -5,15 +5,11 @@ import signal from typing import TYPE_CHECKING if TYPE_CHECKING: - from amcrest2mqtt.core import Amcrest2Mqtt - from amcrest2mqtt.interface import AmcrestServiceProtocol + from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt class LoopsMixin: - if TYPE_CHECKING: - self: "AmcrestServiceProtocol" - - async def device_loop(self: Amcrest2Mqtt): + async def device_loop(self: Amcrest2Mqtt) -> None: while self.running: await self.refresh_all_devices() try: @@ -22,7 +18,7 @@ class LoopsMixin: self.logger.debug("device_loop cancelled during sleep") break - async def collect_events_loop(self: Amcrest2Mqtt): + async def collect_events_loop(self: Amcrest2Mqtt) -> None: while self.running: await self.collect_all_device_events() try: @@ -31,7 +27,7 @@ class LoopsMixin: self.logger.debug("collect_events_loop cancelled during sleep") break - async def check_event_queue_loop(self: Amcrest2Mqtt): + async def check_event_queue_loop(self: Amcrest2Mqtt) -> None: while self.running: await self.check_for_events() try: @@ -40,17 +36,17 @@ class LoopsMixin: self.logger.debug("check_event_queue_loop cancelled during sleep") break - async def collect_snapshots_loop(self: Amcrest2Mqtt): + async def collect_snapshots_loop(self: Amcrest2Mqtt) -> None: while self.running: await self.collect_all_device_snapshots() try: - await asyncio.sleep(self.snapshot_update_interval) + await asyncio.sleep(self.snapshot_update_interval * 60) except asyncio.CancelledError: self.logger.debug("collect_snapshots_loop cancelled during sleep") break # main loop - async def main_loop(self: Amcrest2Mqtt): + async def main_loop(self: Amcrest2Mqtt) -> None: await self.setup_device_list() self.loop = asyncio.get_running_loop() @@ -70,11 +66,7 @@ class LoopsMixin: ] try: - results = await asyncio.gather(*tasks) - for result in results: - if isinstance(result, Exception): - self.logger.error(f"Task raised exception: {result}", exc_info=True) - self.running = False + await asyncio.gather(*tasks) except asyncio.CancelledError: self.logger.warning("Main loop cancelled — shutting down...") except Exception as err: diff --git a/src/amcrest2mqtt/mixins/mqtt.py b/src/amcrest2mqtt/mixins/mqtt.py index 7885ab8..6f738e8 100644 --- a/src/amcrest2mqtt/mixins/mqtt.py +++ b/src/amcrest2mqtt/mixins/mqtt.py @@ -1,26 +1,32 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Jeff Culverhouse +from datetime import datetime, timedelta import json import paho.mqtt.client as mqtt +from paho.mqtt.client import Client, MQTTMessage, PayloadType, ConnectFlags, DisconnectFlags +from paho.mqtt.enums import LogLevel from paho.mqtt.properties import Properties from paho.mqtt.packettypes import PacketTypes +from paho.mqtt.reasoncodes import ReasonCode +from paho.mqtt.enums import CallbackAPIVersion import ssl -import time -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast if TYPE_CHECKING: - from amcrest2mqtt.core import Amcrest2Mqtt - from amcrest2mqtt.interface import AmcrestServiceProtocol + from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt -class MqttMixin: - if TYPE_CHECKING: - self: "AmcrestServiceProtocol" +class MqttError(ValueError): + """Raised when the connection to the MQTT server fails""" + + pass + - def mqttc_create(self: Amcrest2Mqtt): +class MqttMixin: + def mqttc_create(self: Amcrest2Mqtt) -> None: self.mqttc = mqtt.Client( client_id=self.client_id, - callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + callback_api_version=CallbackAPIVersion.VERSION2, reconnect_on_failure=False, protocol=mqtt.MQTTv5, ) @@ -35,8 +41,8 @@ class MqttMixin: ) if self.mqtt_config.get("username") or self.mqtt_config.get("password"): self.mqttc.username_pw_set( - username=self.mqtt_config.get("username") or None, - password=self.mqtt_config.get("password") or None, + username=self.mqtt_config.get("username", ""), + password=self.mqtt_config.get("password", ""), ) self.mqttc.on_connect = self.mqtt_on_connect @@ -46,11 +52,11 @@ class MqttMixin: self.mqttc.on_log = self.mqtt_on_log # Define a "last will" message (LWT): - self.mqttc.will_set(self.get_service_topic("status"), "offline", qos=self.qos, retain=True) + self.mqttc.will_set(self.mqtt_helper.svc_t("status"), "offline", qos=1, retain=True) try: - host = self.mqtt_config.get("host") - port = self.mqtt_config.get("port") + host = self.mqtt_config["host"] + port = self.mqtt_config["port"] self.logger.info(f"Connecting to MQTT broker at {host}:{port} as {self.client_id}") props = Properties(PacketTypes.CONNECT) @@ -59,106 +65,109 @@ class MqttMixin: self.mqttc.connect(host=host, port=port, keepalive=60, properties=props) self.logger.info(f"Successful connection to {host} MQTT broker") - self.mqtt_connect_time = time.time() + self.mqtt_connect_time = datetime.now() self.mqttc.loop_start() except ConnectionError as error: self.logger.error(f"Failed to connect to MQTT host {host}: {error}") self.running = False + raise SystemExit(1) except Exception as error: self.logger.error(f"Network problem trying to connect to MQTT host {host}: {error}") self.running = False + raise SystemExit(1) - def mqtt_on_connect(self: Amcrest2Mqtt, client, userdata, flags, reason_code, properties): + def mqtt_on_connect( + self: Amcrest2Mqtt, client: Client, userdata: dict[str, Any], flags: ConnectFlags, reason_code: ReasonCode, properties: Properties | None + ) -> None: if reason_code.value != 0: - self.logger.error(f"MQTT failed to connect ({reason_code.getName()})") - self.running = False - return + raise MqttError(f"MQTT failed to connect ({reason_code.getName()})") self.publish_service_discovery() self.publish_service_availability() self.publish_service_state() - self.logger.info("Subscribing to topics on MQTT") + self.logger.debug("Subscribing to topics on MQTT") client.subscribe("homeassistant/status") - client.subscribe(f"{self.service_slug}/service/+/set") - client.subscribe(f"{self.service_slug}/service/+/command") - client.subscribe(f"{self.service_slug}/switch/#") + client.subscribe(f"{self.mqtt_helper.service_slug}/service/+/set") + client.subscribe(f"{self.mqtt_helper.service_slug}/service/+/command") + client.subscribe(f"{self.mqtt_helper.service_slug}/switch/#") - def mqtt_on_disconnect(self: Amcrest2Mqtt, client, userdata, flags, reason_code, properties): + def mqtt_on_disconnect( + self: Amcrest2Mqtt, client: Client, userdata: Any, flags: DisconnectFlags, reason_code: ReasonCode, properties: Properties | None + ) -> None: if reason_code.value != 0: self.logger.error(f"MQTT lost connection ({reason_code.getName()})") else: self.logger.info("Closed MQTT connection") - if self.running and (self.mqtt_connect_time is None or time.time() > self.mqtt_connect_time + 10): - # clear connect_time and try to restart - self.mqtt_connect_time = None - while not self.mqtt_connect_time: - try: - self.client_id = self.get_new_client_id() - self.mqttc_create() - except Exception as e: - self.logger.error(f"Trouble reconnecting to MQTT (retry in 10 s): {e}") - time.sleep(10) + if self.running and (self.mqtt_connect_time is None or datetime.now() > self.mqtt_connect_time + timedelta(seconds=10)): + # lets use a new client_id for a reconnect attempt + self.client_id = self.mqtt_helper.client_id() + self.mqttc_create() else: self.logger.info("MQTT disconnect — stopping service loop") self.running = False - def mqtt_on_log(self: Amcrest2Mqtt, client, userdata, paho_log_level, msg): - if paho_log_level == mqtt.LogLevel.MQTT_LOG_ERR: + def mqtt_on_log(self: Amcrest2Mqtt, client: Client, userdata: Any, paho_log_level: int, msg: str) -> None: + if paho_log_level == LogLevel.MQTT_LOG_ERR: self.logger.error(f"MQTT logged: {msg}") - if paho_log_level == mqtt.LogLevel.MQTT_LOG_WARNING: + if paho_log_level == LogLevel.MQTT_LOG_WARNING: self.logger.warning(f"MQTT logged: {msg}") - def mqtt_on_message(self: Amcrest2Mqtt, client, userdata, msg): + def mqtt_on_message(self: Amcrest2Mqtt, client: Client, userdata: Any, msg: MQTTMessage) -> None: topic = msg.topic payload = self._decode_payload(msg.payload) components = topic.split("/") - # Dispatch based on type of message if components[0] == self.mqtt_config["discovery_prefix"]: return self._handle_homeassistant_message(payload) - if components[0] == self.service_slug and components[1] == "service": + if components[0] == self.mqtt_helper.service_slug and components[1] == "service": return self.handle_service_command(components[2], payload) - if components[0] == self.service_slug: + if components[0] == self.mqtt_helper.service_slug: return self._handle_device_topic(components, payload) - # self.logger.debug(f"Ignoring unrelated MQTT topic: {topic}") + self.logger.debug(f"Ignoring unrelated MQTT topic: {topic}") - def _decode_payload(self: Amcrest2Mqtt, raw): + def _decode_payload(self: Amcrest2Mqtt, raw: bytes) -> Any: try: return json.loads(raw) except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError): - # Fallback: try to decode as UTF-8 string try: return raw.decode("utf-8") except Exception: self.logger.warning("Failed to decode MQTT payload") return None - def _handle_homeassistant_message(self: Amcrest2Mqtt, payload): + def _handle_homeassistant_message(self: Amcrest2Mqtt, payload: str) -> None: if payload == "online": self.rediscover_all() self.logger.info("Home Assistant came online — rediscovering devices") - def _handle_device_topic(self: Amcrest2Mqtt, components, payload): - vendor, device_id, attribute = self._parse_device_topic(components) - if not vendor or not vendor.startswith(self.service_slug): - self.logger.debug(f"Ignoring non-Amcrest device topic: {'/'.join(components)}") + def _handle_device_topic(self: Amcrest2Mqtt, components: list[str], payload: str) -> None: + parsed = self._parse_device_topic(components) + if not parsed: + return + + (vendor, device_id, attribute) = parsed + if not vendor or not vendor.startswith(self.mqtt_helper.service_slug): + self.logger.error(f"Ignoring non-Amcrest device command, got vendor {vendor}") + return + if not device_id or not attribute: + self.logger.error(f"Failed to parse device_id and/or payload from mqtt topic components: {components}") return if not self.devices.get(device_id, None): self.logger.warning(f"Got MQTT message for unknown device: {device_id}") return - self.logger.debug(f"Got message for {self.get_device_name(device_id)}: {attribute} => {payload}") + self.logger.info(f"Got message for {self.get_device_name(device_id)}: set {components[-2]} to {payload}") self.handle_device_command(device_id, attribute, payload) - def _parse_device_topic(self: Amcrest2Mqtt, components): + def _parse_device_topic(self: Amcrest2Mqtt, components: list[str]) -> list[str | None] | None: try: if components[-1] != "set": - return (None, None, None) + return None # Example topics: # amcrest2mqtt/light/amcrest2mqtt_2BEFD0C907BB6BF2/set @@ -176,34 +185,33 @@ class MqttMixin: else: raise ValueError(f"Malformed topic (expected underscore): {'/'.join(components)}") - return (vendor, device_id, attribute) + return [vendor, device_id, attribute] except Exception as e: self.logger.warning(f"Malformed device topic: {components} ({e})") - return (None, None, None) + return [] - def safe_split_device(self: Amcrest2Mqtt, topic, segment): - """Split a topic segment into (vendor, device_id) safely.""" + def safe_split_device(self: Amcrest2Mqtt, topic: str, segment: str) -> list[str]: try: return segment.split("-", 1) except ValueError: self.logger.warning(f"Ignoring malformed topic: {topic}") - return (None, None) + return [] - def mqtt_on_subscribe(self: Amcrest2Mqtt, client, userdata, mid, reason_code_list, properties): + def mqtt_on_subscribe(self: Amcrest2Mqtt, client: Client, userdata: Any, mid: int, reason_code_list: list[ReasonCode], properties: Properties) -> None: reason_names = [rc.getName() for rc in reason_code_list] joined = "; ".join(reason_names) if reason_names else "none" self.logger.debug(f"MQTT subscribed (mid={mid}): {joined}") - def mqtt_safe_publish(self: Amcrest2Mqtt, topic, payload, **kwargs): + def mqtt_safe_publish(self: Amcrest2Mqtt, topic: str, payload: str | bool | int | dict, **kwargs: Any) -> None: if not topic: - raise ValueError(f"topic {topic} is empty, why bother") + raise ValueError("Cannot post to a blank topic") if isinstance(payload, dict) and ("component" in payload or "//////" in payload): self.logger.warning("Questionable payload includes 'component' or string of slashes - wont't send to HA") self.logger.warning(f"topic: {topic}") self.logger.warning(f"payload: {payload}") raise ValueError("Possible invalid payload. topic: {topic} payload: {payload}") try: - self.mqttc.publish(topic, payload, **kwargs) + self.mqttc.publish(topic, cast(PayloadType, payload), **kwargs) except Exception as e: - self.logger.warning(f"MQTT publish failed for {topic}: {e}") + self.logger.warning(f"MQTT publish failed for {topic} with {payload[:120] if isinstance(payload, str) else payload}: {e}") diff --git a/src/amcrest2mqtt/mixins/publish.py b/src/amcrest2mqtt/mixins/publish.py new file mode 100644 index 0000000..66291d3 --- /dev/null +++ b/src/amcrest2mqtt/mixins/publish.py @@ -0,0 +1,255 @@ +from datetime import datetime +import json +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt + + +class PublishMixin: + + # Service ------------------------------------------------------------------------------------- + + def publish_service_discovery(self: Amcrest2Mqtt) -> None: + device_block = self.mqtt_helper.device_block( + self.service_name, + self.mqtt_helper.service_slug, + "weirdTangent", + self.config["version"], + ) + + self.logger.info("Publishing service entity") + self.mqtt_safe_publish( + topic=self.mqtt_helper.disc_t("binary_sensor", "service"), + payload=json.dumps( + { + "name": self.service_name, + "uniq_id": self.mqtt_helper.svc_unique_id("service"), + "stat_t": self.mqtt_helper.svc_t("service"), + "device_class": "connectivity", + "icon": "mdi:server", + "device": device_block, + "origin": { + "name": self.service_name, + "sw_version": self.config["version"], + "support_url": "https://github.com/weirdtangent/amcrest2mqtt", + }, + } + ), + qos=self.mqtt_config["qos"], + retain=True, + ) + + self.mqtt_safe_publish( + topic=self.mqtt_helper.disc_t("sensor", "api_calls"), + payload=json.dumps( + { + "name": f"{self.service_name} API Calls Today", + "uniq_id": self.mqtt_helper.svc_unique_id("api_calls"), + "stat_t": self.mqtt_helper.stat_t("service", "service", "api_calls"), + "json_attr_t": self.mqtt_helper.attr_t("service", "service", "api_calls", "attributes"), + "unit_of_measurement": "calls", + "icon": "mdi:api", + "state_class": "total_increasing", + "device": device_block, + } + ), + qos=self.mqtt_config["qos"], + retain=True, + ) + self.mqtt_safe_publish( + topic=self.mqtt_helper.disc_t("binary_sensor", "rate_limited"), + payload=json.dumps( + { + "name": f"{self.service_name} Rate Limited by Amcrest", + "uniq_id": self.mqtt_helper.svc_unique_id("rate_limited"), + "stat_t": self.mqtt_helper.stat_t("service", "service", "rate_limited"), + "json_attr_t": self.mqtt_helper.attr_t("service", "service", "rate_limited", "attributes"), + "payload_on": "YES", + "payload_off": "NO", + "device_class": "problem", + "icon": "mdi:speedometer-slow", + "device": device_block, + } + ), + qos=self.mqtt_config["qos"], + retain=True, + ) + self.mqtt_safe_publish( + topic=self.mqtt_helper.disc_t("number", "storage_refresh"), + payload=json.dumps( + { + "name": f"{self.service_name} Device Refresh Interval", + "uniq_id": self.mqtt_helper.svc_unique_id("storage_refresh"), + "stat_t": self.mqtt_helper.stat_t("service", "service", "storage_refresh"), + "json_attr_t": self.mqtt_helper.attr_t("service", "service", "storage_refresh", "attributes"), + "cmd_t": self.mqtt_helper.cmd_t("service", "storage_refresh"), + "unit_of_measurement": "s", + "min": 1, + "max": 3600, + "step": 1, + "icon": "mdi:timer-refresh", + "device": device_block, + } + ), + qos=self.mqtt_config["qos"], + retain=True, + ) + self.mqtt_safe_publish( + topic=self.mqtt_helper.disc_t("number", "device_list_refresh"), + payload=json.dumps( + { + "name": f"{self.service_name} Device List Refresh Interval", + "uniq_id": self.mqtt_helper.svc_unique_id("device_list_refresh"), + "stat_t": self.mqtt_helper.stat_t("service", "service", "device_list_refresh"), + "json_attr_t": self.mqtt_helper.attr_t("service", "service", "device_list_refresh", "attributes"), + "cmd_t": self.mqtt_helper.cmd_t("service", "device_list_refresh"), + "unit_of_measurement": "s", + "min": 1, + "max": 3600, + "step": 1, + "icon": "mdi:format-list-bulleted", + "device": device_block, + } + ), + qos=self.mqtt_config["qos"], + retain=True, + ) + self.mqtt_safe_publish( + topic=self.mqtt_helper.disc_t("number", "snapshot_refresh"), + payload=json.dumps( + { + "name": f"{self.service_name} Snapshot Refresh Interval", + "uniq_id": self.mqtt_helper.svc_unique_id("snapshot_refresh"), + "stat_t": self.mqtt_helper.stat_t("service", "service", "snapshot_refresh"), + "json_attr_t": self.mqtt_helper.attr_t("service", "service", "snapshot_refresh", "attributes"), + "cmd_t": self.mqtt_helper.cmd_t("service", "snapshot_refresh"), + "unit_of_measurement": "m", + "min": 1, + "max": 60, + "step": 1, + "icon": "mdi:lightning-bolt", + "device": device_block, + } + ), + qos=self.mqtt_config["qos"], + retain=True, + ) + self.mqtt_safe_publish( + topic=self.mqtt_helper.disc_t("button", "refresh_device_list"), + payload=json.dumps( + { + "name": f"{self.service_name} Refresh Device List", + "uniq_id": self.mqtt_helper.svc_unique_id("refresh_device_list"), + "cmd_t": self.mqtt_helper.cmd_t("service", "refresh_device_list", "command"), + "payload_press": "refresh", + "icon": "mdi:refresh", + "device": device_block, + } + ), + qos=self.mqtt_config["qos"], + retain=True, + ) + self.logger.debug(f"[HA] Discovery published for {self.service} ({self.mqtt_helper.service_slug})") + + def publish_service_availability(self: Amcrest2Mqtt, avail: str = "online") -> None: + self.mqtt_safe_publish(self.mqtt_helper.svc_t("status"), avail, qos=self.qos, retain=True) + + def publish_service_state(self: Amcrest2Mqtt) -> None: + service = { + "state": "online", + "api_calls": { + "api_calls": self.get_api_calls(), + "last_api_call": self.get_last_call_date(), + }, + "rate_limited": "YES" if self.is_rate_limited() else "NO", + "storage_refresh": self.device_interval, + "device_list_refresh": self.device_list_interval, + "snapshot_refresh": self.snapshot_update_interval, + } + + payload: Any + for key, value in service.items(): + if isinstance(value, dict): + payload = value.get(key) + if isinstance(payload, datetime): + payload = payload.isoformat() + payload = json.dumps(payload) + else: + payload = str(value) + + self.mqtt_safe_publish( + self.mqtt_helper.stat_t("service", "service", key), + payload, + qos=self.mqtt_config["qos"], + retain=True, + ) + + # Devices ------------------------------------------------------------------------------------- + + def publish_device_discovery(self: Amcrest2Mqtt, device_id: str) -> None: + def _publish_one(dev_id: str, defn: dict, suffix: str = "") -> None: + # Compute a per-mode device_id for topic namespacing + eff_device_id = dev_id if not suffix else f"{dev_id}_{suffix}" + + # Grab this component's discovery topic + topic = self.mqtt_helper.disc_t(defn["component_type"], f"{dev_id}_{suffix}" if suffix else dev_id) + + # Shallow copy to avoid mutating source + payload = {k: v for k, v in defn.items() if k != "component_type"} + + # Publish discovery + self.mqtt_safe_publish(topic, json.dumps(payload), retain=True) + + # Mark discovered in state (per published entity) + self.states.setdefault(eff_device_id, {}).setdefault("internal", {})["discovered"] = 1 + + component = self.get_component(device_id) + _publish_one(device_id, component) + + # Publish any modes (0..n) + modes = self.get_modes(device_id) + for slug, mode in modes.items(): + _publish_one(device_id, mode, suffix=slug) + + def publish_device_availability(self: Amcrest2Mqtt, device_id: str, online: bool = True) -> None: + payload = "online" if online else "offline" + + avty_t = self.get_device_availability_topic(device_id) + self.mqtt_safe_publish(avty_t, payload, retain=True) + + def publish_device_state(self: Amcrest2Mqtt, device_id: str) -> None: + def _publish_one(dev_id: str, defn: str | dict[str, Any], suffix: str = "") -> None: + # Grab this component's state topic + topic = self.get_device_state_topic(dev_id, suffix) + + # Shallow copy to avoid mutating source + if isinstance(defn, dict): + flat: dict[str, Any] = {k: v for k, v in defn.items() if k != "component_type"} + + # Add metadata + meta = self.states[dev_id].get("meta") + if isinstance(meta, dict) and "last_update" in meta: + flat["last_update"] = meta["last_update"] + self.mqtt_safe_publish(topic, json.dumps(flat), retain=True) + else: + self.mqtt_safe_publish(topic, defn, retain=True) + + if not self.is_discovered(device_id): + self.logger.debug(f"[device state] Discovery not complete for {device_id} yet, holding off on sending state") + return + + states = self.states[device_id] + _publish_one(device_id, states[self.get_component_type(device_id)]) + + # Publish any modes (0..n) + modes = self.get_modes(device_id) + for name, mode in modes.items(): + component_type = mode["component_type"] + + # if no state yet, skip it + if component_type not in states or (isinstance(states[component_type], dict) and name not in states[component_type]): + continue + + type_states = states[component_type][name] if isinstance(states[component_type], dict) else states[component_type] + _publish_one(device_id, type_states, name) diff --git a/src/amcrest2mqtt/mixins/refresh.py b/src/amcrest2mqtt/mixins/refresh.py index d637072..3a056eb 100644 --- a/src/amcrest2mqtt/mixins/refresh.py +++ b/src/amcrest2mqtt/mixins/refresh.py @@ -4,20 +4,16 @@ import asyncio from typing import TYPE_CHECKING if TYPE_CHECKING: - from amcrest2mqtt.core import Amcrest2Mqtt - from amcrest2mqtt.interface import AmcrestServiceProtocol + from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt class RefreshMixin: - if TYPE_CHECKING: - self: "AmcrestServiceProtocol" - - async def refresh_all_devices(self: Amcrest2Mqtt): + async def refresh_all_devices(self: Amcrest2Mqtt) -> None: self.logger.info(f"Refreshing all devices from Amcrest (every {self.device_interval} sec)") semaphore = asyncio.Semaphore(5) - async def _refresh(device_id): + async def _refresh(device_id: str) -> None: async with semaphore: await asyncio.to_thread(self.build_device_states, device_id) @@ -25,7 +21,7 @@ class RefreshMixin: for device_id in self.devices: if not self.running: break - if device_id == "service" or device_id in self.boosted: + if device_id == "service": continue tasks.append(_refresh(device_id)) diff --git a/src/amcrest2mqtt/mixins/service.py b/src/amcrest2mqtt/mixins/service.py deleted file mode 100644 index 52f7237..0000000 --- a/src/amcrest2mqtt/mixins/service.py +++ /dev/null @@ -1,185 +0,0 @@ -# SPDX-License-Identifier: MIT -# Copyright (c) 2025 Jeff Culverhouse -from datetime import datetime -import json -from typing import TYPE_CHECKING - -if TYPE_CHECKING: - from amcrest2mqtt.core import Amcrest2Mqtt - from amcrest2mqtt.interface import AmcrestServiceProtocol - - -class ServiceMixin: - if TYPE_CHECKING: - self: "AmcrestServiceProtocol" - - def publish_service_discovery(self: Amcrest2Mqtt): - app = self.get_device_block(self.service_slug, self.service_name) - - self.mqtt_safe_publish( - topic=self.get_discovery_topic("binary_sensor", self.service_slug), - payload=json.dumps( - { - "name": self.service_name, - "uniq_id": self.service_slug, - "stat_t": self.get_service_topic("status"), - "payload_on": "online", - "payload_off": "offline", - "device_class": "connectivity", - "icon": "mdi:server", - "device": app, - "origin": { - "name": self.service_name, - "sw_version": self.config["version"], - "support_url": "https://github.com/weirdtangent/amcrest2mqtt", - }, - } - ), - qos=self.mqtt_config["qos"], - retain=True, - ) - - self.mqtt_safe_publish( - topic=self.get_discovery_topic("sensor", f"{self.service_slug}_api_calls"), - payload=json.dumps( - { - "name": f"{self.service_name} API Calls Today", - "uniq_id": f"{self.service_slug}_api_calls", - "stat_t": self.get_state_topic("service", "service", "api_calls"), - "json_attr_t": self.get_attribute_topic("service", "service", "api_calls", "attributes"), - "unit_of_measurement": "calls", - "icon": "mdi:api", - "state_class": "total_increasing", - "device": app, - } - ), - qos=self.mqtt_config["qos"], - retain=True, - ) - self.mqtt_safe_publish( - topic=self.get_discovery_topic("binary_sensor", f"{self.service_slug}_rate_limited"), - payload=json.dumps( - { - "name": f"{self.service_name} Rate Limited by Amcrest", - "uniq_id": f"{self.service_slug}_rate_limited", - "stat_t": self.get_state_topic("service", "service", "rate_limited"), - "json_attr_t": self.get_attribute_topic("service", "service", "rate_limited", "attributes"), - "payload_on": "yes", - "payload_off": "no", - "device_class": "problem", - "icon": "mdi:speedometer-slow", - "device": app, - } - ), - qos=self.mqtt_config["qos"], - retain=True, - ) - self.mqtt_safe_publish( - topic=self.get_discovery_topic("number", f"{self.service_slug}_storage_refresh"), - payload=json.dumps( - { - "name": f"{self.service_name} Device Refresh Interval", - "uniq_id": f"{self.service_slug}_storage_refresh", - "stat_t": self.get_state_topic("service", "service", "storage_refresh"), - "json_attr_t": self.get_attribute_topic("service", "service", "storage_refresh", "attributes"), - "cmd_t": self.get_command_topic("service", "storage_refresh"), - "unit_of_measurement": "s", - "min": 1, - "max": 3600, - "step": 1, - "icon": "mdi:timer-refresh", - "device": app, - } - ), - qos=self.mqtt_config["qos"], - retain=True, - ) - self.mqtt_safe_publish( - topic=self.get_discovery_topic("number", f"{self.service_slug}_device_list_refresh"), - payload=json.dumps( - { - "name": f"{self.service_name} Device List Refresh Interval", - "uniq_id": f"{self.service_slug}_device_list_refresh", - "stat_t": self.get_state_topic("service", "service", "device_list_refresh"), - "json_attr_t": self.get_attribute_topic("service", "service", "device_list_refresh", "attributes"), - "cmd_t": self.get_command_topic("service", "device_list_refresh"), - "unit_of_measurement": "s", - "min": 1, - "max": 3600, - "step": 1, - "icon": "mdi:format-list-bulleted", - "device": app, - } - ), - qos=self.mqtt_config["qos"], - retain=True, - ) - self.mqtt_safe_publish( - topic=self.get_discovery_topic("number", f"{self.service_slug}_snapshot_refresh"), - payload=json.dumps( - { - "name": f"{self.service_name} Device Boost Refresh Interval", - "uniq_id": f"{self.service_slug}_snapshot_refresh", - "stat_t": self.get_state_topic("service", "service", "snapshot_refresh"), - "json_attr_t": self.get_attribute_topic("service", "service", "snapshot_refresh", "attributes"), - "cmd_t": self.get_command_topic("service", "snapshot_refresh"), - "unit_of_measurement": "s", - "min": 1, - "max": 30, - "step": 1, - "icon": "mdi:lightning-bolt", - "device": app, - } - ), - qos=self.mqtt_config["qos"], - retain=True, - ) - self.mqtt_safe_publish( - topic=self.get_discovery_topic("button", f"{self.service_slug}_refresh_device_list"), - payload=json.dumps( - { - "name": f"{self.service_name} Refresh Device List", - "uniq_id": f"{self.service_slug}_refresh_device_list", - "cmd_t": self.get_command_topic("service", "refresh_device_list", "command"), - "payload_press": "refresh", - "icon": "mdi:refresh", - "device": app, - } - ), - qos=self.mqtt_config["qos"], - retain=True, - ) - self.logger.debug(f"[HA] Discovery published for {self.service} ({self.service_slug})") - - def publish_service_availability(self: Amcrest2Mqtt): - self.mqtt_safe_publish(self.get_service_topic("status"), "online", qos=self.qos, retain=True) - - def publish_service_state(self: Amcrest2Mqtt): - service = { - "state": "online", - "api_calls": { - "api_calls": self.get_api_calls(), - "last_api_call": self.get_last_call_date(), - }, - "rate_limited": "yes" if self.is_rate_limited() else "no", - "storage_refresh": self.device_interval, - "device_list_refresh": self.device_list_interval, - "snapshot_refresh": self.device_boost_interval, - } - - for key, value in service.items(): - # Scalars like "state" -> just publish as is (but as a string) - if not isinstance(value, dict): - payload = str(value) - else: - payload = value.get(key) - if isinstance(payload, datetime): - payload = payload.isoformat() - payload = json.dumps(payload) - - self.mqtt_safe_publish( - self.get_state_topic("service", "service", key), - payload, - qos=self.mqtt_config["qos"], - retain=True, - ) diff --git a/src/amcrest2mqtt/mixins/topics.py b/src/amcrest2mqtt/mixins/topics.py index 902cd02..f2c9b4a 100644 --- a/src/amcrest2mqtt/mixins/topics.py +++ b/src/amcrest2mqtt/mixins/topics.py @@ -1,128 +1,52 @@ # SPDX-License-Identifier: MIT # Copyright (c) 2025 Jeff Culverhouse -import random import re -import string -from typing import TYPE_CHECKING, Optional +from typing import TYPE_CHECKING, cast, Any if TYPE_CHECKING: - from amcrest2mqtt.core import Amcrest2Mqtt - from amcrest2mqtt.interface import AmcrestServiceProtocol + from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt class TopicsMixin: - if TYPE_CHECKING: - self: "AmcrestServiceProtocol" - def get_new_client_id(self: Amcrest2Mqtt): - return self.mqtt_config["prefix"] + "-" + "".join(random.choices(string.ascii_lowercase + string.digits, k=8)) + # Device properties -------------------------------------------------------------------------- - # Slug strings -------------------------------------------------------------------------------- - - def get_device_slug(self: Amcrest2Mqtt, device_id: str, type: Optional[str] = None) -> str: - return "_".join(filter(None, [self.service_slug, device_id.replace(":", ""), type])) + def get_device_name(self: Amcrest2Mqtt, device_id: str) -> str: + return cast(str, self.devices[device_id]["component"]["device"]["name"]) def get_device_name_slug(self: Amcrest2Mqtt, device_id: str) -> str: - return re.sub(r"[^a-zA-Z0-9]+", "_", self.get_device_name(device_id)) - - def get_vendor_device_slug(self: Amcrest2Mqtt, device_id): - return f"{self.service_slug}-{device_id.replace(':', '')}" - - # Topic strings ------------------------------------------------------------------------------- - - def get_service_device(self: Amcrest2Mqtt): - return self.service - - def get_service_topic(self: Amcrest2Mqtt, topic): - return f"{self.service_slug}/status/{topic}" - - def get_device_topic(self: Amcrest2Mqtt, component_type, device_id, *parts) -> str: - if device_id == "service": - return "/".join([self.service_slug, *map(str, parts)]) - - device_slug = self.get_device_slug(device_id) - return "/".join([self.service_slug, component_type, device_slug, *map(str, parts)]) - - def get_discovery_topic(self: Amcrest2Mqtt, component, item) -> str: - return f"{self.mqtt_config['discovery_prefix']}/{component}/{item}/config" - - def get_state_topic(self: Amcrest2Mqtt, device_id, category, item=None) -> str: - topic = f"{self.service_slug}/{category}" if device_id == "service" else f"{self.service_slug}/devices/{self.get_device_slug(device_id)}/{category}" - return f"{topic}/{item}" if item else topic - - def get_availability_topic(self: Amcrest2Mqtt, device_id, category="availability", item=None) -> str: - topic = f"{self.service_slug}/{category}" if device_id == "service" else f"{self.service_slug}/devices/{self.get_device_slug(device_id)}/{category}" - return f"{topic}/{item}" if item else topic - - def get_attribute_topic(self: Amcrest2Mqtt, device_id, category, item, attribute) -> str: - if device_id == "service": - return f"{self.service_slug}/{category}/{item}/{attribute}" + return re.sub(r"[^a-zA-Z0-9]+", "_", self.get_device_name(device_id).lower()) - device_entry = self.devices.get(device_id, {}) - component = device_entry.get("component") or device_entry.get("component_type") or category - return f"{self.mqtt_config['discovery_prefix']}/{component}/{self.get_device_slug(device_id)}/{item}/{attribute}" + def get_component(self: Amcrest2Mqtt, device_id: str) -> dict[str, Any]: + return cast(dict[str, Any], self.devices[device_id]["component"]) - def get_command_topic(self: Amcrest2Mqtt, device_id, category, item=None, command="set") -> str: - if device_id == "service": - return f"{self.service_slug}/service/{category}/{item}" + def get_component_type(self: Amcrest2Mqtt, device_id: str) -> str: + return cast(str, self.devices[device_id]["component"].get("component_type", "unknown")) - # if category is not passed in, device must exist already - if not category: - category = self.devices[device_id]["component"]["component_type"] + def get_modes(self: Amcrest2Mqtt, device_id: str) -> dict[str, Any]: + return cast(dict[str, Any], self.devices[device_id]["modes"]) - return f"{self.service_slug}/{category}/{self.get_device_slug(device_id)}/{item}/{command}" + def get_mode(self: Amcrest2Mqtt, device_id: str, mode_name: str) -> dict[str, Any]: + return cast(dict[str, Any], self.devices[device_id]["modes"][mode_name]) - # Device propertiesi -------------------------------------------------------------------------- + def is_discovered(self: Amcrest2Mqtt, device_id: str) -> bool: + return cast(bool, self.states[device_id]["internal"].get("discovered", False)) - def get_device_name(self: Amcrest2Mqtt, device_id): - return self.devices[device_id]["component"]["name"] - - def get_component(self: Amcrest2Mqtt, device_id): - return self.devices[device_id]["component"] - - def get_component_type(self: Amcrest2Mqtt, device_id): - return self.devices[device_id]["component"]["component_type"] - - def get_modes(self: "Amcrest2Mqtt", device_id): - return self.devices[device_id].get("modes", {}) - - def get_mode(self: "Amcrest2Mqtt", device_id, mode_name): - modes = self.devices[device_id].get("modes", {}) - return modes.get(mode_name, {}) - - def get_last_update(self: "Amcrest2Mqtt", device_id: str) -> str: - return self.states[device_id]["internal"].get("last_update", None) - - def is_discovered(self: "Amcrest2Mqtt", device_id: str) -> bool: - return self.states[device_id]["internal"].get("discovered", False) - - def get_device_state_topic(self: "Amcrest2Mqtt", device_id, mode_name=None): + def get_device_state_topic(self: Amcrest2Mqtt, device_id: str, mode_name: str = "") -> str: component = self.get_mode(device_id, mode_name) if mode_name else self.get_component(device_id) - component_type = component["component_type"] - if component_type in ["camera", "image"]: - return component.get("topic", None) - else: - return component.get("stat_t", component.get("state_topic", None)) + match component["component_type"]: + case "camera": + return cast(str, component["topic"]) + case "image": + return cast(str, component["image_topic"]) + case _: + return cast(str, component.get("stat_t") or component.get("state_topic")) - def get_device_availability_topic(self: Amcrest2Mqtt, device_id): + def get_device_image_topic(self: Amcrest2Mqtt, device_id: str) -> str: component = self.get_component(device_id) - return component.get("avty_t", component.get("availability_topic", None)) - - # Misc helpers -------------------------------------------------------------------------------- - - def get_device_block(self: Amcrest2Mqtt, id, name, vendor="Amcrest", sku=None): - device = {"name": name, "identifiers": [id], "manufacturer": vendor} + return cast(str, component["topic"]) - if sku: - device["model"] = sku - - if name == self.service_name: - device.update( - { - "suggested_area": "House", - "manufacturer": "weirdTangent", - "sw_version": self.config["version"], - } - ) - return device + def get_device_availability_topic(self: Amcrest2Mqtt, device_id: str) -> str: + component = self.get_component(device_id) + return cast(str, component.get("avty_t") or component.get("availability_topic")) diff --git a/src/interface.py b/src/interface.py deleted file mode 100644 index 6986dc2..0000000 --- a/src/interface.py +++ /dev/null @@ -1,89 +0,0 @@ -import argparse -from amcrest import AmcrestCamera -from typing import Protocol, Optional, Any -from amcrest2mqtt.core import Amcrest2Mqtt - -# grep -ERh --exclude interface.py 'def\s+[^_]' src/ | sed -E "s/^[[:space:]]+//g" | awk '{ print " ", $0, "..." }' | sort - - -class AmcrestServiceProtocol(Protocol): - """Common interface so mixins can type-hint against the full service.""" - - async def build_camera(self: Amcrest2Mqtt, device: str) -> str: ... - async def build_component(self: Amcrest2Mqtt, device: dict) -> str: ... - async def check_event_queue_loop(self: Amcrest2Mqtt): ... - async def check_for_events(self: Amcrest2Mqtt) -> None: ... - async def collect_all_device_events(self: Amcrest2Mqtt) -> None: ... - async def collect_all_device_snapshots(self: Amcrest2Mqtt) -> None: ... - async def collect_events_loop(self: Amcrest2Mqtt): ... - async def collect_snapshots_loop(self: Amcrest2Mqtt): ... - async def connect_to_devices(self: Amcrest2Mqtt) -> dict[str, Any]: ... - async def device_loop(self: Amcrest2Mqtt): ... - async def get_events_from_device(self, device_id: str) -> None: ... - async def get_snapshot_from_device(self, device_id: str) -> str | None: ... - async def main_loop(self: Amcrest2Mqtt): ... - async def process_device_event(self, device_id: str, code: str, payload: Any): ... - async def refresh_all_devices(self: Amcrest2Mqtt): ... - async def setup_device_list(self: Amcrest2Mqtt) -> None: ... - def build_device_states(self: Amcrest2Mqtt, device_id: str) -> None: ... - def build_parser() -> argparse.ArgumentParser: ... - def classify_device(self: Amcrest2Mqtt, device: str) -> str: ... - def get_api_calls(self: Amcrest2Mqtt): ... - def get_attribute_topic(self: Amcrest2Mqtt, device_id, category, item, attribute) -> str: ... - def get_availability_topic(self: Amcrest2Mqtt, device_id, category="availability", item=None) -> str: ... - def get_camera(self, host: str) -> AmcrestCamera: ... - def get_command_topic(self: Amcrest2Mqtt, device_id, category, item=None, command="set") -> str: ... - def get_component_type(self: Amcrest2Mqtt, device_id): ... - def get_component(self: Amcrest2Mqtt, device_id): ... - def get_device_availability_topic(self: Amcrest2Mqtt, device_id): ... - def get_device_block(self: Amcrest2Mqtt, id, name, vendor="Amcrest", sku=None): ... - def get_device_name(self: Amcrest2Mqtt, device_id): ... - def get_device_slug(self: Amcrest2Mqtt, device_id: str, type: Optional[str] = None) -> str: ... - def get_device_state_topic(self: "Amcrest2Mqtt", device_id, mode_name=None): ... - def get_device_topic(self: Amcrest2Mqtt, component_type, device_id, *parts) -> str: ... - def get_device(self, host: str, device_name: str) -> None: ... - def get_discovery_topic(self: Amcrest2Mqtt, component, item) -> str: ... - def get_ip_address(self: Amcrest2Mqtt, string: str) -> str: ... - def get_last_call_date(self: Amcrest2Mqtt): ... - def get_last_update(self: "Amcrest2Mqtt", device_id: str) -> str: ... - def get_mode(self: "Amcrest2Mqtt", device_id, mode_name): ... - def get_modes(self: "Amcrest2Mqtt", device_id): ... - def get_motion_detection(self, device_id: str) -> bool: ... - def get_new_client_id(self: Amcrest2Mqtt): ... - def get_next_event(self: Amcrest2Mqtt) -> str | None: ... - def get_privacy_mode(self, device_id: str) -> bool: ... - def get_recorded_file(self, device_id: str, file: str) -> str | None: ... - def get_service_device(self: Amcrest2Mqtt): ... - def get_service_topic(self: Amcrest2Mqtt, topic): ... - def get_snapshot(self, device_id: str) -> str | None: ... - def get_state_topic(self: Amcrest2Mqtt, device_id, category, item=None) -> str: ... - def get_storage_stats(self, device_id: str) -> dict[str, str]: ... - def get_vendor_device_slug(self: Amcrest2Mqtt, device_id): ... - def handle_device_command(self: Amcrest2Mqtt, device_id: str, handler: str, message: str) -> None: ... - def handle_service_command(self: Amcrest2Mqtt, handler: str, message: str) -> None: ... - def is_discovered(self: "Amcrest2Mqtt", device_id: str) -> bool: ... - def is_ipv4(self: Amcrest2Mqtt, string: str) -> bool: ... - def is_rate_limited(self: Amcrest2Mqtt): ... - def load_config(self: Amcrest2Mqtt, config_arg: str = None, media_arg: str = None) -> list[str, Any]: ... - def main(argv=None): ... - def mqtt_on_connect(self: Amcrest2Mqtt, client, userdata, flags, reason_code, properties): ... - def mqtt_on_disconnect(self: Amcrest2Mqtt, client, userdata, flags, reason_code, properties): ... - def mqtt_on_log(self: Amcrest2Mqtt, client, userdata, paho_log_level, msg): ... - def mqtt_on_message(self: Amcrest2Mqtt, client, userdata, msg): ... - def mqtt_on_subscribe(self: Amcrest2Mqtt, client, userdata, mid, reason_code_list, properties): ... - def mqtt_safe_publish(self: Amcrest2Mqtt, topic, payload, **kwargs): ... - def mqttc_create(self: Amcrest2Mqtt): ... - def publish_device_availability(self: Amcrest2Mqtt, device_id, online: bool = True): ... - def publish_device_discovery(self: Amcrest2Mqtt, device_id: str) -> None: ... - def publish_device_state(self: Amcrest2Mqtt, device_id: str) -> None: ... - def publish_service_availability(self: Amcrest2Mqtt): ... - def publish_service_discovery(self: Amcrest2Mqtt): ... - def publish_service_state(self: Amcrest2Mqtt): ... - def read_file(self: Amcrest2Mqtt, file_name: str) -> str: ... - def rediscover_all(self: Amcrest2Mqtt): ... - def safe_split_device(self: Amcrest2Mqtt, topic, segment): ... - def set_motion_detection(self, device_id: str, switch: bool) -> str: ... - def set_privacy_mode(self, device_id: str, switch: bool) -> str: ... - def to_gb(self: Amcrest2Mqtt, total: [int]) -> str: ... - def upsert_device(self: Amcrest2Mqtt, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> None: ... - def upsert_state(self: Amcrest2Mqtt, device_id, **kwargs: dict[str, Any] | str | int | bool) -> None: ... diff --git a/uv.lock b/uv.lock index 13edb59..a34b9e0 100644 --- a/uv.lock +++ b/uv.lock @@ -1,6 +1,6 @@ version = 1 revision = 3 -requires-python = ">=3.13" +requires-python = ">=3.14" [[package]] name = "amcrest" @@ -25,6 +25,7 @@ dependencies = [ { name = "amcrest" }, { name = "deepmerge" }, { name = "json-logging-graystorm" }, + { name = "mqtt-helper-graystorm" }, { name = "paho-mqtt" }, { name = "pathlib" }, { name = "pyyaml" }, @@ -46,8 +47,10 @@ dev = [ [package.dev-dependencies] dev = [ { name = "black" }, + { name = "mypy" }, { name = "pytest" }, { name = "ruff" }, + { name = "types-pyyaml" }, ] [package.metadata] @@ -56,8 +59,9 @@ requires-dist = [ { name = "attrs", marker = "extra == 'dev'", specifier = ">=25.4.0" }, { name = "black", marker = "extra == 'dev'", specifier = ">=24.10.0" }, { name = "deepmerge", specifier = "==2.0" }, - { name = "json-logging-graystorm", url = "https://github.com/weirdtangent/json_logging/archive/refs/tags/0.1.3.tar.gz" }, + { name = "json-logging-graystorm", git = "https://github.com/weirdtangent/json_logging.git?branch=main" }, { name = "jsonschema", marker = "extra == 'dev'", specifier = ">=4.25.1" }, + { name = "mqtt-helper-graystorm", git = "https://github.com/weirdtangent/mqtt-helper.git?branch=main" }, { name = "packaging", marker = "extra == 'dev'", specifier = ">=25.0" }, { name = "paho-mqtt", specifier = ">=2.1.0" }, { name = "pathlib", specifier = ">=1.0.1" }, @@ -73,8 +77,10 @@ provides-extras = ["dev"] [package.metadata.requires-dev] dev = [ { name = "black", specifier = ">=25.9.0" }, + { name = "mypy", specifier = ">=1.18.2" }, { name = "pytest", specifier = ">=8.4.2" }, { name = "ruff", specifier = ">=0.14.1" }, + { name = "types-pyyaml", specifier = ">=6.0.12.20250915" }, ] [[package]] @@ -122,10 +128,6 @@ dependencies = [ ] sdist = { url = "https://files.pythonhosted.org/packages/4b/43/20b5c90612d7bdb2bdbcceeb53d588acca3bb8f0e4c5d5c751a2c8fdd55a/black-25.9.0.tar.gz", hash = "sha256:0474bca9a0dd1b51791fcc507a4e02078a1c63f6d4e4ae5544b9848c7adfb619", size = 648393, upload-time = "2025-09-19T00:27:37.758Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/99/3acfea65f5e79f45472c45f87ec13037b506522719cd9d4ac86484ff51ac/black-25.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0172a012f725b792c358d57fe7b6b6e8e67375dd157f64fa7a3097b3ed3e2175", size = 1742165, upload-time = "2025-09-19T00:34:10.402Z" }, - { url = "https://files.pythonhosted.org/packages/3a/18/799285282c8236a79f25d590f0222dbd6850e14b060dfaa3e720241fd772/black-25.9.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:3bec74ee60f8dfef564b573a96b8930f7b6a538e846123d5ad77ba14a8d7a64f", size = 1581259, upload-time = "2025-09-19T00:32:49.685Z" }, - { url = "https://files.pythonhosted.org/packages/f1/ce/883ec4b6303acdeca93ee06b7622f1fa383c6b3765294824165d49b1a86b/black-25.9.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b756fc75871cb1bcac5499552d771822fd9db5a2bb8db2a7247936ca48f39831", size = 1655583, upload-time = "2025-09-19T00:30:44.505Z" }, - { url = "https://files.pythonhosted.org/packages/21/17/5c253aa80a0639ccc427a5c7144534b661505ae2b5a10b77ebe13fa25334/black-25.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:846d58e3ce7879ec1ffe816bb9df6d006cd9590515ed5d17db14e17666b2b357", size = 1343428, upload-time = "2025-09-19T00:32:13.839Z" }, { url = "https://files.pythonhosted.org/packages/1b/46/863c90dcd3f9d41b109b7f19032ae0db021f0b2a81482ba0a1e28c84de86/black-25.9.0-py3-none-any.whl", hash = "sha256:474b34c1342cdc157d307b56c4c65bce916480c4a8f6551fdc6bf9b486a7c4ae", size = 203363, upload-time = "2025-09-19T00:27:35.724Z" }, ] @@ -144,22 +146,6 @@ version = "3.4.4" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/13/69/33ddede1939fdd074bce5434295f38fae7136463422fe4fd3e0e89b98062/charset_normalizer-3.4.4.tar.gz", hash = "sha256:94537985111c35f28720e43603b8e7b43a6ecfb2ce1d3058bbe955b73404e21a", size = 129418, upload-time = "2025-10-14T04:42:32.879Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/97/45/4b3a1239bbacd321068ea6e7ac28875b03ab8bc0aa0966452db17cd36714/charset_normalizer-3.4.4-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:e1f185f86a6f3403aa2420e815904c67b2f9ebc443f045edd0de921108345794", size = 208091, upload-time = "2025-10-14T04:41:13.346Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/73a6d7450829655a35bb88a88fca7d736f9882a27eacdca2c6d505b57e2e/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b39f987ae8ccdf0d2642338faf2abb1862340facc796048b604ef14919e55ed", size = 147936, upload-time = "2025-10-14T04:41:14.461Z" }, - { url = "https://files.pythonhosted.org/packages/89/c5/adb8c8b3d6625bef6d88b251bbb0d95f8205831b987631ab0c8bb5d937c2/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3162d5d8ce1bb98dd51af660f2121c55d0fa541b46dff7bb9b9f86ea1d87de72", size = 144180, upload-time = "2025-10-14T04:41:15.588Z" }, - { url = "https://files.pythonhosted.org/packages/91/ed/9706e4070682d1cc219050b6048bfd293ccf67b3d4f5a4f39207453d4b99/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:81d5eb2a312700f4ecaa977a8235b634ce853200e828fbadf3a9c50bab278328", size = 161346, upload-time = "2025-10-14T04:41:16.738Z" }, - { url = "https://files.pythonhosted.org/packages/d5/0d/031f0d95e4972901a2f6f09ef055751805ff541511dc1252ba3ca1f80cf5/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5bd2293095d766545ec1a8f612559f6b40abc0eb18bb2f5d1171872d34036ede", size = 158874, upload-time = "2025-10-14T04:41:17.923Z" }, - { url = "https://files.pythonhosted.org/packages/f5/83/6ab5883f57c9c801ce5e5677242328aa45592be8a00644310a008d04f922/charset_normalizer-3.4.4-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8a8b89589086a25749f471e6a900d3f662d1d3b6e2e59dcecf787b1cc3a1894", size = 153076, upload-time = "2025-10-14T04:41:19.106Z" }, - { url = "https://files.pythonhosted.org/packages/75/1e/5ff781ddf5260e387d6419959ee89ef13878229732732ee73cdae01800f2/charset_normalizer-3.4.4-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc7637e2f80d8530ee4a78e878bce464f70087ce73cf7c1caf142416923b98f1", size = 150601, upload-time = "2025-10-14T04:41:20.245Z" }, - { url = "https://files.pythonhosted.org/packages/d7/57/71be810965493d3510a6ca79b90c19e48696fb1ff964da319334b12677f0/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f8bf04158c6b607d747e93949aa60618b61312fe647a6369f88ce2ff16043490", size = 150376, upload-time = "2025-10-14T04:41:21.398Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d5/c3d057a78c181d007014feb7e9f2e65905a6c4ef182c0ddf0de2924edd65/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:554af85e960429cf30784dd47447d5125aaa3b99a6f0683589dbd27e2f45da44", size = 144825, upload-time = "2025-10-14T04:41:22.583Z" }, - { url = "https://files.pythonhosted.org/packages/e6/8c/d0406294828d4976f275ffbe66f00266c4b3136b7506941d87c00cab5272/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:74018750915ee7ad843a774364e13a3db91682f26142baddf775342c3f5b1133", size = 162583, upload-time = "2025-10-14T04:41:23.754Z" }, - { url = "https://files.pythonhosted.org/packages/d7/24/e2aa1f18c8f15c4c0e932d9287b8609dd30ad56dbe41d926bd846e22fb8d/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c0463276121fdee9c49b98908b3a89c39be45d86d1dbaa22957e38f6321d4ce3", size = 150366, upload-time = "2025-10-14T04:41:25.27Z" }, - { url = "https://files.pythonhosted.org/packages/e4/5b/1e6160c7739aad1e2df054300cc618b06bf784a7a164b0f238360721ab86/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:362d61fd13843997c1c446760ef36f240cf81d3ebf74ac62652aebaf7838561e", size = 160300, upload-time = "2025-10-14T04:41:26.725Z" }, - { url = "https://files.pythonhosted.org/packages/7a/10/f882167cd207fbdd743e55534d5d9620e095089d176d55cb22d5322f2afd/charset_normalizer-3.4.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9a26f18905b8dd5d685d6d07b0cdf98a79f3c7a918906af7cc143ea2e164c8bc", size = 154465, upload-time = "2025-10-14T04:41:28.322Z" }, - { url = "https://files.pythonhosted.org/packages/89/66/c7a9e1b7429be72123441bfdbaf2bc13faab3f90b933f664db506dea5915/charset_normalizer-3.4.4-cp313-cp313-win32.whl", hash = "sha256:9b35f4c90079ff2e2edc5b26c0c77925e5d2d255c42c74fdb70fb49b172726ac", size = 99404, upload-time = "2025-10-14T04:41:29.95Z" }, - { url = "https://files.pythonhosted.org/packages/c4/26/b9924fa27db384bdcd97ab83b4f0a8058d96ad9626ead570674d5e737d90/charset_normalizer-3.4.4-cp313-cp313-win_amd64.whl", hash = "sha256:b435cba5f4f750aa6c0a0d92c541fb79f69a387c91e61f1795227e4ed9cece14", size = 107092, upload-time = "2025-10-14T04:41:31.188Z" }, - { url = "https://files.pythonhosted.org/packages/af/8f/3ed4bfa0c0c72a7ca17f0380cd9e4dd842b09f664e780c13cff1dcf2ef1b/charset_normalizer-3.4.4-cp313-cp313-win_arm64.whl", hash = "sha256:542d2cee80be6f80247095cc36c418f7bddd14f4a6de45af91dfad36d817bba2", size = 100408, upload-time = "2025-10-14T04:41:32.624Z" }, { url = "https://files.pythonhosted.org/packages/2a/35/7051599bd493e62411d6ede36fd5af83a38f37c4767b92884df7301db25d/charset_normalizer-3.4.4-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:da3326d9e65ef63a817ecbcc0df6e94463713b754fe293eaa03da99befb9a5bd", size = 207746, upload-time = "2025-10-14T04:41:33.773Z" }, { url = "https://files.pythonhosted.org/packages/10/9a/97c8d48ef10d6cd4fcead2415523221624bf58bcf68a802721a6bc807c8f/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8af65f14dc14a79b924524b1e7fffe304517b2bff5a58bf64f30b98bbc5079eb", size = 147889, upload-time = "2025-10-14T04:41:34.897Z" }, { url = "https://files.pythonhosted.org/packages/10/bf/979224a919a1b606c82bd2c5fa49b5c6d5727aa47b4312bb27b1734f53cd/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:74664978bb272435107de04e36db5a9735e78232b85b77d45cfb38f758efd33e", size = 143641, upload-time = "2025-10-14T04:41:36.116Z" }, @@ -206,32 +192,6 @@ version = "7.11.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/1c/38/ee22495420457259d2f3390309505ea98f98a5eed40901cf62196abad006/coverage-7.11.0.tar.gz", hash = "sha256:167bd504ac1ca2af7ff3b81d245dfea0292c5032ebef9d66cc08a7d28c1b8050", size = 811905, upload-time = "2025-10-15T15:15:08.542Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/60/7f/85e4dfe65e400645464b25c036a26ac226cf3a69d4a50c3934c532491cdd/coverage-7.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:cc3f49e65ea6e0d5d9bd60368684fe52a704d46f9e7fc413918f18d046ec40e1", size = 216129, upload-time = "2025-10-15T15:13:25.371Z" }, - { url = "https://files.pythonhosted.org/packages/96/5d/dc5fa98fea3c175caf9d360649cb1aa3715e391ab00dc78c4c66fabd7356/coverage-7.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f39ae2f63f37472c17b4990f794035c9890418b1b8cca75c01193f3c8d3e01be", size = 216380, upload-time = "2025-10-15T15:13:26.976Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f5/3da9cc9596708273385189289c0e4d8197d37a386bdf17619013554b3447/coverage-7.11.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7db53b5cdd2917b6eaadd0b1251cf4e7d96f4a8d24e174bdbdf2f65b5ea7994d", size = 247375, upload-time = "2025-10-15T15:13:28.923Z" }, - { url = "https://files.pythonhosted.org/packages/65/6c/f7f59c342359a235559d2bc76b0c73cfc4bac7d61bb0df210965cb1ecffd/coverage-7.11.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:10ad04ac3a122048688387828b4537bc9cf60c0bf4869c1e9989c46e45690b82", size = 249978, upload-time = "2025-10-15T15:13:30.525Z" }, - { url = "https://files.pythonhosted.org/packages/e7/8c/042dede2e23525e863bf1ccd2b92689692a148d8b5fd37c37899ba882645/coverage-7.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4036cc9c7983a2b1f2556d574d2eb2154ac6ed55114761685657e38782b23f52", size = 251253, upload-time = "2025-10-15T15:13:32.174Z" }, - { url = "https://files.pythonhosted.org/packages/7b/a9/3c58df67bfa809a7bddd786356d9c5283e45d693edb5f3f55d0986dd905a/coverage-7.11.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ab934dd13b1c5e94b692b1e01bd87e4488cb746e3a50f798cb9464fd128374b", size = 247591, upload-time = "2025-10-15T15:13:34.147Z" }, - { url = "https://files.pythonhosted.org/packages/26/5b/c7f32efd862ee0477a18c41e4761305de6ddd2d49cdeda0c1116227570fd/coverage-7.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59a6e5a265f7cfc05f76e3bb53eca2e0dfe90f05e07e849930fecd6abb8f40b4", size = 249411, upload-time = "2025-10-15T15:13:38.425Z" }, - { url = "https://files.pythonhosted.org/packages/76/b5/78cb4f1e86c1611431c990423ec0768122905b03837e1b4c6a6f388a858b/coverage-7.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:df01d6c4c81e15a7c88337b795bb7595a8596e92310266b5072c7e301168efbd", size = 247303, upload-time = "2025-10-15T15:13:40.464Z" }, - { url = "https://files.pythonhosted.org/packages/87/c9/23c753a8641a330f45f221286e707c427e46d0ffd1719b080cedc984ec40/coverage-7.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:8c934bd088eed6174210942761e38ee81d28c46de0132ebb1801dbe36a390dcc", size = 247157, upload-time = "2025-10-15T15:13:42.087Z" }, - { url = "https://files.pythonhosted.org/packages/c5/42/6e0cc71dc8a464486e944a4fa0d85bdec031cc2969e98ed41532a98336b9/coverage-7.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5a03eaf7ec24078ad64a07f02e30060aaf22b91dedf31a6b24d0d98d2bba7f48", size = 248921, upload-time = "2025-10-15T15:13:43.715Z" }, - { url = "https://files.pythonhosted.org/packages/e8/1c/743c2ef665e6858cccb0f84377dfe3a4c25add51e8c7ef19249be92465b6/coverage-7.11.0-cp313-cp313-win32.whl", hash = "sha256:695340f698a5f56f795b2836abe6fb576e7c53d48cd155ad2f80fd24bc63a040", size = 218526, upload-time = "2025-10-15T15:13:45.336Z" }, - { url = "https://files.pythonhosted.org/packages/ff/d5/226daadfd1bf8ddbccefbd3aa3547d7b960fb48e1bdac124e2dd13a2b71a/coverage-7.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:2727d47fce3ee2bac648528e41455d1b0c46395a087a229deac75e9f88ba5a05", size = 219317, upload-time = "2025-10-15T15:13:47.401Z" }, - { url = "https://files.pythonhosted.org/packages/97/54/47db81dcbe571a48a298f206183ba8a7ba79200a37cd0d9f4788fcd2af4a/coverage-7.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:0efa742f431529699712b92ecdf22de8ff198df41e43aeaaadf69973eb93f17a", size = 217948, upload-time = "2025-10-15T15:13:49.096Z" }, - { url = "https://files.pythonhosted.org/packages/e5/8b/cb68425420154e7e2a82fd779a8cc01549b6fa83c2ad3679cd6c088ebd07/coverage-7.11.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:587c38849b853b157706407e9ebdca8fd12f45869edb56defbef2daa5fb0812b", size = 216837, upload-time = "2025-10-15T15:13:51.09Z" }, - { url = "https://files.pythonhosted.org/packages/33/55/9d61b5765a025685e14659c8d07037247de6383c0385757544ffe4606475/coverage-7.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b971bdefdd75096163dd4261c74be813c4508477e39ff7b92191dea19f24cd37", size = 217061, upload-time = "2025-10-15T15:13:52.747Z" }, - { url = "https://files.pythonhosted.org/packages/52/85/292459c9186d70dcec6538f06ea251bc968046922497377bf4a1dc9a71de/coverage-7.11.0-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:269bfe913b7d5be12ab13a95f3a76da23cf147be7fa043933320ba5625f0a8de", size = 258398, upload-time = "2025-10-15T15:13:54.45Z" }, - { url = "https://files.pythonhosted.org/packages/1f/e2/46edd73fb8bf51446c41148d81944c54ed224854812b6ca549be25113ee0/coverage-7.11.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:dadbcce51a10c07b7c72b0ce4a25e4b6dcb0c0372846afb8e5b6307a121eb99f", size = 260574, upload-time = "2025-10-15T15:13:56.145Z" }, - { url = "https://files.pythonhosted.org/packages/07/5e/1df469a19007ff82e2ca8fe509822820a31e251f80ee7344c34f6cd2ec43/coverage-7.11.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9ed43fa22c6436f7957df036331f8fe4efa7af132054e1844918866cd228af6c", size = 262797, upload-time = "2025-10-15T15:13:58.635Z" }, - { url = "https://files.pythonhosted.org/packages/f9/50/de216b31a1434b94d9b34a964c09943c6be45069ec704bfc379d8d89a649/coverage-7.11.0-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9516add7256b6713ec08359b7b05aeff8850c98d357784c7205b2e60aa2513fa", size = 257361, upload-time = "2025-10-15T15:14:00.409Z" }, - { url = "https://files.pythonhosted.org/packages/82/1e/3f9f8344a48111e152e0fd495b6fff13cc743e771a6050abf1627a7ba918/coverage-7.11.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:eb92e47c92fcbcdc692f428da67db33337fa213756f7adb6a011f7b5a7a20740", size = 260349, upload-time = "2025-10-15T15:14:02.188Z" }, - { url = "https://files.pythonhosted.org/packages/65/9b/3f52741f9e7d82124272f3070bbe316006a7de1bad1093f88d59bfc6c548/coverage-7.11.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:d06f4fc7acf3cabd6d74941d53329e06bab00a8fe10e4df2714f0b134bfc64ef", size = 258114, upload-time = "2025-10-15T15:14:03.907Z" }, - { url = "https://files.pythonhosted.org/packages/0b/8b/918f0e15f0365d50d3986bbd3338ca01178717ac5678301f3f547b6619e6/coverage-7.11.0-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:6fbcee1a8f056af07ecd344482f711f563a9eb1c2cad192e87df00338ec3cdb0", size = 256723, upload-time = "2025-10-15T15:14:06.324Z" }, - { url = "https://files.pythonhosted.org/packages/44/9e/7776829f82d3cf630878a7965a7d70cc6ca94f22c7d20ec4944f7148cb46/coverage-7.11.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dbbf012be5f32533a490709ad597ad8a8ff80c582a95adc8d62af664e532f9ca", size = 259238, upload-time = "2025-10-15T15:14:08.002Z" }, - { url = "https://files.pythonhosted.org/packages/9a/b8/49cf253e1e7a3bedb85199b201862dd7ca4859f75b6cf25ffa7298aa0760/coverage-7.11.0-cp313-cp313t-win32.whl", hash = "sha256:cee6291bb4fed184f1c2b663606a115c743df98a537c969c3c64b49989da96c2", size = 219180, upload-time = "2025-10-15T15:14:09.786Z" }, - { url = "https://files.pythonhosted.org/packages/ac/e1/1a541703826be7ae2125a0fb7f821af5729d56bb71e946e7b933cc7a89a4/coverage-7.11.0-cp313-cp313t-win_amd64.whl", hash = "sha256:a386c1061bf98e7ea4758e4313c0ab5ecf57af341ef0f43a0bf26c2477b5c268", size = 220241, upload-time = "2025-10-15T15:14:11.471Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d1/5ee0e0a08621140fd418ec4020f595b4d52d7eb429ae6a0c6542b4ba6f14/coverage-7.11.0-cp313-cp313t-win_arm64.whl", hash = "sha256:f9ea02ef40bb83823b2b04964459d281688fe173e20643870bb5d2edf68bc836", size = 218510, upload-time = "2025-10-15T15:14:13.46Z" }, { url = "https://files.pythonhosted.org/packages/f4/06/e923830c1985ce808e40a3fa3eb46c13350b3224b7da59757d37b6ce12b8/coverage-7.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:c770885b28fb399aaf2a65bbd1c12bf6f307ffd112d6a76c5231a94276f0c497", size = 216110, upload-time = "2025-10-15T15:14:15.157Z" }, { url = "https://files.pythonhosted.org/packages/42/82/cdeed03bfead45203fb651ed756dfb5266028f5f939e7f06efac4041dad5/coverage-7.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3d0e2087dba64c86a6b254f43e12d264b636a39e88c5cc0a01a7c71bcfdab7e", size = 216395, upload-time = "2025-10-15T15:14:16.863Z" }, { url = "https://files.pythonhosted.org/packages/fc/ba/e1c80caffc3199aa699813f73ff097bc2df7b31642bdbc7493600a8f1de5/coverage-7.11.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:73feb83bb41c32811973b8565f3705caf01d928d972b72042b44e97c71fd70d1", size = 247433, upload-time = "2025-10-15T15:14:18.589Z" }, @@ -328,8 +288,7 @@ wheels = [ [[package]] name = "json-logging-graystorm" version = "0.1.3" -source = { url = "https://github.com/weirdtangent/json_logging/archive/refs/tags/0.1.3.tar.gz" } -sdist = { hash = "sha256:f9ad04398fafc8eb9693691ddc96b221931126230b655600fa02e00fd17a0fbf" } +source = { git = "https://github.com/weirdtangent/json_logging.git?branch=main#82662d518f271eed752ba34067db286b3723249c" } [[package]] name = "jsonschema" @@ -358,6 +317,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437, upload-time = "2025-09-08T01:34:57.871Z" }, ] +[[package]] +name = "mqtt-helper-graystorm" +version = "0.1.0" +source = { git = "https://github.com/weirdtangent/mqtt-helper.git?branch=main#914f89a54a637e56ef61d496adfb6501f87963b2" } + +[[package]] +name = "mypy" +version = "1.18.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/77/8f0d0001ffad290cef2f7f216f96c814866248a0b92a722365ed54648e7e/mypy-1.18.2.tar.gz", hash = "sha256:06a398102a5f203d7477b2923dda3634c36727fa5c237d8f859ef90c42a9924b", size = 3448846, upload-time = "2025-09-19T00:11:10.519Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/0c/7d5300883da16f0063ae53996358758b2a2df2a09c72a5061fa79a1f5006/mypy-1.18.2-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:62f0e1e988ad41c2a110edde6c398383a889d95b36b3e60bcf155f5164c4fdce", size = 12893775, upload-time = "2025-09-19T00:10:03.814Z" }, + { url = "https://files.pythonhosted.org/packages/50/df/2cffbf25737bdb236f60c973edf62e3e7b4ee1c25b6878629e88e2cde967/mypy-1.18.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:8795a039bab805ff0c1dfdb8cd3344642c2b99b8e439d057aba30850b8d3423d", size = 11936852, upload-time = "2025-09-19T00:10:51.631Z" }, + { url = "https://files.pythonhosted.org/packages/be/50/34059de13dd269227fb4a03be1faee6e2a4b04a2051c82ac0a0b5a773c9a/mypy-1.18.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6ca1e64b24a700ab5ce10133f7ccd956a04715463d30498e64ea8715236f9c9c", size = 12480242, upload-time = "2025-09-19T00:11:07.955Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/040983fad5132d85914c874a2836252bbc57832065548885b5bb5b0d4359/mypy-1.18.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d924eef3795cc89fecf6bedc6ed32b33ac13e8321344f6ddbf8ee89f706c05cb", size = 13326683, upload-time = "2025-09-19T00:09:55.572Z" }, + { url = "https://files.pythonhosted.org/packages/e9/ba/89b2901dd77414dd7a8c8729985832a5735053be15b744c18e4586e506ef/mypy-1.18.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:20c02215a080e3a2be3aa50506c67242df1c151eaba0dcbc1e4e557922a26075", size = 13514749, upload-time = "2025-09-19T00:10:44.827Z" }, + { url = "https://files.pythonhosted.org/packages/25/bc/cc98767cffd6b2928ba680f3e5bc969c4152bf7c2d83f92f5a504b92b0eb/mypy-1.18.2-cp314-cp314-win_amd64.whl", hash = "sha256:749b5f83198f1ca64345603118a6f01a4e99ad4bf9d103ddc5a3200cc4614adf", size = 9982959, upload-time = "2025-09-19T00:10:37.344Z" }, + { url = "https://files.pythonhosted.org/packages/87/e3/be76d87158ebafa0309946c4a73831974d4d6ab4f4ef40c3b53a385a66fd/mypy-1.18.2-py3-none-any.whl", hash = "sha256:22a1748707dd62b58d2ae53562ffc4d7f8bcc727e8ac7cbc69c053ddc874d47e", size = 2352367, upload-time = "2025-09-19T00:10:15.489Z" }, +] + [[package]] name = "mypy-extensions" version = "1.1.0" @@ -487,16 +471,6 @@ version = "6.0.3" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, @@ -551,35 +525,6 @@ version = "0.28.0" source = { registry = "https://pypi.org/simple" } sdist = { url = "https://files.pythonhosted.org/packages/48/dc/95f074d43452b3ef5d06276696ece4b3b5d696e7c9ad7173c54b1390cd70/rpds_py-0.28.0.tar.gz", hash = "sha256:abd4df20485a0983e2ca334a216249b6186d6e3c1627e106651943dbdb791aea", size = 27419, upload-time = "2025-10-22T22:24:29.327Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/03/ce566d92611dfac0085c2f4b048cd53ed7c274a5c05974b882a908d540a2/rpds_py-0.28.0-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:e9e184408a0297086f880556b6168fa927d677716f83d3472ea333b42171ee3b", size = 366235, upload-time = "2025-10-22T22:22:28.397Z" }, - { url = "https://files.pythonhosted.org/packages/00/34/1c61da1b25592b86fd285bd7bd8422f4c9d748a7373b46126f9ae792a004/rpds_py-0.28.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:edd267266a9b0448f33dc465a97cfc5d467594b600fe28e7fa2f36450e03053a", size = 348241, upload-time = "2025-10-22T22:22:30.171Z" }, - { url = "https://files.pythonhosted.org/packages/fc/00/ed1e28616848c61c493a067779633ebf4b569eccaacf9ccbdc0e7cba2b9d/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:85beb8b3f45e4e32f6802fb6cd6b17f615ef6c6a52f265371fb916fae02814aa", size = 378079, upload-time = "2025-10-22T22:22:31.644Z" }, - { url = "https://files.pythonhosted.org/packages/11/b2/ccb30333a16a470091b6e50289adb4d3ec656fd9951ba8c5e3aaa0746a67/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d2412be8d00a1b895f8ad827cc2116455196e20ed994bb704bf138fe91a42724", size = 393151, upload-time = "2025-10-22T22:22:33.453Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d0/73e2217c3ee486d555cb84920597480627d8c0240ff3062005c6cc47773e/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cf128350d384b777da0e68796afdcebc2e9f63f0e9f242217754e647f6d32491", size = 517520, upload-time = "2025-10-22T22:22:34.949Z" }, - { url = "https://files.pythonhosted.org/packages/c4/91/23efe81c700427d0841a4ae7ea23e305654381831e6029499fe80be8a071/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a2036d09b363aa36695d1cc1a97b36865597f4478470b0697b5ee9403f4fe399", size = 408699, upload-time = "2025-10-22T22:22:36.584Z" }, - { url = "https://files.pythonhosted.org/packages/ca/ee/a324d3198da151820a326c1f988caaa4f37fc27955148a76fff7a2d787a9/rpds_py-0.28.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b8e1e9be4fa6305a16be628959188e4fd5cd6f1b0e724d63c6d8b2a8adf74ea6", size = 385720, upload-time = "2025-10-22T22:22:38.014Z" }, - { url = "https://files.pythonhosted.org/packages/19/ad/e68120dc05af8b7cab4a789fccd8cdcf0fe7e6581461038cc5c164cd97d2/rpds_py-0.28.0-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0a403460c9dd91a7f23fc3188de6d8977f1d9603a351d5db6cf20aaea95b538d", size = 401096, upload-time = "2025-10-22T22:22:39.869Z" }, - { url = "https://files.pythonhosted.org/packages/99/90/c1e070620042459d60df6356b666bb1f62198a89d68881816a7ed121595a/rpds_py-0.28.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d7366b6553cdc805abcc512b849a519167db8f5e5c3472010cd1228b224265cb", size = 411465, upload-time = "2025-10-22T22:22:41.395Z" }, - { url = "https://files.pythonhosted.org/packages/68/61/7c195b30d57f1b8d5970f600efee72a4fad79ec829057972e13a0370fd24/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5b43c6a3726efd50f18d8120ec0551241c38785b68952d240c45ea553912ac41", size = 558832, upload-time = "2025-10-22T22:22:42.871Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3d/06f3a718864773f69941d4deccdf18e5e47dd298b4628062f004c10f3b34/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:0cb7203c7bc69d7c1585ebb33a2e6074492d2fc21ad28a7b9d40457ac2a51ab7", size = 583230, upload-time = "2025-10-22T22:22:44.877Z" }, - { url = "https://files.pythonhosted.org/packages/66/df/62fc783781a121e77fee9a21ead0a926f1b652280a33f5956a5e7833ed30/rpds_py-0.28.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a52a5169c664dfb495882adc75c304ae1d50df552fbd68e100fdc719dee4ff9", size = 553268, upload-time = "2025-10-22T22:22:46.441Z" }, - { url = "https://files.pythonhosted.org/packages/84/85/d34366e335140a4837902d3dea89b51f087bd6a63c993ebdff59e93ee61d/rpds_py-0.28.0-cp313-cp313-win32.whl", hash = "sha256:2e42456917b6687215b3e606ab46aa6bca040c77af7df9a08a6dcfe8a4d10ca5", size = 217100, upload-time = "2025-10-22T22:22:48.342Z" }, - { url = "https://files.pythonhosted.org/packages/3c/1c/f25a3f3752ad7601476e3eff395fe075e0f7813fbb9862bd67c82440e880/rpds_py-0.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:e0a0311caedc8069d68fc2bf4c9019b58a2d5ce3cd7cb656c845f1615b577e1e", size = 227759, upload-time = "2025-10-22T22:22:50.219Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d6/5f39b42b99615b5bc2f36ab90423ea404830bdfee1c706820943e9a645eb/rpds_py-0.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:04c1b207ab8b581108801528d59ad80aa83bb170b35b0ddffb29c20e411acdc1", size = 217326, upload-time = "2025-10-22T22:22:51.647Z" }, - { url = "https://files.pythonhosted.org/packages/5c/8b/0c69b72d1cee20a63db534be0df271effe715ef6c744fdf1ff23bb2b0b1c/rpds_py-0.28.0-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:f296ea3054e11fc58ad42e850e8b75c62d9a93a9f981ad04b2e5ae7d2186ff9c", size = 355736, upload-time = "2025-10-22T22:22:53.211Z" }, - { url = "https://files.pythonhosted.org/packages/f7/6d/0c2ee773cfb55c31a8514d2cece856dd299170a49babd50dcffb15ddc749/rpds_py-0.28.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5a7306c19b19005ad98468fcefeb7100b19c79fc23a5f24a12e06d91181193fa", size = 342677, upload-time = "2025-10-22T22:22:54.723Z" }, - { url = "https://files.pythonhosted.org/packages/e2/1c/22513ab25a27ea205144414724743e305e8153e6abe81833b5e678650f5a/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e5d9b86aa501fed9862a443c5c3116f6ead8bc9296185f369277c42542bd646b", size = 371847, upload-time = "2025-10-22T22:22:56.295Z" }, - { url = "https://files.pythonhosted.org/packages/60/07/68e6ccdb4b05115ffe61d31afc94adef1833d3a72f76c9632d4d90d67954/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e5bbc701eff140ba0e872691d573b3d5d30059ea26e5785acba9132d10c8c31d", size = 381800, upload-time = "2025-10-22T22:22:57.808Z" }, - { url = "https://files.pythonhosted.org/packages/73/bf/6d6d15df80781d7f9f368e7c1a00caf764436518c4877fb28b029c4624af/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a5690671cd672a45aa8616d7374fdf334a1b9c04a0cac3c854b1136e92374fe", size = 518827, upload-time = "2025-10-22T22:22:59.826Z" }, - { url = "https://files.pythonhosted.org/packages/7b/d3/2decbb2976cc452cbf12a2b0aaac5f1b9dc5dd9d1f7e2509a3ee00421249/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9f1d92ecea4fa12f978a367c32a5375a1982834649cdb96539dcdc12e609ab1a", size = 399471, upload-time = "2025-10-22T22:23:01.968Z" }, - { url = "https://files.pythonhosted.org/packages/b1/2c/f30892f9e54bd02e5faca3f6a26d6933c51055e67d54818af90abed9748e/rpds_py-0.28.0-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8d252db6b1a78d0a3928b6190156042d54c93660ce4d98290d7b16b5296fb7cc", size = 377578, upload-time = "2025-10-22T22:23:03.52Z" }, - { url = "https://files.pythonhosted.org/packages/f0/5d/3bce97e5534157318f29ac06bf2d279dae2674ec12f7cb9c12739cee64d8/rpds_py-0.28.0-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:d61b355c3275acb825f8777d6c4505f42b5007e357af500939d4a35b19177259", size = 390482, upload-time = "2025-10-22T22:23:05.391Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f0/886bd515ed457b5bd93b166175edb80a0b21a210c10e993392127f1e3931/rpds_py-0.28.0-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:acbe5e8b1026c0c580d0321c8aae4b0a1e1676861d48d6e8c6586625055b606a", size = 402447, upload-time = "2025-10-22T22:23:06.93Z" }, - { url = "https://files.pythonhosted.org/packages/42/b5/71e8777ac55e6af1f4f1c05b47542a1eaa6c33c1cf0d300dca6a1c6e159a/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:8aa23b6f0fc59b85b4c7d89ba2965af274346f738e8d9fc2455763602e62fd5f", size = 552385, upload-time = "2025-10-22T22:23:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/5d/cb/6ca2d70cbda5a8e36605e7788c4aa3bea7c17d71d213465a5a675079b98d/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:7b14b0c680286958817c22d76fcbca4800ddacef6f678f3a7c79a1fe7067fe37", size = 575642, upload-time = "2025-10-22T22:23:10.348Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d4/407ad9960ca7856d7b25c96dcbe019270b5ffdd83a561787bc682c797086/rpds_py-0.28.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:bcf1d210dfee61a6c86551d67ee1031899c0fdbae88b2d44a569995d43797712", size = 544507, upload-time = "2025-10-22T22:23:12.434Z" }, - { url = "https://files.pythonhosted.org/packages/51/31/2f46fe0efcac23fbf5797c6b6b7e1c76f7d60773e525cb65fcbc582ee0f2/rpds_py-0.28.0-cp313-cp313t-win32.whl", hash = "sha256:3aa4dc0fdab4a7029ac63959a3ccf4ed605fee048ba67ce89ca3168da34a1342", size = 205376, upload-time = "2025-10-22T22:23:13.979Z" }, - { url = "https://files.pythonhosted.org/packages/92/e4/15947bda33cbedfc134490a41841ab8870a72a867a03d4969d886f6594a2/rpds_py-0.28.0-cp313-cp313t-win_amd64.whl", hash = "sha256:7b7d9d83c942855e4fdcfa75d4f96f6b9e272d42fffcb72cd4bb2577db2e2907", size = 215907, upload-time = "2025-10-22T22:23:15.5Z" }, { url = "https://files.pythonhosted.org/packages/08/47/ffe8cd7a6a02833b10623bf765fbb57ce977e9a4318ca0e8cf97e9c3d2b3/rpds_py-0.28.0-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:dcdcb890b3ada98a03f9f2bb108489cdc7580176cb73b4f2d789e9a1dac1d472", size = 353830, upload-time = "2025-10-22T22:23:17.03Z" }, { url = "https://files.pythonhosted.org/packages/f9/9f/890f36cbd83a58491d0d91ae0db1702639edb33fb48eeb356f80ecc6b000/rpds_py-0.28.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f274f56a926ba2dc02976ca5b11c32855cbd5925534e57cfe1fda64e04d1add2", size = 341819, upload-time = "2025-10-22T22:23:18.57Z" }, { url = "https://files.pythonhosted.org/packages/09/e3/921eb109f682aa24fb76207698fbbcf9418738f35a40c21652c29053f23d/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fe0438ac4a29a520ea94c8c7f1754cdd8feb1bc490dfda1bfd990072363d527", size = 373127, upload-time = "2025-10-22T22:23:20.216Z" }, @@ -646,6 +591,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" }, ] +[[package]] +name = "types-pyyaml" +version = "6.0.12.20250915" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7e/69/3c51b36d04da19b92f9e815be12753125bd8bc247ba0470a982e6979e71c/types_pyyaml-6.0.12.20250915.tar.gz", hash = "sha256:0f8b54a528c303f0e6f7165687dd33fafa81c807fcac23f632b63aa624ced1d3", size = 17522, upload-time = "2025-09-15T03:01:00.728Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, +] + [[package]] name = "typing-extensions" version = "4.15.0"