feature: move things around, cleaner code

pull/106/head
Jeff Culverhouse 3 months ago
parent 072afbed68
commit 1d52185673

@ -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

3
.gitignore vendored

@ -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

@ -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

@ -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

@ -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

@ -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

@ -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}")

@ -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,

@ -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: ...

@ -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 ""
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_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)

@ -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

@ -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]

@ -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,9 +177,9 @@ 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)),
"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"),
@ -195,14 +197,14 @@ class HelpersMixin:
amcrest = {
"hosts": hosts,
"names": names,
"port": int(amcrest.get("port") or os.getenv("AMCREST_PORT", 80)),
"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(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)),
"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)),
"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

@ -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:

@ -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()
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()
except Exception as e:
self.logger.error(f"Trouble reconnecting to MQTT (retry in 10 s): {e}")
time.sleep(10)
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}")

@ -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)

@ -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))

@ -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,
)

@ -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))
# Slug strings --------------------------------------------------------------------------------
# Device properties --------------------------------------------------------------------------
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"))

@ -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: ...

@ -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"

Loading…
Cancel
Save