refactor: fully async device setup and improved SDK value handling

- Converted device initialization and state building to async coroutines
- Parallelized Amcrest camera connections using asyncio.gather
- Added clean_value() helper for tuple/list/string SDK outputs
- Added fallback for missing DefaultInterface in network config
- Improved error handling, timeouts, and type annotations
- Verified code quality (ruff, black, mypy all passing)
pull/106/head
Jeff Culverhouse 3 months ago
parent 8780d542eb
commit 94a4297665

@ -4,7 +4,6 @@
# permission notice in all copies or substantial portions of the software. # permission notice in all copies or substantial portions of the software.
# #
# The software is provided 'as is', without any warranty. # The software is provided 'as is', without any warranty.
import asyncio import asyncio
import argparse import argparse
from json_logging import setup_logging, get_logger from json_logging import setup_logging, get_logger
@ -24,7 +23,7 @@ def build_parser() -> argparse.ArgumentParser:
return p return p
def main() -> int: async def async_main() -> int:
setup_logging() setup_logging()
logger = get_logger(__name__) logger = get_logger(__name__)
@ -32,16 +31,8 @@ def main() -> int:
args = parser.parse_args() args = parser.parse_args()
try: try:
with Amcrest2Mqtt(args=args) as amcrest2mqtt: async with Amcrest2Mqtt(args=args) as amcrest2mqtt:
try: await amcrest2mqtt.main_loop()
asyncio.run(amcrest2mqtt.main_loop())
except RuntimeError as err:
if "asyncio.run() cannot be called from a running event loop" in str(err):
# Nested event loop (common in tests or Jupyter) — fall back gracefully
loop = asyncio.get_event_loop()
loop.run_until_complete(amcrest2mqtt.main_loop())
else:
raise
except ConfigError as err: except ConfigError as err:
logger.error(f"Fatal config error was found: {err}") logger.error(f"Fatal config error was found: {err}")
return 1 return 1
@ -55,8 +46,20 @@ def main() -> int:
logger.warning("Main loop cancelled.") logger.warning("Main loop cancelled.")
return 1 return 1
except Exception as err: except Exception as err:
logger.error(f"unhandled exception: {err}", exc_info=True) logger.error(f"Unhandled exception: {err}", exc_info=True)
return 1 return 1
finally: finally:
logger.info("amcrest2mqtt stopped.") logger.info("amcrest2mqtt stopped.")
return 0 return 0
def main() -> int:
try:
return asyncio.run(async_main())
except RuntimeError as err:
# Fallback for nested loops (Jupyter, tests, etc.)
if "asyncio.run() cannot be called from a running event loop" in str(err):
loop = asyncio.get_event_loop()
return loop.run_until_complete(async_main())
raise

@ -1,6 +1,8 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2025 Jeff Culverhouse # Copyright (c) 2025 Jeff Culverhouse
import asyncio
import argparse import argparse
import concurrent.futures
from datetime import datetime from datetime import datetime
import logging import logging
from mqtt_helper import MqttHelper from mqtt_helper import MqttHelper
@ -20,6 +22,9 @@ class Base:
def __init__(self: Amcrest2Mqtt, args: argparse.Namespace | None = None, **kwargs: Any): def __init__(self: Amcrest2Mqtt, args: argparse.Namespace | None = None, **kwargs: Any):
super().__init__(**kwargs) super().__init__(**kwargs)
self.loop = asyncio.get_running_loop()
self.loop.set_default_executor(concurrent.futures.ThreadPoolExecutor(max_workers=16))
self.args = args self.args = args
self.logger = get_logger(__name__) self.logger = get_logger(__name__)
@ -66,18 +71,18 @@ class Base:
self.last_call_date = datetime.now() self.last_call_date = datetime.now()
self.rate_limited = False self.rate_limited = False
def __enter__(self: Self) -> Amcrest2Mqtt: async def __aenter__(self: Self) -> Amcrest2Mqtt:
super_enter = getattr(super(), "__enter__", None) super_enter = getattr(super(), "__enter__", None)
if callable(super_enter): if callable(super_enter):
super_enter() super_enter()
cast(Any, self).mqttc_create() await cast(Any, self).mqttc_create()
cast(Any, self).restore_state() cast(Any, self).restore_state()
self.running = True self.running = True
return cast(Amcrest2Mqtt, self) return cast(Amcrest2Mqtt, self)
def __exit__(self: Self, exc_type: BaseException | None, exc_val: BaseException | None, exc_tb: TracebackType) -> None: async def __aexit__(self: Self, exc_type: BaseException | None, exc_val: BaseException | None, exc_tb: TracebackType) -> None:
super_exit = getattr(super(), "__exit__", None) super_exit = getattr(super(), "__exit__", None)
if callable(super_exit): if callable(super_exit):
super_exit(exc_type, exc_val, exc_tb) super_exit(exc_type, exc_val, exc_tb)
@ -87,7 +92,7 @@ class Base:
if cast(Any, self).mqttc is not None: if cast(Any, self).mqttc is not None:
try: try:
cast(Any, self).publish_service_availability("offline") await cast(Any, self).publish_service_availability("offline")
cast(Any, self).mqttc.loop_stop() cast(Any, self).mqttc.loop_stop()
except Exception as err: except Exception as err:
self.logger.debug(f"Mqtt loop_stop failed: {err}") self.logger.debug(f"Mqtt loop_stop failed: {err}")

@ -1,14 +1,16 @@
from amcrest import AmcrestCamera from amcrest import ApiWrapper
from argparse import Namespace from argparse import Namespace
from asyncio import AbstractEventLoop from asyncio import AbstractEventLoop
from datetime import datetime, timezone from datetime import datetime
from logging import Logger from logging import Logger
from mqtt_helper import MqttHelper from mqtt_helper import MqttHelper
from paho.mqtt.client import Client, MQTTMessage, ConnectFlags, DisconnectFlags from paho.mqtt.client import Client, MQTTMessage, ConnectFlags, DisconnectFlags
from paho.mqtt.reasoncodes import ReasonCode from paho.mqtt.reasoncodes import ReasonCode
from paho.mqtt.properties import Properties from paho.mqtt.properties import Properties
from types import FrameType from types import FrameType
from typing import Protocol, Any from typing import Protocol, Any, Callable, Coroutine, TypeVar
_T = TypeVar("_T")
class AmcrestServiceProtocol(Protocol): class AmcrestServiceProtocol(Protocol):
@ -38,10 +40,10 @@ class AmcrestServiceProtocol(Protocol):
storage_update_interval: int storage_update_interval: int
snapshot_update_interval: int snapshot_update_interval: int
states: dict[str, Any] states: dict[str, Any]
timezone: timezone
async def build_camera(self, device: dict) -> str: ... async def build_camera(self, device: dict) -> str: ...
async def build_component(self, device: dict) -> str: ... async def build_component(self, device: dict) -> str: ...
async def build_device_states(self, device_id: str) -> bool: ...
async def check_event_queue_loop(self) -> None: ... async def check_event_queue_loop(self) -> None: ...
async def check_for_events(self) -> None: ... async def check_for_events(self) -> None: ...
async def collect_all_device_events(self) -> None: ... async def collect_all_device_events(self) -> None: ...
@ -50,26 +52,54 @@ class AmcrestServiceProtocol(Protocol):
async def collect_snapshots_loop(self) -> None: ... async def collect_snapshots_loop(self) -> None: ...
async def connect_to_devices(self) -> dict[str, Any]: ... async def connect_to_devices(self) -> dict[str, Any]: ...
async def device_loop(self) -> None: ... async def device_loop(self) -> None: ...
async def get_camera(self, host: str) -> ApiWrapper: ...
async def get_device(self, host: str, device_name: str, index: int) -> None: ...
async def get_events_from_device(self, device_id: str) -> None: ... async def get_events_from_device(self, device_id: str) -> None: ...
async def get_ip_address(self, string: str) -> str: ...
async def get_motion_detection(self, device_id: str) -> bool: ...
async def get_privacy_mode(self, device_id: str) -> bool: ...
async def get_snapshot_from_device(self, device_id: str) -> str | None: ... async def get_snapshot_from_device(self, device_id: str) -> str | None: ...
async def get_storage_stats(self, device_id: str) -> dict[str, str | float]: ...
async def handle_device_command(self, device_id: str, handler: str, message: str) -> None: ...
async def handle_device_topic(self, components: list[str], payload: str) -> None: ...
async def handle_homeassistant_message(self, payload: str) -> None: ...
async def handle_service_command(self, handler: str, message: str) -> None: ...
async def heartbeat(self) -> None: ... async def heartbeat(self) -> None: ...
async def main_loop(self) -> None: ... async def main_loop(self) -> None: ...
async def mqtt_on_connect(
self, client: Client, userdata: dict[str, Any], flags: ConnectFlags, reason_code: ReasonCode, properties: Properties | None
) -> None: ...
async def mqtt_on_disconnect(
self, client: Client, userdata: Any, flags: DisconnectFlags, reason_code: ReasonCode, properties: Properties | None
) -> None: ...
async def mqtt_on_message(self, client: Client, userdata: Any, msg: MQTTMessage) -> None: ...
async def mqtt_on_subscribe(self, client: Client, userdata: Any, mid: int, reason_code_list: list[ReasonCode], properties: Properties) -> None: ...
async def mqtt_on_log(self, client: Client, userdata: Any, paho_log_level: int, msg: str) -> None: ...
async def mqttc_create(self) -> None: ...
async def process_device_event(self, device_id: str, code: str, payload: Any) -> None: ... async def process_device_event(self, device_id: str, code: str, payload: Any) -> None: ...
async def publish_device_availability(self, device_id: str, online: bool = True) -> None: ...
async def publish_device_discovery(self, device_id: str) -> None: ...
async def publish_device_state(self, device_id: str) -> None: ...
async def publish_service_availability(self, status: str = "online") -> None: ...
async def publish_service_discovery(self) -> None: ...
async def publish_service_state(self) -> None: ...
async def rediscover_all(self) -> None: ...
async def refresh_all_devices(self) -> None: ... async def refresh_all_devices(self) -> None: ...
async def set_motion_detection(self, device_id: str, switch: bool) -> None: ...
async def set_privacy_mode(self, device_id: str, switch: bool) -> None: ...
async def setup_device_list(self) -> None: ... async def setup_device_list(self) -> None: ...
async def store_recording_in_media(self, device_id: str, amcrest_file: str) -> str | None: ... async def store_recording_in_media(self, device_id: str, amcrest_file: str) -> str | 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 _parse_device_topic(self, components: list[str]) -> list[str | None] | None: ... def _parse_device_topic(self, components: list[str]) -> list[str | None] | None: ...
def _wrap_async(
self,
coro_func: Callable[..., Coroutine[Any, Any, _T]],
) -> Callable[..., None]: ...
def assert_no_tuples(self, data: Any, path: str = "root") -> None: ... def assert_no_tuples(self, data: Any, path: str = "root") -> None: ...
def b_to_gb(self, total: int) -> float: ... def b_to_gb(self, total: int) -> float: ...
def b_to_mb(self, total: int) -> float: ... def b_to_mb(self, total: int) -> float: ...
def build_device_states(self, device_id: str) -> bool: ...
def classify_device(self, device: dict) -> str: ... def classify_device(self, device: dict) -> str: ...
def get_camera(self, host: str) -> AmcrestCamera: ...
def get_component_type(self, device_id: str) -> str: ... def get_component_type(self, device_id: str) -> str: ...
def get_component(self, device_id: str) -> dict[str, Any]: ... def get_component(self, device_id: str) -> dict[str, Any]: ...
def get_device_availability_topic(self, device_id: str) -> str: ... def get_device_availability_topic(self, device_id: str) -> str: ...
@ -77,18 +107,11 @@ class AmcrestServiceProtocol(Protocol):
def get_device_name(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_name_slug(self, device_id: str) -> str: ...
def get_device_state_topic(self, device_id: str, mode_name: 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_mode(self, device_id: str, mode_name: str) -> dict[str, Any]: ... 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_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_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_recorded_file(self, device_id: str, file: str, encode: bool = True) -> str | None: ...
def get_snapshot(self, device_id: str) -> 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 handle_signal(self, signum: int, _: FrameType | None) -> Any: ... def handle_signal(self, signum: int, _: FrameType | None) -> Any: ...
def heartbeat_ready(self) -> None: ... def heartbeat_ready(self) -> None: ...
def increase_api_calls(self) -> None: ... def increase_api_calls(self) -> None: ...
@ -99,28 +122,10 @@ class AmcrestServiceProtocol(Protocol):
def load_config(self, config_arg: Any | None) -> dict[str, Any]: ... def load_config(self, config_arg: Any | None) -> dict[str, Any]: ...
def mark_ready(self) -> None: ... def mark_ready(self) -> None: ...
def mb_to_b(self, total: int) -> int: ... 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 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, status: str = "online") -> None: ...
def publish_service_discovery(self) -> None: ...
def publish_service_state(self) -> None: ...
def read_file(self, file_name: str) -> str: ... def read_file(self, file_name: str) -> str: ...
def reboot_device(self, device_id: str) -> None: ... def reboot_device(self, device_id: str) -> None: ...
def rediscover_all(self) -> None: ...
def restore_state(self) -> None: ... def restore_state(self) -> None: ...
def restore_state_values(self, api_calls: int, last_call_date: str) -> None: ...
def safe_split_device(self, topic: str, segment: str) -> list[str]: ... def safe_split_device(self, topic: str, segment: str) -> list[str]: ...
def save_state(self) -> None: ... def save_state(self) -> None: ...
def set_motion_detection(self, device_id: str, switch: bool) -> None: ...
def set_privacy_mode(self, device_id: str, switch: bool) -> None: ...
def upsert_device(self, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> bool: ... def upsert_device(self, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> bool: ...
def upsert_state(self, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> bool: ... def upsert_state(self, device_id: str, **kwargs: dict[str, Any] | str | int | bool) -> bool: ...

@ -12,19 +12,25 @@ class AmcrestMixin:
self.logger.debug("setting up device list from config") self.logger.debug("setting up device list from config")
amcrest_devices = await self.connect_to_devices() amcrest_devices = await self.connect_to_devices()
self.publish_service_state() await self.publish_service_state()
seen_devices = set() seen_devices: set[str] = set()
for device in amcrest_devices.values(): # Build all components concurrently
created = await self.build_component(device) tasks = [self.build_component(device) for device in amcrest_devices.values()]
if created: results = await asyncio.gather(*tasks, return_exceptions=True)
seen_devices.add(created)
# Collect successful device IDs
for result in results:
if isinstance(result, Exception):
self.logger.error(f"error during build_component: {result}", exc_info=True)
elif result and isinstance(result, str):
seen_devices.add(result)
# Mark missing devices offline # Mark missing devices offline
missing_devices = set(self.devices.keys()) - seen_devices missing_devices = set(self.devices.keys()) - seen_devices
for device_id in missing_devices: for device_id in missing_devices:
self.publish_device_availability(device_id, online=False) await self.publish_device_availability(device_id, online=False)
self.logger.warning(f"device {device_id} not seen in Amcrest API list — marked offline") self.logger.warning(f"device {device_id} not seen in Amcrest API list — marked offline")
# Handle first discovery completion # Handle first discovery completion
@ -369,13 +375,13 @@ class AmcrestMixin:
"recording_url": "", "recording_url": "",
}, },
) )
self.build_device_states(device_id) await self.build_device_states(device_id)
if not self.states[device_id]["internal"].get("discovered", None): if not self.states[device_id]["internal"].get("discovered", None):
self.logger.info(f'added new camera: "{device["device_name"]}" {device["vendor"]} {device["device_type"]}] ({device_id})') self.logger.info(f'added new camera: "{device["device_name"]}" {device["vendor"]} {device["device_type"]}] ({device_id})')
self.publish_device_discovery(device_id) await self.publish_device_discovery(device_id)
self.publish_device_availability(device_id, online=True) await self.publish_device_availability(device_id, online=True)
self.publish_device_state(device_id) await self.publish_device_state(device_id)
return device_id return device_id

@ -1,9 +1,10 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2025 Jeff Culverhouse # Copyright (c) 2025 Jeff Culverhouse
from amcrest import AmcrestCamera from amcrest import AmcrestCamera, ApiWrapper
from amcrest.exceptions import LoginError, AmcrestError, CommError from amcrest.exceptions import LoginError, AmcrestError, CommError
import asyncio import asyncio
import base64 import base64
from collections.abc import Sequence
from datetime import datetime, timedelta from datetime import datetime, timedelta
import random import random
from typing import TYPE_CHECKING, Any, cast from typing import TYPE_CHECKING, Any, cast
@ -28,7 +29,7 @@ class AmcrestAPIMixin:
async def _connect_device(host: str, name: str, index: int) -> None: async def _connect_device(host: str, name: str, index: int) -> None:
async with semaphore: async with semaphore:
await asyncio.to_thread(self.get_device, host, name, index) await self.get_device(host, name, index)
self.logger.debug(f'connecting to: {self.amcrest_config["hosts"]}') self.logger.debug(f'connecting to: {self.amcrest_config["hosts"]}')
@ -42,18 +43,39 @@ class AmcrestAPIMixin:
self.logger.info("connecting to hosts done") self.logger.info("connecting to hosts done")
return {d: self.amcrest_devices[d]["config"] for d in self.amcrest_devices.keys()} return {d: self.amcrest_devices[d]["config"] for d in self.amcrest_devices.keys()}
def get_camera(self: Amcrest2Mqtt, host: str) -> AmcrestCamera: async def get_camera(self: Amcrest2Mqtt, host: str) -> ApiWrapper:
config = self.amcrest_config config = self.amcrest_config
self.increase_api_calls() return AmcrestCamera(
return AmcrestCamera(host, config["port"], config["username"], config["password"], verbose=False) host,
config["port"],
config["username"],
config["password"],
verbose=False,
retries_connection=0, # dont multiply wall time at startup
timeout_protocol=(4.0, 4.0), # (connect, read) in seconds
).camera
async def get_device(self: Amcrest2Mqtt, host: str, device_name: str, index: int) -> None:
def clean_value(value: str | Sequence[str], prefix: str = "") -> str:
# Normalize to a string first
if not isinstance(value, str):
# Handle list/tuple cases
if isinstance(value, Sequence) and len(value) > 0:
value = value[0]
else:
# Graceful fallback if value is empty or weird
return ""
# At this point, value is guaranteed to be a str
if prefix and value.startswith(prefix):
value = value[len(prefix) :]
return value.strip()
def get_device(self: Amcrest2Mqtt, host: str, device_name: str, index: int) -> None:
camera = None camera = None
try: try:
host_ip = self.get_ip_address(host) host_ip = await self.get_ip_address(host)
device = self.get_camera(host_ip) camera = await self.get_camera(host_ip)
camera = device.camera
self.increase_api_calls() self.increase_api_calls()
except LoginError: except LoginError:
self.logger.error(f'invalid username/password to connect to device "{host}", fix in config.yaml') self.logger.error(f'invalid username/password to connect to device "{host}", fix in config.yaml')
@ -65,24 +87,50 @@ class AmcrestAPIMixin:
self.logger.error(f"error connecting to {host}: {err}") self.logger.error(f"error connecting to {host}: {err}")
return return
serial_number = camera.serial_number (
serial_number,
device_type,
sw_info,
net_config,
device_class,
hardware_version,
vendor_info,
) = await asyncio.gather(
camera.async_serial_number,
camera.async_device_type,
camera.async_software_information,
camera.async_network_config,
camera.async_device_class,
camera.async_hardware_version,
camera.async_vendor_information,
)
serial_number = clean_value(serial_number, "SerialNumber=")
device_class = clean_value(device_class, "deviceClass=")
device_type = clean_value(device_type, "type=")
device_type = camera.device_type.replace("type=", "").strip()
is_ad110 = device_type == "AD110" is_ad110 = device_type == "AD110"
is_ad410 = device_type == "AD410" is_ad410 = device_type == "AD410"
is_doorbell = is_ad110 or is_ad410 is_doorbell = is_ad110 or is_ad410
version = camera.software_information[0].replace("version=", "").strip() version = sw_info[0].replace("version=", "").strip()
build = camera.software_information[1].strip() build = sw_info[1].strip()
sw_version = f"{version} ({build})" sw_version = f"{version} ({build})"
network_config = dict(item.split("=") for item in camera.network_config.splitlines()) network_config = dict(item.split("=", 1) for item in net_config[0].splitlines() if "=" in item)
interface = network_config["table.Network.DefaultInterface"]
ip_address = network_config[f"table.Network.{interface}.IPAddress"]
mac_address = network_config[f"table.Network.{interface}.PhysicalAddress"].upper()
if camera.serial_number not in self.amcrest_devices: interface = network_config.get("table.Network.DefaultInterface")
self.logger.info(f"connected to {host} with serial number {camera.serial_number}") if not interface:
# Find first interface key dynamically
candidates = [k.split(".")[2] for k in network_config if k.startswith("table.Network.") and ".IPAddress" in k]
interface = candidates[0] if candidates else "eth0"
self.logger.debug(f"No DefaultInterface key; using {interface}")
ip_address = network_config.get(f"table.Network.{interface}.IPAddress", "0.0.0.0")
mac_address = network_config.get(f"table.Network.{interface}.PhysicalAddress", "00:00:00:00:00:00").upper()
if serial_number not in self.amcrest_devices:
self.logger.info(f"connected to {host} with serial number {serial_number}")
self.amcrest_devices[serial_number] = { self.amcrest_devices[serial_number] = {
"camera": camera, "camera": camera,
@ -92,14 +140,14 @@ class AmcrestAPIMixin:
"host_ip": host_ip, "host_ip": host_ip,
"device_name": device_name, "device_name": device_name,
"device_type": device_type, "device_type": device_type,
"device_class": camera.device_class, "device_class": device_class,
"is_ad110": is_ad110, "is_ad110": is_ad110,
"is_ad410": is_ad410, "is_ad410": is_ad410,
"is_doorbell": is_doorbell, "is_doorbell": is_doorbell,
"serial_number": serial_number, "serial_number": serial_number,
"software_version": sw_version, "software_version": sw_version,
"hardware_version": camera.hardware_version, "hardware_version": hardware_version,
"vendor": camera.vendor_information, "vendor": vendor_info,
"network": { "network": {
"interface": interface, "interface": interface,
"ip_address": ip_address, "ip_address": ip_address,
@ -132,7 +180,7 @@ class AmcrestAPIMixin:
# Storage stats ------------------------------------------------------------------------------- # Storage stats -------------------------------------------------------------------------------
def get_storage_stats(self: Amcrest2Mqtt, device_id: str) -> dict[str, str | float]: async def get_storage_stats(self: Amcrest2Mqtt, device_id: str) -> dict[str, str | float]:
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
states = self.states[device_id] states = self.states[device_id]
@ -148,8 +196,7 @@ class AmcrestAPIMixin:
return current return current
try: try:
storage = device["camera"].storage_all storage = await device["camera"].async_storage_all
self.increase_api_calls()
except CommError as err: except CommError as err:
self.logger.error(f"failed to get storage stats from ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to get storage stats from ({self.get_device_name(device_id)}): {err}")
return current return current
@ -157,6 +204,8 @@ class AmcrestAPIMixin:
self.logger.error(f"failed to auth to ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to auth to ({self.get_device_name(device_id)}): {err}")
return current return current
self.increase_api_calls()
return { return {
"used_percent": storage.get("used_percent", "unknown"), "used_percent": storage.get("used_percent", "unknown"),
"used": self.b_to_gb(storage["used"][0]), "used": self.b_to_gb(storage["used"][0]),
@ -165,7 +214,7 @@ class AmcrestAPIMixin:
# Privacy config ------------------------------------------------------------------------------ # Privacy config ------------------------------------------------------------------------------
def get_privacy_mode(self: Amcrest2Mqtt, device_id: str) -> bool: async def get_privacy_mode(self: Amcrest2Mqtt, device_id: str) -> bool:
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
states = self.states[device_id] states = self.states[device_id]
@ -177,10 +226,7 @@ class AmcrestAPIMixin:
return current return current
try: try:
privacy = device["camera"].privacy_config().split() privacy = await device["camera"].async_privacy_config()
privacy_mode = True if privacy[0].split("=")[1] == "true" else False
device["privacy_mode"] = privacy_mode
self.increase_api_calls()
except CommError as err: except CommError as err:
self.logger.error(f"failed to get privacy mode from ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to get privacy mode from ({self.get_device_name(device_id)}): {err}")
return current return current
@ -188,21 +234,25 @@ class AmcrestAPIMixin:
self.logger.error(f"failed to auth to device ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to auth to device ({self.get_device_name(device_id)}): {err}")
return current return current
self.increase_api_calls()
if not privacy or not isinstance(privacy, list) or len(privacy) < 1:
return current
privacy_mode = True if privacy[0].split("=")[1] == "true" else False
return privacy_mode return privacy_mode
def set_privacy_mode(self: Amcrest2Mqtt, device_id: str, switch: bool) -> None: async def set_privacy_mode(self: Amcrest2Mqtt, device_id: str, switch: bool) -> None:
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
if not device["camera"]: if not device["camera"]:
self.logger.warning(f"camera not found for {self.get_device_name(device_id)}") self.logger.warning(f"camera not found for {self.get_device_name(device_id)}")
return None return None
try: try:
response = str(device["camera"].set_privacy(switch)).strip() response = str(await device["camera"].async_set_privacy(switch)).strip()
self.increase_api_calls() self.increase_api_calls()
self.logger.debug(f"Set privacy_mode on {self.get_device_name(device_id)} to {switch}, got back: {response}") self.logger.debug(f"Set privacy_mode on {self.get_device_name(device_id)} to {switch}, got back: {response}")
if response == "OK": if response == "OK":
self.upsert_state(device_id, switch={"privacy": "ON" if switch else "OFF"}) self.upsert_state(device_id, switch={"privacy": "ON" if switch else "OFF"})
self.publish_device_state(device_id) await self.publish_device_state(device_id)
except CommError as err: except CommError as err:
self.logger.error(f"failed to set privacy mode on ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to set privacy mode on ({self.get_device_name(device_id)}): {err}")
except LoginError as err: except LoginError as err:
@ -212,7 +262,7 @@ class AmcrestAPIMixin:
# Motion detection config --------------------------------------------------------------------- # Motion detection config ---------------------------------------------------------------------
def get_motion_detection(self: Amcrest2Mqtt, device_id: str) -> bool: async def get_motion_detection(self: Amcrest2Mqtt, device_id: str) -> bool:
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
states = self.states[device_id] states = self.states[device_id]
@ -224,8 +274,7 @@ class AmcrestAPIMixin:
return current return current
try: try:
motion_detection = bool(device["camera"].is_motion_detector_on()) motion_detection = bool(await device["camera"].async_is_motion_detector_on())
self.increase_api_calls()
except CommError as err: except CommError as err:
self.logger.error(f"failed to get motion detection switch on ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to get motion detection switch on ({self.get_device_name(device_id)}): {err}")
return current return current
@ -233,9 +282,10 @@ class AmcrestAPIMixin:
self.logger.error(f"failed to auth to device ({self.get_device_name(device_id)}): {err}") self.logger.error(f"failed to auth to device ({self.get_device_name(device_id)}): {err}")
return current return current
self.increase_api_calls()
return motion_detection return motion_detection
def set_motion_detection(self: Amcrest2Mqtt, device_id: str, switch: bool) -> None: async def set_motion_detection(self: Amcrest2Mqtt, device_id: str, switch: bool) -> None:
device = self.amcrest_devices[device_id] device = self.amcrest_devices[device_id]
if not device["camera"]: if not device["camera"]:
@ -243,12 +293,12 @@ class AmcrestAPIMixin:
return None return None
try: try:
response = bool(device["camera"].set_motion_detection(switch)) response = bool(await device["camera"].async_set_motion_detection(switch))
self.increase_api_calls() self.increase_api_calls()
self.logger.debug(f"Set motion_detection on {self.get_device_name(device_id)} to {switch}, got back: {response}") self.logger.debug(f"Set motion_detection on {self.get_device_name(device_id)} to {switch}, got back: {response}")
if response: if response:
self.upsert_state(device_id, switch={"motion_detection": "ON" if switch else "OFF"}) self.upsert_state(device_id, switch={"motion_detection": "ON" if switch else "OFF"})
self.publish_device_state(device_id) await self.publish_device_state(device_id)
except CommError: except CommError:
self.logger.error(f"Failed to communicate with device ({self.get_device_name(device_id)}) to set motion detections") self.logger.error(f"Failed to communicate with device ({self.get_device_name(device_id)}) to set motion detections")
except LoginError: except LoginError:
@ -298,7 +348,7 @@ class AmcrestAPIMixin:
device_id, device_id,
image={"snapshot": encoded}, image={"snapshot": encoded},
) )
self.publish_device_state(device_id) await self.publish_device_state(device_id)
self.logger.debug(f"got snapshot from {self.get_device_name(device_id)} {len(image_bytes)} raw bytes -> {len(encoded)} b64 chars") self.logger.debug(f"got snapshot from {self.get_device_name(device_id)} {len(image_bytes)} raw bytes -> {len(encoded)} b64 chars")
return encoded return encoded

@ -1,5 +1,6 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2025 Jeff Culverhouse # Copyright (c) 2025 Jeff Culverhouse
import asyncio
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from datetime import datetime, timezone from datetime import datetime, timezone
@ -74,5 +75,6 @@ class EventsMixin:
else: else:
self.logger.debug(f'ignored event for "{self.get_device_name(device_id)}": {event} with {payload}') self.logger.debug(f'ignored event for "{self.get_device_name(device_id)}": {event} with {payload}')
for id in needs_publish: tasks = [self.publish_device_state(id) for id in needs_publish]
self.publish_device_state(id) if tasks:
await asyncio.gather(*tasks)

@ -1,5 +1,6 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2025 Jeff Culverhouse # Copyright (c) 2025 Jeff Culverhouse
import asyncio
from deepmerge.merger import Merger from deepmerge.merger import Merger
import ipaddress import ipaddress
import os import os
@ -26,14 +27,17 @@ class ConfigError(ValueError):
class HelpersMixin: class HelpersMixin:
def build_device_states(self: Amcrest2Mqtt, device_id: str) -> bool: async def build_device_states(self: Amcrest2Mqtt, device_id: str) -> bool:
if self.is_rebooting(device_id): if self.is_rebooting(device_id):
self.logger.debug(f"skipping device states for {self.get_device_name(device_id)}, still rebooting") self.logger.debug(f"skipping device states for {self.get_device_name(device_id)}, still rebooting")
return False return False
storage = self.get_storage_stats(device_id) # get properties from device
privacy = self.get_privacy_mode(device_id) storage, privacy, motion_detection = await asyncio.gather(
motion_detection = self.get_motion_detection(device_id) self.get_storage_stats(device_id),
self.get_privacy_mode(device_id),
self.get_motion_detection(device_id),
)
changed = self.upsert_state( changed = self.upsert_state(
device_id, device_id,
@ -51,22 +55,22 @@ class HelpersMixin:
# send command to Amcrest ----------------------------------------------------------------------- # send command to Amcrest -----------------------------------------------------------------------
def handle_device_command(self: Amcrest2Mqtt, device_id: str, handler: str, message: str) -> None: async def handle_device_command(self: Amcrest2Mqtt, device_id: str, handler: str, message: str) -> None:
match handler: match handler:
case "save_recordings": case "save_recordings":
if message == "ON" and "path" not in self.config["media"]: if message == "ON" and "path" not in self.config["media"]:
self.logger.error("user tried to turn on save_recordings, but there is no media path set") self.logger.error("user tried to turn on save_recordings, but there is no media path set")
return return
self.upsert_state(device_id, switch={"save_recordings": message}) self.upsert_state(device_id, switch={"save_recordings": message})
self.publish_device_state(device_id) await self.publish_device_state(device_id)
case "motion_detection": case "motion_detection":
self.set_motion_detection(device_id, message == "ON") await self.set_motion_detection(device_id, message == "ON")
case "privacy": case "privacy":
self.set_privacy_mode(device_id, message == "ON") await self.set_privacy_mode(device_id, message == "ON")
case "reboot": case "reboot":
self.reboot_device(device_id) self.reboot_device(device_id)
def handle_service_command(self: Amcrest2Mqtt, handler: str, message: str) -> None: async def handle_service_command(self: Amcrest2Mqtt, handler: str, message: str) -> None:
match handler: match handler:
case "storage_refresh": case "storage_refresh":
self.device_interval = int(message) self.device_interval = int(message)
@ -76,18 +80,18 @@ class HelpersMixin:
self.snapshot_update_interval = int(message) self.snapshot_update_interval = int(message)
case "refresh_device_list": case "refresh_device_list":
if message == "refresh": if message == "refresh":
self.rediscover_all() await self.rediscover_all()
case _: case _:
self.logger.error(f"unrecognized message to {self.mqtt_helper.service_slug}: {handler} -> {message}") self.logger.error(f"unrecognized message to {self.mqtt_helper.service_slug}: {handler} -> {message}")
return return
self.publish_service_state() await self.publish_service_state()
def rediscover_all(self: Amcrest2Mqtt) -> None: async def rediscover_all(self: Amcrest2Mqtt) -> None:
self.publish_service_discovery() await self.publish_service_discovery()
self.publish_service_state() await self.publish_service_state()
for device_id in self.devices: for device_id in self.devices:
self.publish_device_discovery(device_id) await self.publish_device_discovery(device_id)
self.publish_device_state(device_id) await self.publish_device_state(device_id)
# Utility functions --------------------------------------------------------------------------- # Utility functions ---------------------------------------------------------------------------
@ -118,15 +122,17 @@ class HelpersMixin:
except ValueError: except ValueError:
return False return False
def get_ip_address(self: Amcrest2Mqtt, string: str) -> str: async def get_ip_address(self: Amcrest2Mqtt, string: str) -> str:
if self.is_ipv4(string): if self.is_ipv4(string):
return string return string
try: try:
for i in socket.getaddrinfo(string, None): infos = await self.loop.getaddrinfo(string, None, family=socket.AF_INET)
if i[0] == socket.AddressFamily.AF_INET: # getaddrinfo returns a list of 5-tuples; [4][0] holds the IP string
return str(i[4][0]) return infos[0][4][0]
except socket.gaierror as err: except socket.gaierror as err:
raise Exception(f"failed to resolve {string}: {err}") raise Exception(f"failed to resolve {string}: {err}") from err
except IndexError:
raise Exception(f"failed to find IP address for {string}") raise Exception(f"failed to find IP address for {string}")
def list_from_env(self: Amcrest2Mqtt, env_name: str) -> list[str]: def list_from_env(self: Amcrest2Mqtt, env_name: str) -> list[str]:

@ -56,15 +56,13 @@ class LoopsMixin:
# main loop # main loop
async def main_loop(self: Amcrest2Mqtt) -> None: async def main_loop(self: Amcrest2Mqtt) -> None:
await self.setup_device_list()
self.loop = asyncio.get_running_loop()
for sig in (signal.SIGTERM, signal.SIGINT): for sig in (signal.SIGTERM, signal.SIGINT):
try: try:
signal.signal(sig, self.handle_signal) signal.signal(sig, self.handle_signal)
except Exception: except Exception:
self.logger.debug(f"Cannot install handler for {sig}") self.logger.debug(f"Cannot install handler for {sig}")
await self.setup_device_list()
self.running = True self.running = True
self.mark_ready() self.mark_ready()

@ -1,5 +1,6 @@
# SPDX-License-Identifier: MIT # SPDX-License-Identifier: MIT
# Copyright (c) 2025 Jeff Culverhouse # Copyright (c) 2025 Jeff Culverhouse
import asyncio
from datetime import datetime, timedelta from datetime import datetime, timedelta
import json import json
import paho.mqtt.client as mqtt import paho.mqtt.client as mqtt
@ -10,11 +11,13 @@ from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.reasoncodes import ReasonCode from paho.mqtt.reasoncodes import ReasonCode
from paho.mqtt.enums import CallbackAPIVersion from paho.mqtt.enums import CallbackAPIVersion
import ssl import ssl
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any, Callable, Coroutine, TypeVar
if TYPE_CHECKING: if TYPE_CHECKING:
from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt from amcrest2mqtt.interface import AmcrestServiceProtocol as Amcrest2Mqtt
_T = TypeVar("_T")
class MqttError(ValueError): class MqttError(ValueError):
"""Raised when the connection to the MQTT server fails""" """Raised when the connection to the MQTT server fails"""
@ -23,7 +26,7 @@ class MqttError(ValueError):
class MqttMixin: class MqttMixin:
def mqttc_create(self: Amcrest2Mqtt) -> None: async def mqttc_create(self: Amcrest2Mqtt) -> None:
self.mqttc = mqtt.Client( self.mqttc = mqtt.Client(
client_id=self.client_id, client_id=self.client_id,
callback_api_version=CallbackAPIVersion.VERSION2, callback_api_version=CallbackAPIVersion.VERSION2,
@ -45,11 +48,11 @@ class MqttMixin:
password=self.mqtt_config.get("password", ""), password=self.mqtt_config.get("password", ""),
) )
self.mqttc.on_connect = self.mqtt_on_connect self.mqttc.on_connect = self._wrap_async(self.mqtt_on_connect)
self.mqttc.on_disconnect = self.mqtt_on_disconnect self.mqttc.on_disconnect = self._wrap_async(self.mqtt_on_disconnect)
self.mqttc.on_message = self.mqtt_on_message self.mqttc.on_message = self._wrap_async(self.mqtt_on_message)
self.mqttc.on_subscribe = self.mqtt_on_subscribe self.mqttc.on_subscribe = self._wrap_async(self.mqtt_on_subscribe)
self.mqttc.on_log = self.mqtt_on_log self.mqttc.on_log = self._wrap_async(self.mqtt_on_log)
# Define a "last will" message (LWT): # Define a "last will" message (LWT):
self.mqttc.will_set(self.mqtt_helper.avty_t("service"), "offline", qos=1, retain=True) self.mqttc.will_set(self.mqtt_helper.avty_t("service"), "offline", qos=1, retain=True)
@ -76,7 +79,16 @@ class MqttMixin:
self.running = False self.running = False
raise SystemExit(1) raise SystemExit(1)
def mqtt_on_connect( def _wrap_async(
self: Amcrest2Mqtt,
coro_func: Callable[..., Coroutine[Any, Any, _T]],
) -> Callable[..., None]:
def wrapper(*args: Any, **kwargs: Any) -> None:
self.loop.call_soon_threadsafe(lambda: asyncio.create_task(coro_func(*args, **kwargs)))
return wrapper
async def mqtt_on_connect(
self: Amcrest2Mqtt, client: Client, userdata: dict[str, Any], flags: ConnectFlags, reason_code: ReasonCode, properties: Properties | None self: Amcrest2Mqtt, client: Client, userdata: dict[str, Any], flags: ConnectFlags, reason_code: ReasonCode, properties: Properties | None
) -> None: ) -> None:
# send our helper the client # send our helper the client
@ -85,9 +97,9 @@ class MqttMixin:
if reason_code.value != 0: if reason_code.value != 0:
raise MqttError(f"MQTT failed to connect ({reason_code.getName()})") raise MqttError(f"MQTT failed to connect ({reason_code.getName()})")
self.publish_service_discovery() await self.publish_service_discovery()
self.publish_service_availability() await self.publish_service_availability()
self.publish_service_state() await self.publish_service_state()
self.logger.debug("subscribing to topics on MQTT") self.logger.debug("subscribing to topics on MQTT")
client.subscribe("homeassistant/status") client.subscribe("homeassistant/status")
@ -96,7 +108,7 @@ class MqttMixin:
client.subscribe(f"{self.mqtt_helper.service_slug}/+/switch/+/set") client.subscribe(f"{self.mqtt_helper.service_slug}/+/switch/+/set")
client.subscribe(f"{self.mqtt_helper.service_slug}/+/button/+/set") client.subscribe(f"{self.mqtt_helper.service_slug}/+/button/+/set")
def mqtt_on_disconnect( async def mqtt_on_disconnect(
self: Amcrest2Mqtt, client: Client, userdata: Any, flags: DisconnectFlags, reason_code: ReasonCode, properties: Properties | None self: Amcrest2Mqtt, client: Client, userdata: Any, flags: DisconnectFlags, reason_code: ReasonCode, properties: Properties | None
) -> None: ) -> None:
# clear the client on our helper # clear the client on our helper
@ -110,49 +122,47 @@ class MqttMixin:
if self.running and (self.mqtt_connect_time is None or datetime.now() > self.mqtt_connect_time + timedelta(seconds=10)): if self.running and (self.mqtt_connect_time is None or datetime.now() > self.mqtt_connect_time + timedelta(seconds=10)):
# lets use a new client_id for a reconnect attempt # lets use a new client_id for a reconnect attempt
self.client_id = self.mqtt_helper.client_id() self.client_id = self.mqtt_helper.client_id()
self.mqttc_create() await self.mqttc_create()
else: else:
self.logger.info("Mqtt disconnect — stopping service loop") self.logger.info("Mqtt disconnect — stopping service loop")
self.running = False self.running = False
def mqtt_on_log(self: Amcrest2Mqtt, client: Client, userdata: Any, paho_log_level: int, msg: str) -> None: async 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: if paho_log_level == LogLevel.MQTT_LOG_ERR:
self.logger.error(f"Mqtt logged: {msg}") self.logger.error(f"Mqtt logged: {msg}")
if paho_log_level == LogLevel.MQTT_LOG_WARNING: if paho_log_level == LogLevel.MQTT_LOG_WARNING:
self.logger.warning(f"Mqtt logged: {msg}") self.logger.warning(f"Mqtt logged: {msg}")
def mqtt_on_message(self: Amcrest2Mqtt, client: Client, userdata: Any, msg: MQTTMessage) -> None: async def mqtt_on_message(self: Amcrest2Mqtt, client: Client, userdata: Any, msg: MQTTMessage) -> None:
topic = msg.topic topic = msg.topic
payload = self._decode_payload(msg.payload)
components = topic.split("/") components = topic.split("/")
try:
payload = json.loads(msg.payload)
except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError):
try:
payload = msg.payload.decode("utf-8")
except Exception:
self.logger.warning("failed to decode MQTT payload: {err}")
return None
if components[0] == self.mqtt_config["discovery_prefix"]: if components[0] == self.mqtt_config["discovery_prefix"]:
return self._handle_homeassistant_message(payload) return await self.handle_homeassistant_message(payload)
if components[0] == self.mqtt_helper.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) return await self.handle_service_command(components[2], payload)
if components[0] == self.mqtt_helper.service_slug: if components[0] == self.mqtt_helper.service_slug:
return self._handle_device_topic(components, payload) return await 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: bytes) -> Any: async def handle_homeassistant_message(self: Amcrest2Mqtt, payload: str) -> None:
try:
return json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError):
try:
return raw.decode("utf-8")
except Exception:
self.logger.warning("failed to decode MQTT payload: {err}")
return None
def _handle_homeassistant_message(self: Amcrest2Mqtt, payload: str) -> None:
if payload == "online": if payload == "online":
self.rediscover_all() await self.rediscover_all()
self.logger.info("home Assistant came (back?) online — resending device discovery") self.logger.info("home Assistant came (back?) online — resending device discovery")
def _handle_device_topic(self: Amcrest2Mqtt, components: list[str], payload: str) -> None: async def handle_device_topic(self: Amcrest2Mqtt, components: list[str], payload: str) -> None:
parsed = self._parse_device_topic(components) parsed = self._parse_device_topic(components)
if not parsed: if not parsed:
return return
@ -169,7 +179,7 @@ class MqttMixin:
return return
self.logger.info(f"got message for {self.get_device_name(device_id)}: set {components[-2]} to {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) await self.handle_device_command(device_id, attribute, payload)
def _parse_device_topic(self: Amcrest2Mqtt, components: list[str]) -> list[str | None] | None: def _parse_device_topic(self: Amcrest2Mqtt, components: list[str]) -> list[str | None] | None:
try: try:
@ -196,7 +206,9 @@ class MqttMixin:
self.logger.warning(f"Ignoring malformed topic {topic}: {err}") self.logger.warning(f"Ignoring malformed topic {topic}: {err}")
return [] return []
def mqtt_on_subscribe(self: Amcrest2Mqtt, client: Client, userdata: Any, mid: int, reason_code_list: list[ReasonCode], properties: Properties) -> None: async 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] reason_names = [rc.getName() for rc in reason_code_list]
joined = "; ".join(reason_names) if reason_names else "none" joined = "; ".join(reason_names) if reason_names else "none"
self.logger.debug(f"Mqtt subscribed (mid={mid}): {joined}") self.logger.debug(f"Mqtt subscribed (mid={mid}): {joined}")

@ -1,5 +1,6 @@
import json import asyncio
from datetime import timezone from datetime import timezone
import json
from typing import TYPE_CHECKING, Any from typing import TYPE_CHECKING, Any
if TYPE_CHECKING: if TYPE_CHECKING:
@ -10,7 +11,7 @@ class PublishMixin:
# Service ------------------------------------------------------------------------------------- # Service -------------------------------------------------------------------------------------
def publish_service_discovery(self: Amcrest2Mqtt) -> None: async def publish_service_discovery(self: Amcrest2Mqtt) -> None:
device_block = self.mqtt_helper.device_block( device_block = self.mqtt_helper.device_block(
self.service_name, self.service_name,
self.mqtt_helper.service_slug, self.mqtt_helper.service_slug,
@ -161,7 +162,8 @@ class PublishMixin:
qos=self.mqtt_config["qos"], qos=self.mqtt_config["qos"],
retain=True, retain=True,
) )
self.mqtt_helper.safe_publish( await asyncio.to_thread(
self.mqtt_helper.safe_publish,
topic=self.mqtt_helper.disc_t("button", "refresh_device_list"), topic=self.mqtt_helper.disc_t("button", "refresh_device_list"),
payload=json.dumps( payload=json.dumps(
{ {
@ -178,10 +180,10 @@ class PublishMixin:
) )
self.logger.debug(f"discovery published for {self.service} ({self.mqtt_helper.service_slug})") self.logger.debug(f"discovery published for {self.service} ({self.mqtt_helper.service_slug})")
def publish_service_availability(self: Amcrest2Mqtt, status: str = "online") -> None: async def publish_service_availability(self: Amcrest2Mqtt, status: str = "online") -> None:
self.mqtt_helper.safe_publish(self.mqtt_helper.avty_t("service"), status, qos=self.qos, retain=True) await asyncio.to_thread(self.mqtt_helper.safe_publish, self.mqtt_helper.avty_t("service"), status, qos=self.qos, retain=True)
def publish_service_state(self: Amcrest2Mqtt) -> None: async def publish_service_state(self: Amcrest2Mqtt) -> None:
# we keep last_call_date in localtime so it rolls-over the api call counter # we keep last_call_date in localtime so it rolls-over the api call counter
# at the right time (midnight, local) but we want to send last_call_date # at the right time (midnight, local) but we want to send last_call_date
# to HomeAssistant as UTC # to HomeAssistant as UTC
@ -199,7 +201,8 @@ class PublishMixin:
} }
for key, value in service.items(): for key, value in service.items():
self.mqtt_helper.safe_publish( await asyncio.to_thread(
self.mqtt_helper.safe_publish,
self.mqtt_helper.stat_t("service", "service", key), self.mqtt_helper.stat_t("service", "service", key),
json.dumps(value) if isinstance(value, dict) else value, json.dumps(value) if isinstance(value, dict) else value,
qos=self.mqtt_config["qos"], qos=self.mqtt_config["qos"],
@ -208,45 +211,45 @@ class PublishMixin:
# Devices ------------------------------------------------------------------------------------- # Devices -------------------------------------------------------------------------------------
def publish_device_discovery(self: Amcrest2Mqtt, device_id: str) -> None: async def publish_device_discovery(self: Amcrest2Mqtt, device_id: str) -> None:
def _publish_one(dev_id: str, defn: dict, suffix: str = "") -> None: async def _publish_one(dev_id: str, defn: dict, suffix: str = "") -> None:
eff_device_id = dev_id if not suffix else f"{dev_id}_{suffix}" eff_device_id = dev_id if not suffix else f"{dev_id}_{suffix}"
topic = self.mqtt_helper.disc_t(defn["component_type"], f"{dev_id}_{suffix}" if suffix else dev_id) topic = self.mqtt_helper.disc_t(defn["component_type"], f"{dev_id}_{suffix}" if suffix else dev_id)
payload = {k: v for k, v in defn.items() if k != "component_type"} payload = {k: v for k, v in defn.items() if k != "component_type"}
self.mqtt_helper.safe_publish(topic, json.dumps(payload), retain=True) await asyncio.to_thread(self.mqtt_helper.safe_publish, topic, json.dumps(payload), retain=True)
self.upsert_state(eff_device_id, internal={"discovered": True}) self.upsert_state(eff_device_id, internal={"discovered": True})
_publish_one(device_id, self.get_component(device_id)) await _publish_one(device_id, self.get_component(device_id))
# Publish any modes (0..n) # Publish any modes (0..n)
modes = self.get_modes(device_id) modes = self.get_modes(device_id)
for slug, mode in modes.items(): for slug, mode in modes.items():
_publish_one(device_id, mode, suffix=slug) await _publish_one(device_id, mode, suffix=slug)
def publish_device_availability(self: Amcrest2Mqtt, device_id: str, online: bool = True) -> None: async def publish_device_availability(self: Amcrest2Mqtt, device_id: str, online: bool = True) -> None:
payload = "online" if online else "offline" payload = "online" if online else "offline"
avty_t = self.get_device_availability_topic(device_id) avty_t = self.get_device_availability_topic(device_id)
self.mqtt_helper.safe_publish(avty_t, payload, retain=True) await asyncio.to_thread(self.mqtt_helper.safe_publish, avty_t, payload, retain=True)
def publish_device_state(self: Amcrest2Mqtt, device_id: str) -> None: async def publish_device_state(self: Amcrest2Mqtt, device_id: str) -> None:
def _publish_one(dev_id: str, defn: str | dict[str, Any], suffix: str = "") -> None: async def _publish_one(dev_id: str, defn: str | dict[str, Any], suffix: str = "") -> None:
topic = self.get_device_state_topic(dev_id, suffix) topic = self.get_device_state_topic(dev_id, suffix)
if isinstance(defn, dict): if isinstance(defn, dict):
flat: dict[str, Any] = {k: v for k, v in defn.items() if k != "component_type"} flat: dict[str, Any] = {k: v for k, v in defn.items() if k != "component_type"}
meta = self.states[dev_id].get("meta") meta = self.states[dev_id].get("meta")
if isinstance(meta, dict) and "last_update" in meta: if isinstance(meta, dict) and "last_update" in meta:
flat["last_update"] = meta["last_update"] flat["last_update"] = meta["last_update"]
self.mqtt_helper.safe_publish(topic, json.dumps(flat), retain=True) await asyncio.to_thread(self.mqtt_helper.safe_publish, topic, json.dumps(flat), retain=True)
else: else:
self.mqtt_helper.safe_publish(topic, defn, retain=True) await asyncio.to_thread(self.mqtt_helper.safe_publish, topic, defn, retain=True)
if not self.is_discovered(device_id): if not self.is_discovered(device_id):
self.logger.debug(f"discovery not complete for {device_id} yet, holding off on sending state") self.logger.debug(f"discovery not complete for {device_id} yet, holding off on sending state")
return return
states = self.states[device_id] states = self.states[device_id]
_publish_one(device_id, states[self.get_component_type(device_id)]) await _publish_one(device_id, states[self.get_component_type(device_id)])
modes = self.get_modes(device_id) modes = self.get_modes(device_id)
for name, mode in modes.items(): for name, mode in modes.items():
@ -257,6 +260,6 @@ class PublishMixin:
continue continue
type_states = states[component_type][name] if isinstance(states[component_type], dict) else states[component_type] type_states = states[component_type][name] if isinstance(states[component_type], dict) else states[component_type]
_publish_one(device_id, type_states, name) await _publish_one(device_id, type_states, name)
self.publish_service_state() await self.publish_service_state()

@ -15,9 +15,9 @@ class RefreshMixin:
async def _refresh(device_id: str) -> None: async def _refresh(device_id: str) -> None:
async with semaphore: async with semaphore:
changed = await asyncio.to_thread(self.build_device_states, device_id) changed = await self.build_device_states(device_id)
if changed: if changed:
await asyncio.to_thread(self.publish_device_state, device_id) await self.publish_device_state(device_id)
tasks = [] tasks = []
for device_id in self.devices: for device_id in self.devices:

@ -188,37 +188,37 @@ wheels = [
[[package]] [[package]]
name = "coverage" name = "coverage"
version = "7.11.0" version = "7.11.1"
source = { registry = "https://pypi.org/simple" } 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" } sdist = { url = "https://files.pythonhosted.org/packages/89/12/3e2d2ec71796e0913178478e693a06af6a3bc9f7f9cb899bf85a426d8370/coverage-7.11.1.tar.gz", hash = "sha256:b4b3a072559578129a9e863082a2972a2abd8975bc0e2ec57da96afcd6580a8a", size = 814037, upload-time = "2025-11-07T10:52:41.067Z" }
wheels = [ wheels = [
{ 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/d9/c2/ac2c3417eaa4de1361036ebbc7da664242b274b2e00c4b4a1cfc7b29920b/coverage-7.11.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:0305463c45c5f21f0396cd5028de92b1f1387e2e0756a85dd3147daa49f7a674", size = 216967, upload-time = "2025-11-07T10:51:45.55Z" },
{ 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/5e/a3/afef455d03c468ee303f9df9a6f407e8bea64cd576fca914ff888faf52ca/coverage-7.11.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fa4d468d5efa1eb6e3062be8bd5f45cbf28257a37b71b969a8c1da2652dfec77", size = 217298, upload-time = "2025-11-07T10:51:47.31Z" },
{ 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" }, { url = "https://files.pythonhosted.org/packages/9d/59/6e2fb3fb58637001132dc32228b4fb5b332d75d12f1353cb00fe084ee0ba/coverage-7.11.1-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d2b2f5fc8fe383cbf2d5c77d6c4b2632ede553bc0afd0cdc910fa5390046c290", size = 248337, upload-time = "2025-11-07T10:51:49.48Z" },
{ url = "https://files.pythonhosted.org/packages/80/c0/5b259b029694ce0a5bbc1548834c7ba3db41d3efd3474489d7efce4ceb18/coverage-7.11.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:c6f31f281012235ad08f9a560976cc2fc9c95c17604ff3ab20120fe480169bca", size = 249970, upload-time = "2025-10-15T15:14:20.307Z" }, { url = "https://files.pythonhosted.org/packages/1d/5e/ce442bab963e3388658da8bde6ddbd0a15beda230afafaa25e3c487dc391/coverage-7.11.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:bde6488c1ad509f4fb1a4f9960fd003d5a94adef61e226246f9699befbab3276", size = 250853, upload-time = "2025-11-07T10:51:51.215Z" },
{ url = "https://files.pythonhosted.org/packages/8c/86/171b2b5e1aac7e2fd9b43f7158b987dbeb95f06d1fbecad54ad8163ae3e8/coverage-7.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9570ad567f880ef675673992222746a124b9595506826b210fbe0ce3f0499cd", size = 251324, upload-time = "2025-10-15T15:14:22.419Z" }, { url = "https://files.pythonhosted.org/packages/d1/2f/43f94557924ca9b64e09f1c3876da4eec44a05a41e27b8a639d899716c0e/coverage-7.11.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a69e0d6fa0b920fe6706a898c52955ec5bcfa7e45868215159f45fd87ea6da7c", size = 252190, upload-time = "2025-11-07T10:51:53.262Z" },
{ url = "https://files.pythonhosted.org/packages/1a/7e/7e10414d343385b92024af3932a27a1caf75c6e27ee88ba211221ff1a145/coverage-7.11.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8badf70446042553a773547a61fecaa734b55dc738cacf20c56ab04b77425e43", size = 247445, upload-time = "2025-10-15T15:14:24.205Z" }, { url = "https://files.pythonhosted.org/packages/8c/fa/a04e769b92bc5628d4bd909dcc3c8219efe5e49f462e29adc43e198ecfde/coverage-7.11.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:976e51e4a549b80e4639eda3a53e95013a14ff6ad69bb58ed604d34deb0e774c", size = 248335, upload-time = "2025-11-07T10:51:55.388Z" },
{ url = "https://files.pythonhosted.org/packages/c4/3b/e4f966b21f5be8c4bf86ad75ae94efa0de4c99c7bbb8114476323102e345/coverage-7.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a09c1211959903a479e389685b7feb8a17f59ec5a4ef9afde7650bd5eabc2777", size = 249324, upload-time = "2025-10-15T15:14:26.234Z" }, { url = "https://files.pythonhosted.org/packages/99/d0/b98ab5d2abe425c71117a7c690ead697a0b32b83256bf0f566c726b7f77b/coverage-7.11.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d61fcc4d384c82971a3d9cf00d0872881f9ded19404c714d6079b7a4547e2955", size = 250209, upload-time = "2025-11-07T10:51:57.263Z" },
{ url = "https://files.pythonhosted.org/packages/00/a2/8479325576dfcd909244d0df215f077f47437ab852ab778cfa2f8bf4d954/coverage-7.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:5ef83b107f50db3f9ae40f69e34b3bd9337456c5a7fe3461c7abf8b75dd666a2", size = 247261, upload-time = "2025-10-15T15:14:28.42Z" }, { url = "https://files.pythonhosted.org/packages/9c/3f/b9c4fbd2e6d1b64098f99fb68df7f7c1b3e0a0968d24025adb24f359cdec/coverage-7.11.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:284c5df762b533fae3ebd764e3b81c20c1c9648d93ef34469759cb4e3dfe13d0", size = 248163, upload-time = "2025-11-07T10:51:59.014Z" },
{ url = "https://files.pythonhosted.org/packages/7b/d8/3a9e2db19d94d65771d0f2e21a9ea587d11b831332a73622f901157cc24b/coverage-7.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f91f927a3215b8907e214af77200250bb6aae36eca3f760f89780d13e495388d", size = 247092, upload-time = "2025-10-15T15:14:30.784Z" }, { url = "https://files.pythonhosted.org/packages/08/fc/3e4d54fb6368b0628019eefd897fc271badbd025410fd5421a65fb58758f/coverage-7.11.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:bab32cb1d4ad2ac6dcc4e17eee5fa136c2a1d14ae914e4bce6c8b78273aece3c", size = 247983, upload-time = "2025-11-07T10:52:01.027Z" },
{ url = "https://files.pythonhosted.org/packages/b3/b1/bbca3c472544f9e2ad2d5116b2379732957048be4b93a9c543fcd0207e5f/coverage-7.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cdbcd376716d6b7fbfeedd687a6c4be019c5a5671b35f804ba76a4c0a778cba4", size = 248755, upload-time = "2025-10-15T15:14:32.585Z" }, { url = "https://files.pythonhosted.org/packages/b9/4a/a5700764a12e932b35afdddb2f59adbca289c1689455d06437f609f3ef35/coverage-7.11.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:36f2fed9ce392ca450fb4e283900d0b41f05c8c5db674d200f471498be3ce747", size = 249646, upload-time = "2025-11-07T10:52:02.856Z" },
{ url = "https://files.pythonhosted.org/packages/89/49/638d5a45a6a0f00af53d6b637c87007eb2297042186334e9923a61aa8854/coverage-7.11.0-cp314-cp314-win32.whl", hash = "sha256:bab7ec4bb501743edc63609320aaec8cd9188b396354f482f4de4d40a9d10721", size = 218793, upload-time = "2025-10-15T15:14:34.972Z" }, { url = "https://files.pythonhosted.org/packages/0e/2c/45ed33d9e80a1cc9b44b4bd535d44c154d3204671c65abd90ec1e99522a2/coverage-7.11.1-cp314-cp314-win32.whl", hash = "sha256:853136cecb92a5ba1cc8f61ec6ffa62ca3c88b4b386a6c835f8b833924f9a8c5", size = 219700, upload-time = "2025-11-07T10:52:05.05Z" },
{ url = "https://files.pythonhosted.org/packages/30/cc/b675a51f2d068adb3cdf3799212c662239b0ca27f4691d1fff81b92ea850/coverage-7.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:3d4ba9a449e9364a936a27322b20d32d8b166553bfe63059bd21527e681e2fad", size = 219587, upload-time = "2025-10-15T15:14:37.047Z" }, { url = "https://files.pythonhosted.org/packages/90/d7/5845597360f6434af1290118ebe114642865f45ce47e7e822d9c07b371be/coverage-7.11.1-cp314-cp314-win_amd64.whl", hash = "sha256:77443d39143e20927259a61da0c95d55ffc31cf43086b8f0f11a92da5260d592", size = 220516, upload-time = "2025-11-07T10:52:07.259Z" },
{ url = "https://files.pythonhosted.org/packages/93/98/5ac886876026de04f00820e5094fe22166b98dcb8b426bf6827aaf67048c/coverage-7.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:ce37f215223af94ef0f75ac68ea096f9f8e8c8ec7d6e8c346ee45c0d363f0479", size = 218168, upload-time = "2025-10-15T15:14:38.861Z" }, { url = "https://files.pythonhosted.org/packages/ae/d0/d311a06f9cf7a48a98ffcfd0c57db0dcab6da46e75c439286a50dc648161/coverage-7.11.1-cp314-cp314-win_arm64.whl", hash = "sha256:829acb88fa47591a64bf5197e96a931ce9d4b3634c7f81a224ba3319623cdf6c", size = 219091, upload-time = "2025-11-07T10:52:09.216Z" },
{ url = "https://files.pythonhosted.org/packages/14/d1/b4145d35b3e3ecf4d917e97fc8895bcf027d854879ba401d9ff0f533f997/coverage-7.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:f413ce6e07e0d0dc9c433228727b619871532674b45165abafe201f200cc215f", size = 216850, upload-time = "2025-10-15T15:14:40.651Z" }, { url = "https://files.pythonhosted.org/packages/a7/3d/c6a84da4fa9b840933045b19dd19d17b892f3f2dd1612903260291416dba/coverage-7.11.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:2ad1fe321d9522ea14399de83e75a11fb6a8887930c3679feb383301c28070d9", size = 217700, upload-time = "2025-11-07T10:52:11.348Z" },
{ url = "https://files.pythonhosted.org/packages/ca/d1/7f645fc2eccd318369a8a9948acc447bb7c1ade2911e31d3c5620544c22b/coverage-7.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:05791e528a18f7072bf5998ba772fe29db4da1234c45c2087866b5ba4dea710e", size = 217071, upload-time = "2025-10-15T15:14:42.755Z" }, { url = "https://files.pythonhosted.org/packages/94/10/a4fc5022017dd7ac682dc423849c241dfbdad31734b8f96060d84e70b587/coverage-7.11.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f69c332f0c3d1357c74decc9b1843fcd428cf9221bf196a20ad22aa1db3e1b6c", size = 217968, upload-time = "2025-11-07T10:52:13.203Z" },
{ url = "https://files.pythonhosted.org/packages/54/7d/64d124649db2737ceced1dfcbdcb79898d5868d311730f622f8ecae84250/coverage-7.11.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cacb29f420cfeb9283b803263c3b9a068924474ff19ca126ba9103e1278dfa44", size = 258570, upload-time = "2025-10-15T15:14:44.542Z" }, { url = "https://files.pythonhosted.org/packages/59/2d/a554cd98924d296de5816413280ac3b09e42a05fb248d66f8d474d321938/coverage-7.11.1-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:576baeea4eebde684bf6c91c01e97171c8015765c8b2cfd4022a42b899897811", size = 259334, upload-time = "2025-11-07T10:52:15.079Z" },
{ url = "https://files.pythonhosted.org/packages/6c/3f/6f5922f80dc6f2d8b2c6f974835c43f53eb4257a7797727e6ca5b7b2ec1f/coverage-7.11.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:314c24e700d7027ae3ab0d95fbf8d53544fca1f20345fd30cd219b737c6e58d3", size = 260738, upload-time = "2025-10-15T15:14:46.436Z" }, { url = "https://files.pythonhosted.org/packages/05/98/d484cb659ec33958ca96b6f03438f56edc23b239d1ad0417b7a97fc1848a/coverage-7.11.1-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:28ad84c694fa86084cfd3c1eab4149844b8cb95bd8e5cbfc4a647f3ee2cce2b3", size = 261445, upload-time = "2025-11-07T10:52:17.134Z" },
{ url = "https://files.pythonhosted.org/packages/0e/5f/9e883523c4647c860b3812b417a2017e361eca5b635ee658387dc11b13c1/coverage-7.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:630d0bd7a293ad2fc8b4b94e5758c8b2536fdf36c05f1681270203e463cbfa9b", size = 262994, upload-time = "2025-10-15T15:14:48.3Z" }, { url = "https://files.pythonhosted.org/packages/f3/fa/920cba122cc28f4557c0507f8bd7c6e527ebcc537d0309186f66464a8fd9/coverage-7.11.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b1043ff958f09fc3f552c014d599f3c6b7088ba97d7bc1bd1cce8603cd75b520", size = 263858, upload-time = "2025-11-07T10:52:19.836Z" },
{ url = "https://files.pythonhosted.org/packages/07/bb/43b5a8e94c09c8bf51743ffc65c4c841a4ca5d3ed191d0a6919c379a1b83/coverage-7.11.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e89641f5175d65e2dbb44db15fe4ea48fade5d5bbb9868fdc2b4fce22f4a469d", size = 257282, upload-time = "2025-10-15T15:14:50.236Z" }, { url = "https://files.pythonhosted.org/packages/2a/a0/036397bdbee0f3bd46c2e26fdfbb1a61b2140bf9059240c37b61149047fa/coverage-7.11.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c6681add5060c2742dafcf29826dff1ff8eef889a3b03390daeed84361c428bd", size = 258381, upload-time = "2025-11-07T10:52:21.687Z" },
{ url = "https://files.pythonhosted.org/packages/aa/e5/0ead8af411411330b928733e1d201384b39251a5f043c1612970310e8283/coverage-7.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c9f08ea03114a637dab06cedb2e914da9dc67fa52c6015c018ff43fdde25b9c2", size = 260430, upload-time = "2025-10-15T15:14:52.413Z" }, { url = "https://files.pythonhosted.org/packages/b6/61/2533926eb8990f182eb287f4873216c8ca530cc47241144aabf46fe80abe/coverage-7.11.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:773419b225ec9a75caa1e941dd0c83a91b92c2b525269e44e6ee3e4c630607db", size = 261321, upload-time = "2025-11-07T10:52:23.612Z" },
{ url = "https://files.pythonhosted.org/packages/ae/66/03dd8bb0ba5b971620dcaac145461950f6d8204953e535d2b20c6b65d729/coverage-7.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:ce9f3bde4e9b031eaf1eb61df95c1401427029ea1bfddb8621c1161dcb0fa02e", size = 258190, upload-time = "2025-10-15T15:14:54.268Z" }, { url = "https://files.pythonhosted.org/packages/32/6e/618f7e203a998e4f6b8a0fa395744a416ad2adbcdc3735bc19466456718a/coverage-7.11.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a9cb272a0e0157dbb9b2fd0b201b759bd378a1a6138a16536c025c2ce4f7643b", size = 258933, upload-time = "2025-11-07T10:52:25.514Z" },
{ url = "https://files.pythonhosted.org/packages/45/ae/28a9cce40bf3174426cb2f7e71ee172d98e7f6446dff936a7ccecee34b14/coverage-7.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e4dc07e95495923d6fd4d6c27bf70769425b71c89053083843fd78f378558996", size = 256658, upload-time = "2025-10-15T15:14:56.436Z" }, { url = "https://files.pythonhosted.org/packages/22/40/6b1c27f772cb08a14a338647ead1254a57ee9dabbb4cacbc15df7f278741/coverage-7.11.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:e09adb2a7811dc75998eef68f47599cf699e2b62eed09c9fefaeb290b3920f34", size = 257756, upload-time = "2025-11-07T10:52:27.845Z" },
{ url = "https://files.pythonhosted.org/packages/5c/7c/3a44234a8599513684bfc8684878fd7b126c2760f79712bb78c56f19efc4/coverage-7.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:424538266794db2861db4922b05d729ade0940ee69dcf0591ce8f69784db0e11", size = 259342, upload-time = "2025-10-15T15:14:58.538Z" }, { url = "https://files.pythonhosted.org/packages/73/07/f9cd12f71307a785ea15b009c8d8cc2543e4a867bd04b8673843970b6b43/coverage-7.11.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1335fa8c2a2fea49924d97e1e3500cfe8d7c849f5369f26bb7559ad4259ccfab", size = 260086, upload-time = "2025-11-07T10:52:29.776Z" },
{ url = "https://files.pythonhosted.org/packages/e1/e6/0108519cba871af0351725ebdb8660fd7a0fe2ba3850d56d32490c7d9b4b/coverage-7.11.0-cp314-cp314t-win32.whl", hash = "sha256:4c1eeb3fb8eb9e0190bebafd0462936f75717687117339f708f395fe455acc73", size = 219568, upload-time = "2025-10-15T15:15:00.382Z" }, { url = "https://files.pythonhosted.org/packages/34/02/31c5394f6f5d72a466966bcfdb61ce5a19862d452816d6ffcbb44add16ee/coverage-7.11.1-cp314-cp314t-win32.whl", hash = "sha256:4782d71d2a4fa7cef95e853b7097c8bbead4dbd0e6f9c7152a6b11a194b794db", size = 220483, upload-time = "2025-11-07T10:52:31.752Z" },
{ url = "https://files.pythonhosted.org/packages/c9/76/44ba876e0942b4e62fdde23ccb029ddb16d19ba1bef081edd00857ba0b16/coverage-7.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b56efee146c98dbf2cf5cffc61b9829d1e94442df4d7398b26892a53992d3547", size = 220687, upload-time = "2025-10-15T15:15:02.322Z" }, { url = "https://files.pythonhosted.org/packages/7f/96/81e1ef5fbfd5090113a96e823dbe055e4c58d96ca73b1fb0ad9d26f9ec36/coverage-7.11.1-cp314-cp314t-win_amd64.whl", hash = "sha256:939f45e66eceb63c75e8eb8fc58bb7077c00f1a41b0e15c6ef02334a933cfe93", size = 221592, upload-time = "2025-11-07T10:52:33.724Z" },
{ url = "https://files.pythonhosted.org/packages/b9/0c/0df55ecb20d0d0ed5c322e10a441775e1a3a5d78c60f0c4e1abfe6fcf949/coverage-7.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b5c2705afa83f49bd91962a4094b6b082f94aef7626365ab3f8f4bd159c5acf3", size = 218711, upload-time = "2025-10-15T15:15:04.575Z" }, { url = "https://files.pythonhosted.org/packages/38/7a/a5d050de44951ac453a2046a0f3fb5471a4a557f0c914d00db27d543d94c/coverage-7.11.1-cp314-cp314t-win_arm64.whl", hash = "sha256:01c575bdbef35e3f023b50a146e9a75c53816e4f2569109458155cd2315f87d9", size = 219627, upload-time = "2025-11-07T10:52:36.285Z" },
{ url = "https://files.pythonhosted.org/packages/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" }, { url = "https://files.pythonhosted.org/packages/76/32/bd9f48c28e23b2f08946f8e83983617b00619f5538dbd7e1045fa7e88c00/coverage-7.11.1-py3-none-any.whl", hash = "sha256:0fa848acb5f1da24765cee840e1afe9232ac98a8f9431c6112c15b34e880b9e8", size = 208689, upload-time = "2025-11-07T10:52:38.646Z" },
] ]
[[package]] [[package]]
@ -568,28 +568,28 @@ wheels = [
[[package]] [[package]]
name = "ruff" name = "ruff"
version = "0.14.3" version = "0.14.4"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/75/62/50b7727004dfe361104dfbf898c45a9a2fdfad8c72c04ae62900224d6ecf/ruff-0.14.3.tar.gz", hash = "sha256:4ff876d2ab2b161b6de0aa1f5bd714e8e9b4033dc122ee006925fbacc4f62153", size = 5558687, upload-time = "2025-10-31T00:26:26.878Z" } sdist = { url = "https://files.pythonhosted.org/packages/df/55/cccfca45157a2031dcbb5a462a67f7cf27f8b37d4b3b1cd7438f0f5c1df6/ruff-0.14.4.tar.gz", hash = "sha256:f459a49fe1085a749f15414ca76f61595f1a2cc8778ed7c279b6ca2e1fd19df3", size = 5587844, upload-time = "2025-11-06T22:07:45.033Z" }
wheels = [ wheels = [
{ url = "https://files.pythonhosted.org/packages/ce/8e/0c10ff1ea5d4360ab8bfca4cb2c9d979101a391f3e79d2616c9bf348cd26/ruff-0.14.3-py3-none-linux_armv6l.whl", hash = "sha256:876b21e6c824f519446715c1342b8e60f97f93264012de9d8d10314f8a79c371", size = 12535613, upload-time = "2025-10-31T00:25:44.302Z" }, { url = "https://files.pythonhosted.org/packages/17/b9/67240254166ae1eaa38dec32265e9153ac53645a6c6670ed36ad00722af8/ruff-0.14.4-py3-none-linux_armv6l.whl", hash = "sha256:e6604613ffbcf2297cd5dcba0e0ac9bd0c11dc026442dfbb614504e87c349518", size = 12606781, upload-time = "2025-11-06T22:07:01.841Z" },
{ url = "https://files.pythonhosted.org/packages/d3/c8/6724f4634c1daf52409fbf13fefda64aa9c8f81e44727a378b7b73dc590b/ruff-0.14.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:b6fd8c79b457bedd2abf2702b9b472147cd860ed7855c73a5247fa55c9117654", size = 12855812, upload-time = "2025-10-31T00:25:47.793Z" }, { url = "https://files.pythonhosted.org/packages/46/c8/09b3ab245d8652eafe5256ab59718641429f68681ee713ff06c5c549f156/ruff-0.14.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:d99c0b52b6f0598acede45ee78288e5e9b4409d1ce7f661f0fa36d4cbeadf9a4", size = 12946765, upload-time = "2025-11-06T22:07:05.858Z" },
{ url = "https://files.pythonhosted.org/packages/de/03/db1bce591d55fd5f8a08bb02517fa0b5097b2ccabd4ea1ee29aa72b67d96/ruff-0.14.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:71ff6edca490c308f083156938c0c1a66907151263c4abdcb588602c6e696a14", size = 11944026, upload-time = "2025-10-31T00:25:49.657Z" }, { url = "https://files.pythonhosted.org/packages/14/bb/1564b000219144bf5eed2359edc94c3590dd49d510751dad26202c18a17d/ruff-0.14.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:9358d490ec030f1b51d048a7fd6ead418ed0826daf6149e95e30aa67c168af33", size = 11928120, upload-time = "2025-11-06T22:07:08.023Z" },
{ url = "https://files.pythonhosted.org/packages/0b/75/4f8dbd48e03272715d12c87dc4fcaaf21b913f0affa5f12a4e9c6f8a0582/ruff-0.14.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:786ee3ce6139772ff9272aaf43296d975c0217ee1b97538a98171bf0d21f87ed", size = 12356818, upload-time = "2025-10-31T00:25:51.949Z" }, { url = "https://files.pythonhosted.org/packages/a3/92/d5f1770e9988cc0742fefaa351e840d9aef04ec24ae1be36f333f96d5704/ruff-0.14.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:81b40d27924f1f02dfa827b9c0712a13c0e4b108421665322218fc38caf615c2", size = 12370877, upload-time = "2025-11-06T22:07:10.015Z" },
{ url = "https://files.pythonhosted.org/packages/ec/9b/506ec5b140c11d44a9a4f284ea7c14ebf6f8b01e6e8917734a3325bff787/ruff-0.14.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cd6291d0061811c52b8e392f946889916757610d45d004e41140d81fb6cd5ddc", size = 12336745, upload-time = "2025-10-31T00:25:54.248Z" }, { url = "https://files.pythonhosted.org/packages/e2/29/e9282efa55f1973d109faf839a63235575519c8ad278cc87a182a366810e/ruff-0.14.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f5e649052a294fe00818650712083cddc6cc02744afaf37202c65df9ea52efa5", size = 12408538, upload-time = "2025-11-06T22:07:13.085Z" },
{ url = "https://files.pythonhosted.org/packages/c7/e1/c560d254048c147f35e7f8131d30bc1f63a008ac61595cf3078a3e93533d/ruff-0.14.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a497ec0c3d2c88561b6d90f9c29f5ae68221ac00d471f306fa21fa4264ce5fcd", size = 13101684, upload-time = "2025-10-31T00:25:56.253Z" }, { url = "https://files.pythonhosted.org/packages/8e/01/930ed6ecfce130144b32d77d8d69f5c610e6d23e6857927150adf5d7379a/ruff-0.14.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:aa082a8f878deeba955531f975881828fd6afd90dfa757c2b0808aadb437136e", size = 13141942, upload-time = "2025-11-06T22:07:15.386Z" },
{ url = "https://files.pythonhosted.org/packages/a5/32/e310133f8af5cd11f8cc30f52522a3ebccc5ea5bff4b492f94faceaca7a8/ruff-0.14.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:e231e1be58fc568950a04fbe6887c8e4b85310e7889727e2b81db205c45059eb", size = 14535000, upload-time = "2025-10-31T00:25:58.397Z" }, { url = "https://files.pythonhosted.org/packages/6a/46/a9c89b42b231a9f487233f17a89cbef9d5acd538d9488687a02ad288fa6b/ruff-0.14.4-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1043c6811c2419e39011890f14d0a30470f19d47d197c4858b2787dfa698f6c8", size = 14544306, upload-time = "2025-11-06T22:07:17.631Z" },
{ url = "https://files.pythonhosted.org/packages/a2/a1/7b0470a22158c6d8501eabc5e9b6043c99bede40fa1994cadf6b5c2a61c7/ruff-0.14.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:469e35872a09c0e45fecf48dd960bfbce056b5db2d5e6b50eca329b4f853ae20", size = 14156450, upload-time = "2025-10-31T00:26:00.889Z" }, { url = "https://files.pythonhosted.org/packages/78/96/9c6cf86491f2a6d52758b830b89b78c2ae61e8ca66b86bf5a20af73d20e6/ruff-0.14.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a9f3a936ac27fb7c2a93e4f4b943a662775879ac579a433291a6f69428722649", size = 14210427, upload-time = "2025-11-06T22:07:19.832Z" },
{ url = "https://files.pythonhosted.org/packages/0a/96/24bfd9d1a7f532b560dcee1a87096332e461354d3882124219bcaff65c09/ruff-0.14.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d6bc90307c469cb9d28b7cfad90aaa600b10d67c6e22026869f585e1e8a2db0", size = 13568414, upload-time = "2025-10-31T00:26:03.291Z" }, { url = "https://files.pythonhosted.org/packages/71/f4/0666fe7769a54f63e66404e8ff698de1dcde733e12e2fd1c9c6efb689cb5/ruff-0.14.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:95643ffd209ce78bc113266b88fba3d39e0461f0cbc8b55fb92505030fb4a850", size = 13658488, upload-time = "2025-11-06T22:07:22.32Z" },
{ url = "https://files.pythonhosted.org/packages/a7/e7/138b883f0dfe4ad5b76b58bf4ae675f4d2176ac2b24bdd81b4d966b28c61/ruff-0.14.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e2f8a0bbcffcfd895df39c9a4ecd59bb80dca03dc43f7fb63e647ed176b741e", size = 13315293, upload-time = "2025-10-31T00:26:05.708Z" }, { url = "https://files.pythonhosted.org/packages/ee/79/6ad4dda2cfd55e41ac9ed6d73ef9ab9475b1eef69f3a85957210c74ba12c/ruff-0.14.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:456daa2fa1021bc86ca857f43fe29d5d8b3f0e55e9f90c58c317c1dcc2afc7b5", size = 13354908, upload-time = "2025-11-06T22:07:24.347Z" },
{ url = "https://files.pythonhosted.org/packages/33/f4/c09bb898be97b2eb18476b7c950df8815ef14cf956074177e9fbd40b7719/ruff-0.14.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:678fdd7c7d2d94851597c23ee6336d25f9930b460b55f8598e011b57c74fd8c5", size = 13539444, upload-time = "2025-10-31T00:26:08.09Z" }, { url = "https://files.pythonhosted.org/packages/b5/60/f0b6990f740bb15c1588601d19d21bcc1bd5de4330a07222041678a8e04f/ruff-0.14.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:f911bba769e4a9f51af6e70037bb72b70b45a16db5ce73e1f72aefe6f6d62132", size = 13587803, upload-time = "2025-11-06T22:07:26.327Z" },
{ url = "https://files.pythonhosted.org/packages/9c/aa/b30a1db25fc6128b1dd6ff0741fa4abf969ded161599d07ca7edd0739cc0/ruff-0.14.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:1ec1ac071e7e37e0221d2f2dbaf90897a988c531a8592a6a5959f0603a1ecf5e", size = 12252581, upload-time = "2025-10-31T00:26:10.297Z" }, { url = "https://files.pythonhosted.org/packages/c9/da/eaaada586f80068728338e0ef7f29ab3e4a08a692f92eb901a4f06bbff24/ruff-0.14.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76158a7369b3979fa878612c623a7e5430c18b2fd1c73b214945c2d06337db67", size = 12279654, upload-time = "2025-11-06T22:07:28.46Z" },
{ url = "https://files.pythonhosted.org/packages/da/13/21096308f384d796ffe3f2960b17054110a9c3828d223ca540c2b7cc670b/ruff-0.14.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:afcdc4b5335ef440d19e7df9e8ae2ad9f749352190e96d481dc501b753f0733e", size = 12307503, upload-time = "2025-10-31T00:26:12.646Z" }, { url = "https://files.pythonhosted.org/packages/66/d4/b1d0e82cf9bf8aed10a6d45be47b3f402730aa2c438164424783ac88c0ed/ruff-0.14.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:f3b8f3b442d2b14c246e7aeca2e75915159e06a3540e2f4bed9f50d062d24469", size = 12357520, upload-time = "2025-11-06T22:07:31.468Z" },
{ url = "https://files.pythonhosted.org/packages/cb/cc/a350bac23f03b7dbcde3c81b154706e80c6f16b06ff1ce28ed07dc7b07b0/ruff-0.14.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7bfc42f81862749a7136267a343990f865e71fe2f99cf8d2958f684d23ce3dfa", size = 12675457, upload-time = "2025-10-31T00:26:15.044Z" }, { url = "https://files.pythonhosted.org/packages/04/f4/53e2b42cc82804617e5c7950b7079d79996c27e99c4652131c6a1100657f/ruff-0.14.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c62da9a06779deecf4d17ed04939ae8b31b517643b26370c3be1d26f3ef7dbde", size = 12719431, upload-time = "2025-11-06T22:07:33.831Z" },
{ url = "https://files.pythonhosted.org/packages/cb/76/46346029fa2f2078826bc88ef7167e8c198e58fe3126636e52f77488cbba/ruff-0.14.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a65e448cfd7e9c59fae8cf37f9221585d3354febaad9a07f29158af1528e165f", size = 13403980, upload-time = "2025-10-31T00:26:17.81Z" }, { url = "https://files.pythonhosted.org/packages/a2/94/80e3d74ed9a72d64e94a7b7706b1c1ebaa315ef2076fd33581f6a1cd2f95/ruff-0.14.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5a443a83a1506c684e98acb8cb55abaf3ef725078be40237463dae4463366349", size = 13464394, upload-time = "2025-11-06T22:07:35.905Z" },
{ url = "https://files.pythonhosted.org/packages/9f/a4/35f1ef68c4e7b236d4a5204e3669efdeefaef21f0ff6a456792b3d8be438/ruff-0.14.3-py3-none-win32.whl", hash = "sha256:f3d91857d023ba93e14ed2d462ab62c3428f9bbf2b4fbac50a03ca66d31991f7", size = 12500045, upload-time = "2025-10-31T00:26:20.503Z" }, { url = "https://files.pythonhosted.org/packages/54/1a/a49f071f04c42345c793d22f6cf5e0920095e286119ee53a64a3a3004825/ruff-0.14.4-py3-none-win32.whl", hash = "sha256:643b69cb63cd996f1fc7229da726d07ac307eae442dd8974dbc7cf22c1e18fff", size = 12493429, upload-time = "2025-11-06T22:07:38.43Z" },
{ url = "https://files.pythonhosted.org/packages/03/15/51960ae340823c9859fb60c63301d977308735403e2134e17d1d2858c7fb/ruff-0.14.3-py3-none-win_amd64.whl", hash = "sha256:d7b7006ac0756306db212fd37116cce2bd307e1e109375e1c6c106002df0ae5f", size = 13594005, upload-time = "2025-10-31T00:26:22.533Z" }, { url = "https://files.pythonhosted.org/packages/bc/22/e58c43e641145a2b670328fb98bc384e20679b5774258b1e540207580266/ruff-0.14.4-py3-none-win_amd64.whl", hash = "sha256:26673da283b96fe35fa0c939bf8411abec47111644aa9f7cfbd3c573fb125d2c", size = 13635380, upload-time = "2025-11-06T22:07:40.496Z" },
{ url = "https://files.pythonhosted.org/packages/b7/73/4de6579bac8e979fca0a77e54dec1f1e011a0d268165eb8a9bc0982a6564/ruff-0.14.3-py3-none-win_arm64.whl", hash = "sha256:26eb477ede6d399d898791d01961e16b86f02bc2486d0d1a7a9bb2379d055dc1", size = 12590017, upload-time = "2025-10-31T00:26:24.52Z" }, { url = "https://files.pythonhosted.org/packages/30/bd/4168a751ddbbf43e86544b4de8b5c3b7be8d7167a2a5cb977d274e04f0a1/ruff-0.14.4-py3-none-win_arm64.whl", hash = "sha256:dd09c292479596b0e6fec8cd95c65c3a6dc68e9ad17b8f2382130f87ff6a75bb", size = 12663065, upload-time = "2025-11-06T22:07:42.603Z" },
] ]
[[package]] [[package]]

Loading…
Cancel
Save