feat!: complete MQTT and service refactor, add timestamp + event tracking, and new modular mixins
- Rewrote MQTT handling with reconnect, structured logging, and Home Assistant re-discovery triggers - Introduced modular mixins (`helpers`, `mqtt`, `amcrest_api`) for cleaner architecture - Replaced `util.py` with internal mixin helpers and `to_gb()` conversions - Added new `event_text` and `event_time` sensors (with `device_class: timestamp`) - Added support for doorbell and human-detection binary sensors - Improved reconnect logic, last-will handling, and clean shutdown on signals - Overhauled device discovery and state publishing to use unified upsert logic - Simplified event handling and privacy mode inference from motion/human/doorbell events - Introduced `tools/clear_mqtt.sh` for quick topic cleanup - Added full pyproject.toml with lint/test/dev config (Black, Ruff, Pytest) - Embedded full metadata and image labels in Docker build BREAKING CHANGE: Project layout moved to `src/amcrest2mqtt/`, internal class and import paths changed. Users must update configs and volumes to the new structure before deploying.pull/106/head
parent
acb0b6b4e1
commit
e230a7673f
@ -0,0 +1,12 @@
|
|||||||
|
.git
|
||||||
|
.DS_Store
|
||||||
|
node_modules/
|
||||||
|
.venv/
|
||||||
|
__pycache__/
|
||||||
|
.pytest_cache/
|
||||||
|
.ruff_cache/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
tests/
|
||||||
|
config/
|
||||||
@ -1,437 +0,0 @@
|
|||||||
# This software is licensed under the MIT License, which allows you to use,
|
|
||||||
# copy, modify, merge, publish, distribute, and sell copies of the software,
|
|
||||||
# with the requirement to include the original copyright notice and this
|
|
||||||
# permission notice in all copies or substantial portions of the software.
|
|
||||||
#
|
|
||||||
# The software is provided 'as is', without any warranty.
|
|
||||||
|
|
||||||
from amcrest import AmcrestCamera, AmcrestError, CommError, LoginError
|
|
||||||
import asyncio
|
|
||||||
from concurrent.futures import ProcessPoolExecutor
|
|
||||||
import base64
|
|
||||||
import logging
|
|
||||||
import signal
|
|
||||||
from util import get_ip_address, to_gb
|
|
||||||
|
|
||||||
|
|
||||||
class AmcrestAPI:
|
|
||||||
def __init__(self, config):
|
|
||||||
self.logger = logging.getLogger(__name__)
|
|
||||||
|
|
||||||
# Quiet down noisy loggers
|
|
||||||
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)
|
|
||||||
|
|
||||||
self.timezone = config["timezone"]
|
|
||||||
self.amcrest_config = config["amcrest"]
|
|
||||||
self.devices = {}
|
|
||||||
self.events = []
|
|
||||||
|
|
||||||
self.executor = ProcessPoolExecutor(
|
|
||||||
max_workers=min(8, len(self.amcrest_config["hosts"]))
|
|
||||||
)
|
|
||||||
# handle signals gracefully
|
|
||||||
signal.signal(signal.SIGINT, self._sig_handler)
|
|
||||||
signal.signal(signal.SIGTERM, self._sig_handler)
|
|
||||||
self._shutting_down = False
|
|
||||||
|
|
||||||
def _sig_handler(self, signum, frame):
|
|
||||||
if not self._shutting_down:
|
|
||||||
self._shutting_down = True
|
|
||||||
self.logger.warning("SIGINT received — shutting down process pool...")
|
|
||||||
self.shutdown()
|
|
||||||
|
|
||||||
def shutdown(self):
|
|
||||||
"""Instantly kill process pool workers."""
|
|
||||||
try:
|
|
||||||
if self.executor:
|
|
||||||
self.logger.debug("Force-terminating process pool workers...")
|
|
||||||
self.executor.shutdown(wait=False, cancel_futures=True)
|
|
||||||
self.executor = None
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.warning(f"Error shutting down process pool: {e}")
|
|
||||||
|
|
||||||
def get_camera(self, host):
|
|
||||||
cfg = self.amcrest_config
|
|
||||||
return AmcrestCamera(
|
|
||||||
host, cfg["port"], cfg["username"], cfg["password"], verbose=False
|
|
||||||
).camera
|
|
||||||
|
|
||||||
# ----------------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def connect_to_devices(self):
|
|
||||||
self.logger.info(f"Connecting to: {self.amcrest_config['hosts']}")
|
|
||||||
|
|
||||||
# Defensive guard against shutdown signals
|
|
||||||
if getattr(self, "shutting_down", False):
|
|
||||||
self.logger.warning("Connect aborted: shutdown already in progress.")
|
|
||||||
return {}
|
|
||||||
|
|
||||||
tasks = [
|
|
||||||
asyncio.create_task(self._connect_device_threaded(host, name))
|
|
||||||
for host, name in zip(
|
|
||||||
self.amcrest_config["hosts"], self.amcrest_config["names"]
|
|
||||||
)
|
|
||||||
]
|
|
||||||
|
|
||||||
try:
|
|
||||||
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
self.logger.warning("Device connection cancelled by signal.")
|
|
||||||
return {}
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Device connection failed: {e}", exc_info=True)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
successes = [r for r in results if isinstance(r, dict) and "config" in r]
|
|
||||||
failures = [r for r in results if isinstance(r, dict) and "error" in r]
|
|
||||||
|
|
||||||
self.logger.info(
|
|
||||||
f"Device connection summary: {len(successes)} succeeded, {len(failures)} failed"
|
|
||||||
)
|
|
||||||
|
|
||||||
if not successes and not getattr(self, "shutting_down", False):
|
|
||||||
self.logger.error("Failed to connect to any devices, exiting")
|
|
||||||
exit(1)
|
|
||||||
|
|
||||||
# Recreate cameras in parent process
|
|
||||||
for info in successes:
|
|
||||||
cfg = info["config"]
|
|
||||||
serial = cfg["serial_number"]
|
|
||||||
cam = self.get_camera(cfg["host_ip"])
|
|
||||||
self.devices[serial] = {"camera": cam, "config": cfg}
|
|
||||||
|
|
||||||
return {d: self.devices[d]["config"] for d in self.devices.keys()}
|
|
||||||
|
|
||||||
async def _connect_device_threaded(self, host, device_name):
|
|
||||||
"""Run the blocking camera connection logic in a separate process."""
|
|
||||||
loop = asyncio.get_running_loop()
|
|
||||||
return await loop.run_in_executor(
|
|
||||||
self.executor, _connect_device_worker, (host, device_name, self.amcrest_config)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _connect_device_sync(self, host, device_name):
|
|
||||||
"""Blocking version of connect logic that runs in a separate process."""
|
|
||||||
try:
|
|
||||||
import multiprocessing
|
|
||||||
p_name = multiprocessing.current_process().name
|
|
||||||
|
|
||||||
host_ip = get_ip_address(host)
|
|
||||||
camera = self.get_camera(host_ip)
|
|
||||||
|
|
||||||
device_type = camera.device_type.replace("type=", "").strip()
|
|
||||||
is_ad110 = device_type == "AD110"
|
|
||||||
is_ad410 = device_type == "AD410"
|
|
||||||
is_doorbell = is_ad110 or is_ad410
|
|
||||||
|
|
||||||
serial_number = camera.serial_number
|
|
||||||
version = camera.software_information[0].replace("version=", "").strip()
|
|
||||||
build = camera.software_information[1].strip()
|
|
||||||
sw_version = f"{version} ({build})"
|
|
||||||
|
|
||||||
network_config = dict(
|
|
||||||
item.split("=") for item in camera.network_config.splitlines()
|
|
||||||
)
|
|
||||||
iface = network_config["table.Network.DefaultInterface"]
|
|
||||||
ip_address = network_config[f"table.Network.{iface}.IPAddress"]
|
|
||||||
mac_address = network_config[f"table.Network.{iface}.PhysicalAddress"].upper()
|
|
||||||
|
|
||||||
print(f"[{p_name}] Connected to {host} ({ip_address}) as {serial_number}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"config": {
|
|
||||||
"host": host,
|
|
||||||
"host_ip": host_ip,
|
|
||||||
"device_name": device_name,
|
|
||||||
"device_type": device_type,
|
|
||||||
"device_class": camera.device_class,
|
|
||||||
"is_ad110": is_ad110,
|
|
||||||
"is_ad410": is_ad410,
|
|
||||||
"is_doorbell": is_doorbell,
|
|
||||||
"serial_number": serial_number,
|
|
||||||
"software_version": sw_version,
|
|
||||||
"hardware_version": camera.hardware_version,
|
|
||||||
"vendor": camera.vendor_information,
|
|
||||||
"network": {
|
|
||||||
"interface": iface,
|
|
||||||
"ip_address": ip_address,
|
|
||||||
"mac": mac_address,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
err_trace = traceback.format_exc()
|
|
||||||
print(f"[child] Error connecting to {host}: {e}\n{err_trace}")
|
|
||||||
return {"error": f"{e}", "host": host}
|
|
||||||
|
|
||||||
# Storage stats -------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_storage_stats(self, device_id):
|
|
||||||
try:
|
|
||||||
storage = self.devices[device_id]["camera"].storage_all
|
|
||||||
except CommError as err:
|
|
||||||
self.logger.error(f'Failed to communicate with device ({device_id}) for storage stats')
|
|
||||||
except LoginError as err:
|
|
||||||
self.logger.error(f'Failed to authenticate with device ({device_id}) for storage stats')
|
|
||||||
|
|
||||||
return {
|
|
||||||
'used_percent': str(storage['used_percent']),
|
|
||||||
'used': to_gb(storage['used']),
|
|
||||||
'total': to_gb(storage['total']),
|
|
||||||
}
|
|
||||||
|
|
||||||
# Privacy config ------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_privacy_mode(self, device_id):
|
|
||||||
device = self.devices[device_id]
|
|
||||||
|
|
||||||
try:
|
|
||||||
privacy = device["camera"].privacy_config().split()
|
|
||||||
privacy_mode = True if privacy[0].split('=')[1] == 'true' else False
|
|
||||||
device['privacy_mode'] = privacy_mode
|
|
||||||
except CommError as err:
|
|
||||||
self.logger.error(f'Failed to communicate with device ({device_id}) to get privacy mode')
|
|
||||||
except LoginError as err:
|
|
||||||
self.logger.error(f'Failed to authenticate with device ({device_id}) to get privacy mode')
|
|
||||||
|
|
||||||
return privacy_mode
|
|
||||||
|
|
||||||
def set_privacy_mode(self, device_id, switch):
|
|
||||||
device = self.devices[device_id]
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = device["camera"].set_privacy(switch).strip()
|
|
||||||
except CommError as err:
|
|
||||||
self.logger.error(f'Failed to communicate with device ({device_id}) to set privacy mode')
|
|
||||||
except LoginError as err:
|
|
||||||
self.logger.error(f'Failed to authenticate with device ({device_id}) to set privacy mode')
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Motion detection config ---------------------------------------------------------------------
|
|
||||||
|
|
||||||
def get_motion_detection(self, device_id):
|
|
||||||
device = self.devices[device_id]
|
|
||||||
|
|
||||||
try:
|
|
||||||
motion_detection = device["camera"].is_motion_detector_on()
|
|
||||||
except CommError as err:
|
|
||||||
self.logger.error(f'Failed to communicate with device ({device_id}) to get motion detection')
|
|
||||||
except LoginError as err:
|
|
||||||
self.logger.error(f'Failed to authenticate with device ({device_id}) to get motion detection')
|
|
||||||
|
|
||||||
return motion_detection
|
|
||||||
|
|
||||||
def set_motion_detection(self, device_id, switch):
|
|
||||||
device = self.devices[device_id]
|
|
||||||
|
|
||||||
try:
|
|
||||||
response = device["camera"].set_motion_detection(switch)
|
|
||||||
except CommError as err:
|
|
||||||
self.logger.error(f'Failed to communicate with device ({device_id}) to set motion detections')
|
|
||||||
except LoginError as err:
|
|
||||||
self.logger.error(f'Failed to authenticate with device ({device_id}) to set motion detections')
|
|
||||||
|
|
||||||
return response
|
|
||||||
|
|
||||||
# Snapshots -----------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def collect_all_device_snapshots(self):
|
|
||||||
tasks = [self.get_snapshot_from_device(device_id) for device_id in self.devices]
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
async def get_snapshot_from_device(self, device_id):
|
|
||||||
device = self.devices[device_id]
|
|
||||||
|
|
||||||
tries = 0
|
|
||||||
while tries < 3:
|
|
||||||
try:
|
|
||||||
if 'privacy_mode' not in device or device['privacy_mode'] == False:
|
|
||||||
image = await device["camera"].async_snapshot()
|
|
||||||
device['snapshot'] = base64.b64encode(image)
|
|
||||||
self.logger.debug(f'Processed snapshot from ({device_id}) {len(image)} bytes raw, and {len(device['snapshot'])} bytes base64')
|
|
||||||
break
|
|
||||||
else:
|
|
||||||
self.logger.info(f'Skipped snapshot from ({device_id}) because "privacy mode" is ON')
|
|
||||||
break
|
|
||||||
except CommError as err:
|
|
||||||
tries += 1
|
|
||||||
except LoginError as err:
|
|
||||||
tries += 1
|
|
||||||
|
|
||||||
if tries == 3:
|
|
||||||
self.logger.error(f'Failed to communicate with device ({device_id}) to get snapshot')
|
|
||||||
|
|
||||||
def get_snapshot(self, device_id):
|
|
||||||
return self.devices[device_id]['snapshot'] if 'snapshot' in self.devices[device_id] else None
|
|
||||||
|
|
||||||
# Recorded file -------------------------------------------------------------------------------
|
|
||||||
def get_recorded_file(self, device_id, file):
|
|
||||||
device = self.devices[device_id]
|
|
||||||
|
|
||||||
tries = 0
|
|
||||||
while tries < 3:
|
|
||||||
try:
|
|
||||||
data_raw = device["camera"].download_file(file)
|
|
||||||
if data_raw:
|
|
||||||
data_base64 = base64.b64encode(data_raw)
|
|
||||||
self.logger.info(f'Processed recording from ({device_id}) {len(data_raw)} bytes raw, and {len(data_base64)} bytes base64')
|
|
||||||
if len(data_base64) < 100 * 1024 * 1024 * 1024:
|
|
||||||
return data_base64
|
|
||||||
else:
|
|
||||||
self.logger.error(f'Processed recording is too large')
|
|
||||||
return
|
|
||||||
except CommError as err:
|
|
||||||
tries += 1
|
|
||||||
except LoginError as err:
|
|
||||||
tries += 1
|
|
||||||
|
|
||||||
if tries == 3:
|
|
||||||
self.logger.error(f'Failed to communicate with device ({device_id}) to get recorded file')
|
|
||||||
|
|
||||||
# Events --------------------------------------------------------------------------------------
|
|
||||||
|
|
||||||
async def collect_all_device_events(self):
|
|
||||||
tasks = [self.get_events_from_device(device_id) for device_id in self.devices]
|
|
||||||
await asyncio.gather(*tasks)
|
|
||||||
|
|
||||||
async def get_events_from_device(self, device_id):
|
|
||||||
device = self.devices[device_id]
|
|
||||||
|
|
||||||
tries = 0
|
|
||||||
while tries < 3:
|
|
||||||
try:
|
|
||||||
async for code, payload in device["camera"].async_event_actions("All"):
|
|
||||||
await self.process_device_event(device_id, code, payload)
|
|
||||||
except CommError as err:
|
|
||||||
tries += 1
|
|
||||||
except LoginError as err:
|
|
||||||
tries += 1
|
|
||||||
|
|
||||||
if tries == 3:
|
|
||||||
self.logger.error(f'Failed to communicate for events for device ({device_id})')
|
|
||||||
|
|
||||||
async def process_device_event(self, device_id, code, payload):
|
|
||||||
try:
|
|
||||||
device = self.devices[device_id]
|
|
||||||
config = device['config']
|
|
||||||
|
|
||||||
# if code != 'NewFile' and code != 'InterVideoAccess':
|
|
||||||
# self.logger.info(f'Event on {device_id} - {code}: {payload}')
|
|
||||||
|
|
||||||
if ((code == 'ProfileAlarmTransmit' and config['is_ad110'])
|
|
||||||
or (code == 'VideoMotion' and not config['is_ad110'])):
|
|
||||||
motion_payload = {
|
|
||||||
'state': 'on' if payload['action'] == 'Start' else 'off',
|
|
||||||
'region': ', '.join(payload['data']['RegionName'])
|
|
||||||
}
|
|
||||||
self.events.append({ 'device_id': device_id, 'event': 'motion', 'payload': motion_payload })
|
|
||||||
elif code == 'CrossRegionDetection' and payload['data']['ObjectType'] == 'Human':
|
|
||||||
human_payload = 'on' if payload['action'] == 'Start' else 'off'
|
|
||||||
self.events.append({ 'device_id': device_id, 'event': 'human', 'payload': human_payload })
|
|
||||||
elif code == '_DoTalkAction_':
|
|
||||||
doorbell_payload = 'on' if payload['data']['Action'] == 'Invite' else 'off'
|
|
||||||
self.events.append({ 'device_id': device_id, 'event': 'doorbell', 'payload': doorbell_payload })
|
|
||||||
elif code == 'NewFile':
|
|
||||||
if ('File' in payload['data'] and '[R]' not in payload['data']['File']
|
|
||||||
and ('StoragePoint' not in payload['data'] or payload['data']['StoragePoint'] != 'Temporary')):
|
|
||||||
file_payload = { 'file': payload['data']['File'], 'size': payload['data']['Size'] }
|
|
||||||
self.events.append({ 'device_id': device_id, 'event': 'recording', 'payload': file_payload })
|
|
||||||
elif code == 'LensMaskOpen':
|
|
||||||
device['privacy_mode'] = True
|
|
||||||
self.events.append({ 'device_id': device_id, 'event': 'privacy_mode', 'payload': 'on' })
|
|
||||||
elif code == 'LensMaskClose':
|
|
||||||
device['privacy_mode'] = False
|
|
||||||
self.events.append({ 'device_id': device_id, 'event': 'privacy_mode', 'payload': 'off' })
|
|
||||||
# lets send these but not bother logging them here
|
|
||||||
elif code == 'TimeChange':
|
|
||||||
self.events.append({ 'device_id': device_id, 'event': code , 'payload': payload['action'] })
|
|
||||||
elif code == 'NTPAdjustTime':
|
|
||||||
self.events.append({ 'device_id': device_id, 'event': code , 'payload': payload['action'] })
|
|
||||||
elif code == 'RtspSessionDisconnect':
|
|
||||||
self.events.append({ 'device_id': device_id, 'event': code , 'payload': payload['action'] })
|
|
||||||
# lets just ignore these
|
|
||||||
elif code == 'InterVideoAccess': # I think this is US, accessing the API of the camera, lets not inception!
|
|
||||||
pass
|
|
||||||
elif code == 'VideoMotionInfo':
|
|
||||||
pass
|
|
||||||
# save everything else as a 'generic' event
|
|
||||||
else:
|
|
||||||
self.logger.info(f'Event on {device_id} - {code}: {payload}')
|
|
||||||
self.events.append({ 'device_id': device_id, 'event': code , 'payload': payload })
|
|
||||||
except Exception as err:
|
|
||||||
self.logger.error(f'Failed to process event from {device_id}: {err}', exc_info=True)
|
|
||||||
|
|
||||||
def get_next_event(self):
|
|
||||||
return self.events.pop(0) if len(self.events) > 0 else None
|
|
||||||
|
|
||||||
|
|
||||||
def _connect_device_worker(args):
|
|
||||||
"""Top-level helper so it can be pickled by ProcessPoolExecutor."""
|
|
||||||
|
|
||||||
signal.signal(signal.SIGINT, signal.SIG_IGN)
|
|
||||||
|
|
||||||
host, device_name, amcrest_cfg = args
|
|
||||||
|
|
||||||
try:
|
|
||||||
host_ip = get_ip_address(host)
|
|
||||||
camera = AmcrestCamera(
|
|
||||||
host_ip,
|
|
||||||
amcrest_cfg["port"],
|
|
||||||
amcrest_cfg["username"],
|
|
||||||
amcrest_cfg["password"],
|
|
||||||
verbose=False,
|
|
||||||
).camera
|
|
||||||
|
|
||||||
device_type = camera.device_type.replace("type=", "").strip()
|
|
||||||
is_ad110 = device_type == "AD110"
|
|
||||||
is_ad410 = device_type == "AD410"
|
|
||||||
is_doorbell = is_ad110 or is_ad410
|
|
||||||
|
|
||||||
serial_number = camera.serial_number
|
|
||||||
version = camera.software_information[0].replace("version=", "").strip()
|
|
||||||
build = camera.software_information[1].strip()
|
|
||||||
sw_version = f"{version} ({build})"
|
|
||||||
|
|
||||||
network_config = dict(
|
|
||||||
item.split("=") for item in camera.network_config.splitlines()
|
|
||||||
)
|
|
||||||
iface = network_config["table.Network.DefaultInterface"]
|
|
||||||
ip_address = network_config[f"table.Network.{iface}.IPAddress"]
|
|
||||||
mac_address = network_config[f"table.Network.{iface}.PhysicalAddress"].upper()
|
|
||||||
|
|
||||||
print(f"[worker] Connected to {host} ({ip_address}) as {serial_number}")
|
|
||||||
|
|
||||||
return {
|
|
||||||
"config": {
|
|
||||||
"host": host,
|
|
||||||
"host_ip": host_ip,
|
|
||||||
"device_name": device_name,
|
|
||||||
"device_type": device_type,
|
|
||||||
"device_class": camera.device_class,
|
|
||||||
"is_ad110": is_ad110,
|
|
||||||
"is_ad410": is_ad410,
|
|
||||||
"is_doorbell": is_doorbell,
|
|
||||||
"serial_number": serial_number,
|
|
||||||
"software_version": sw_version,
|
|
||||||
"hardware_version": camera.hardware_version,
|
|
||||||
"vendor": camera.vendor_information,
|
|
||||||
"network": {
|
|
||||||
"interface": iface,
|
|
||||||
"ip_address": ip_address,
|
|
||||||
"mac": mac_address,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
import traceback
|
|
||||||
|
|
||||||
err_trace = traceback.format_exc()
|
|
||||||
print(f"[worker] Error connecting to {host}: {e}\n{err_trace}")
|
|
||||||
return {"error": str(e), "host": host}
|
|
||||||
@ -1,61 +0,0 @@
|
|||||||
#!/usr/bin/env python3
|
|
||||||
import asyncio
|
|
||||||
import argparse
|
|
||||||
import logging
|
|
||||||
from amcrest_mqtt import AmcrestMqtt
|
|
||||||
from util import load_config
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
|
||||||
# Parse command-line arguments
|
|
||||||
argparser = argparse.ArgumentParser()
|
|
||||||
argparser.add_argument(
|
|
||||||
"-c",
|
|
||||||
"--config",
|
|
||||||
required=False,
|
|
||||||
help="Directory or file path for config.yaml (defaults to /config/config.yaml)",
|
|
||||||
)
|
|
||||||
args = argparser.parse_args()
|
|
||||||
|
|
||||||
# Load configuration
|
|
||||||
config = load_config(args.config)
|
|
||||||
|
|
||||||
# Setup logging
|
|
||||||
logging.basicConfig(
|
|
||||||
format=(
|
|
||||||
"%(asctime)s.%(msecs)03d [%(levelname)s] %(name)s: %(message)s"
|
|
||||||
if not config["hide_ts"]
|
|
||||||
else "[%(levelname)s] %(name)s: %(message)s"
|
|
||||||
),
|
|
||||||
datefmt="%Y-%m-%d %H:%M:%S",
|
|
||||||
level=logging.DEBUG if config["debug"] else logging.INFO,
|
|
||||||
)
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
logger.info(f"Starting amcrest2mqtt {config['version']}")
|
|
||||||
logger.info(f"Config loaded from {config['config_from']} ({config['config_path']})")
|
|
||||||
|
|
||||||
# Run main loop safely
|
|
||||||
try:
|
|
||||||
with AmcrestMqtt(config) as mqtt:
|
|
||||||
try:
|
|
||||||
# Prefer a clean async run, but handle nested event loops
|
|
||||||
asyncio.run(mqtt.main_loop())
|
|
||||||
except RuntimeError as e:
|
|
||||||
if "asyncio.run() cannot be called from a running event loop" in str(e):
|
|
||||||
loop = asyncio.get_event_loop()
|
|
||||||
loop.run_until_complete(mqtt.main_loop())
|
|
||||||
else:
|
|
||||||
raise
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
logger.info("Shutdown requested (Ctrl+C). Exiting gracefully...")
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
logger.warning("Main loop cancelled.")
|
|
||||||
except Exception as e:
|
|
||||||
logger.exception(f"Unhandled exception in main loop: {e}")
|
|
||||||
finally:
|
|
||||||
try:
|
|
||||||
if "mqtt" in locals() and hasattr(mqtt, "api") and mqtt.api:
|
|
||||||
mqtt.api.shutdown()
|
|
||||||
except Exception as e:
|
|
||||||
logger.debug(f"Error during shutdown: {e}")
|
|
||||||
logger.info("amcrest2mqtt stopped.")
|
|
||||||
@ -0,0 +1,151 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68", "setuptools-scm>=8", "wheel"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "amcrest2mqtt"
|
||||||
|
dynamic = ["version"]
|
||||||
|
license = "MIT"
|
||||||
|
license-files = ["LICENSE"]
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
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",
|
||||||
|
]
|
||||||
|
|
||||||
|
[project.scripts]
|
||||||
|
amcrest2mqtt = "amcrest2mqtt.app:main"
|
||||||
|
|
||||||
|
[project.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
# linting/formating
|
||||||
|
"black>=24.10.0",
|
||||||
|
"ruff>=0.6.9",
|
||||||
|
|
||||||
|
# testing/coverage
|
||||||
|
"pytest>=8.4.2",
|
||||||
|
"pytest-asyncio>=1.2.0",
|
||||||
|
"pytest-cov>=7.0.0",
|
||||||
|
|
||||||
|
# misc
|
||||||
|
"jsonschema>=4.25.1",
|
||||||
|
"packaging>=25.0",
|
||||||
|
"attrs>=25.4.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
package-dir = {"" = "src"}
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
qtt = ["VERSION"]
|
||||||
|
|
||||||
|
[tool.setuptools.packages.find]
|
||||||
|
where = ["src"]
|
||||||
|
include = ["amcrest2mqtt*"]
|
||||||
|
|
||||||
|
[tool.setuptools_scm]
|
||||||
|
|
||||||
|
[tool.ruff]
|
||||||
|
# Exclude a variety of commonly ignored directories.
|
||||||
|
exclude = [
|
||||||
|
".bzr",
|
||||||
|
".direnv",
|
||||||
|
".eggs",
|
||||||
|
".git",
|
||||||
|
".git-rewrite",
|
||||||
|
".hg",
|
||||||
|
".ipynb_checkpoints",
|
||||||
|
".mypy_cache",
|
||||||
|
".nox",
|
||||||
|
".pants.d",
|
||||||
|
".pyenv",
|
||||||
|
".pytest_cache",
|
||||||
|
".pytype",
|
||||||
|
".ruff_cache",
|
||||||
|
".svn",
|
||||||
|
".tox",
|
||||||
|
".venv",
|
||||||
|
".vscode",
|
||||||
|
"__pypackages__",
|
||||||
|
"_build",
|
||||||
|
"buck-out",
|
||||||
|
"build",
|
||||||
|
"dist",
|
||||||
|
"node_modules",
|
||||||
|
"site-packages",
|
||||||
|
"venv",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Same as Black.
|
||||||
|
line-length = 160
|
||||||
|
indent-width = 4
|
||||||
|
|
||||||
|
# Assume Python 3.13
|
||||||
|
target-version = "py313"
|
||||||
|
|
||||||
|
[tool.pyright]
|
||||||
|
include = [ "src" ]
|
||||||
|
extraPaths = [ "src" ]
|
||||||
|
venvPath = "./.venv"
|
||||||
|
typeCheckingMode = "basic"
|
||||||
|
venv = ".venv"
|
||||||
|
|
||||||
|
[tool.pytest.ini_options]
|
||||||
|
asyncio_mode = "auto"
|
||||||
|
addopts = "--quiet"
|
||||||
|
testpaths = "tests"
|
||||||
|
pythonpath = "src"
|
||||||
|
|
||||||
|
[tool.coverage.run]
|
||||||
|
# .coveragerc
|
||||||
|
branch = true
|
||||||
|
source = [
|
||||||
|
"amcrest_api",
|
||||||
|
"qtt",
|
||||||
|
"util",
|
||||||
|
]
|
||||||
|
omit = [
|
||||||
|
"tests/*",
|
||||||
|
"*/__init__.py",
|
||||||
|
"*/venv/*",
|
||||||
|
"*/.venv/*",
|
||||||
|
"*/site-packages/*"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
fail_under = 75
|
||||||
|
show_missing = true
|
||||||
|
skip_covered = false
|
||||||
|
precision = 1
|
||||||
|
exclude_lines = [
|
||||||
|
"pragma: no cover"
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.black]
|
||||||
|
line-length = 160
|
||||||
|
target-version = ["py313"]
|
||||||
|
extend-exclude = '''
|
||||||
|
/(
|
||||||
|
\.venv
|
||||||
|
|\.ruff_cache
|
||||||
|
|\.pytest_cache
|
||||||
|
|node_modules
|
||||||
|
|dist
|
||||||
|
|build
|
||||||
|
)/
|
||||||
|
'''
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
json-logging-graystorm = { url = "https://github.com/weirdtangent/json_logging/archive/refs/tags/0.1.3.tar.gz" }
|
||||||
|
|
||||||
|
[dependency-groups]
|
||||||
|
dev = [
|
||||||
|
"black>=25.9.0",
|
||||||
|
"pytest>=8.4.2",
|
||||||
|
"ruff>=0.14.1",
|
||||||
|
]
|
||||||
@ -0,0 +1,2 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2025 Jeff Culverhouse
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
# This software is licensed under the MIT License, which allows you to use,
|
||||||
|
# copy, modify, merge, publish, distribute, and sell copies of the software,
|
||||||
|
# with the requirement to include the original copyright notice and this
|
||||||
|
# permission notice in all copies or substantial portions of the software.
|
||||||
|
#
|
||||||
|
# The software is provided 'as is', without any warranty.
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import argparse
|
||||||
|
from json_logging import setup_logging, get_logger
|
||||||
|
from .core import Amcrest2Mqtt
|
||||||
|
|
||||||
|
|
||||||
|
def build_parser() -> argparse.ArgumentParser:
|
||||||
|
p = argparse.ArgumentParser(prog="govee2mqtt", exit_on_error=True)
|
||||||
|
p.add_argument(
|
||||||
|
"-c",
|
||||||
|
"--config",
|
||||||
|
help="Directory or file path for config.yaml (defaults to /config/config.yaml)",
|
||||||
|
)
|
||||||
|
return p
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
setup_logging()
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
parser = build_parser()
|
||||||
|
args = parser.parse_args(argv)
|
||||||
|
|
||||||
|
try:
|
||||||
|
with Amcrest2Mqtt(args=args) as amcrest2mqtt:
|
||||||
|
try:
|
||||||
|
asyncio.run(amcrest2mqtt.main_loop())
|
||||||
|
except RuntimeError as e:
|
||||||
|
if "asyncio.run() cannot be called from a running event loop" in str(e):
|
||||||
|
# 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 TypeError as e:
|
||||||
|
logger.error(f"TypeError: {e}", exc_info=True)
|
||||||
|
except ValueError as e:
|
||||||
|
logger.error(f"ValueError: {e}", exc_info=True)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
logger.warning("Shutdown requested (Ctrl+C). Exiting gracefully...")
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
logger.warning("Main loop cancelled.")
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception(f"Unhandled exception in main loop: {e}", exc_info=True)
|
||||||
|
finally:
|
||||||
|
logger.info("amcrest2mqtt stopped.")
|
||||||
@ -0,0 +1,110 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2025 Jeff Culverhouse
|
||||||
|
import argparse
|
||||||
|
import logging
|
||||||
|
from json_logging import get_logger
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from amcrest2mqtt.interface import AmcrestServiceProtocol
|
||||||
|
|
||||||
|
|
||||||
|
class Base:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
self: "AmcrestServiceProtocol"
|
||||||
|
|
||||||
|
def __init__(self, *, args: argparse.Namespace | None = None, **kwargs):
|
||||||
|
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)
|
||||||
|
|
||||||
|
if not self.config["mqtt"] or not self.config["amcrest"]:
|
||||||
|
raise ValueError("config was not loaded")
|
||||||
|
|
||||||
|
# down in trenches if we have to
|
||||||
|
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.qos = self.mqtt_config["qos"]
|
||||||
|
|
||||||
|
self.storage_update_interval = self.config["amcrest"].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.rate_limited = False
|
||||||
|
|
||||||
|
def __enter__(self):
|
||||||
|
super_enter = getattr(super(), "__enter__", None)
|
||||||
|
if callable(super_enter):
|
||||||
|
super_enter()
|
||||||
|
|
||||||
|
self.mqttc_create()
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
return self
|
||||||
|
|
||||||
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
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:
|
||||||
|
try:
|
||||||
|
self.mqttc.loop_stop()
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.debug(f"MQTT loop_stop failed: {e}")
|
||||||
|
|
||||||
|
if self.mqttc.is_connected():
|
||||||
|
try:
|
||||||
|
self.mqttc.disconnect()
|
||||||
|
self.logger.info("Disconnected from MQTT broker")
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Error during MQTT disconnect: {e}")
|
||||||
|
|
||||||
|
self.logger.info("Exiting gracefully")
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
from .mixins.util import UtilMixin
|
||||||
|
from .mixins.mqtt import MqttMixin
|
||||||
|
from .mixins.topics import TopicsMixin
|
||||||
|
from .mixins.events import EventsMixin
|
||||||
|
from .mixins.service import ServiceMixin
|
||||||
|
from .mixins.amcrest import AmcrestMixin
|
||||||
|
from .mixins.amcrest_api import AmcrestAPIMixin
|
||||||
|
from .mixins.refresh import RefreshMixin
|
||||||
|
from .mixins.helpers import HelpersMixin
|
||||||
|
from .mixins.loops import LoopsMixin
|
||||||
|
from .base import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Amcrest2Mqtt(
|
||||||
|
UtilMixin,
|
||||||
|
EventsMixin,
|
||||||
|
TopicsMixin,
|
||||||
|
ServiceMixin,
|
||||||
|
AmcrestMixin,
|
||||||
|
AmcrestAPIMixin,
|
||||||
|
RefreshMixin,
|
||||||
|
HelpersMixin,
|
||||||
|
LoopsMixin,
|
||||||
|
MqttMixin,
|
||||||
|
Base,
|
||||||
|
):
|
||||||
|
pass
|
||||||
@ -0,0 +1,390 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2025 Jeff Culverhouse
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from amcrest2mqtt.core import Amcrest2Mqtt
|
||||||
|
from amcrest2mqtt.interface import AmcrestServiceProtocol
|
||||||
|
|
||||||
|
|
||||||
|
class AmcrestMixin:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
self: "AmcrestServiceProtocol"
|
||||||
|
|
||||||
|
async def setup_device_list(self: Amcrest2Mqtt) -> None:
|
||||||
|
self.logger.info("Setting up device list from config")
|
||||||
|
|
||||||
|
devices = await self.connect_to_devices()
|
||||||
|
self.publish_service_state()
|
||||||
|
|
||||||
|
seen_devices = set()
|
||||||
|
|
||||||
|
for device in devices.values():
|
||||||
|
created = await self.build_component(device)
|
||||||
|
if created:
|
||||||
|
seen_devices.add(created)
|
||||||
|
|
||||||
|
# Mark missing devices offline
|
||||||
|
missing_devices = set(self.devices.keys()) - seen_devices
|
||||||
|
for device_id in missing_devices:
|
||||||
|
self.publish_device_availability(device_id, online=False)
|
||||||
|
self.logger.warning(f"Device {device_id} not seen in Amcrest API list — marked offline")
|
||||||
|
|
||||||
|
# Handle first discovery completion
|
||||||
|
if not self.discovery_complete:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
self.logger.info("First-time device setup and discovery is done")
|
||||||
|
self.discovery_complete = True
|
||||||
|
|
||||||
|
# convert Amcrest device capabilities into MQTT components
|
||||||
|
async def build_component(self: Amcrest2Mqtt, device: dict) -> str | None:
|
||||||
|
device_class = self.classify_device(device)
|
||||||
|
match device_class:
|
||||||
|
case "camera":
|
||||||
|
return await self.build_camera(device)
|
||||||
|
|
||||||
|
def classify_device(self: Amcrest2Mqtt, device: dict) -> str | None:
|
||||||
|
return "camera"
|
||||||
|
|
||||||
|
async def build_camera(self: Amcrest2Mqtt, device: dict) -> str:
|
||||||
|
raw_id = 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"),
|
||||||
|
"icon": "mdi:camera",
|
||||||
|
"via_device": self.get_service_device(),
|
||||||
|
"device": {
|
||||||
|
"name": device["device_name"],
|
||||||
|
"identifiers": [self.get_device_slug(device_id)],
|
||||||
|
"manufacturer": device["vendor"],
|
||||||
|
"model": device["device_type"],
|
||||||
|
"sw_version": device["software_version"],
|
||||||
|
"hw_version": device["hardware_version"],
|
||||||
|
"connections": [
|
||||||
|
["host", device["host"]],
|
||||||
|
["mac", device["network"]["mac"]],
|
||||||
|
["ip address", device["network"]["ip_address"]],
|
||||||
|
],
|
||||||
|
"configuration_url": f"http://{device['host']}/",
|
||||||
|
"serial_number": device["serial_number"],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
modes = {}
|
||||||
|
|
||||||
|
device_block = self.get_device_block(
|
||||||
|
self.get_device_slug(device_id),
|
||||||
|
device["device_name"],
|
||||||
|
device["vendor"],
|
||||||
|
device["device_type"],
|
||||||
|
)
|
||||||
|
|
||||||
|
if "webrtc" in self.amcrest_config:
|
||||||
|
webrtc_config = self.amcrest_config["webrtc"]
|
||||||
|
rtc_host = webrtc_config["host"]
|
||||||
|
rtc_port = webrtc_config["port"]
|
||||||
|
rtc_link = webrtc_config["link"]
|
||||||
|
rtc_source = webrtc_config["sources"].pop(0)
|
||||||
|
rtc_url = f"http://{rtc_host}:{rtc_port}/{rtc_link}?src={rtc_source}"
|
||||||
|
|
||||||
|
modes["snapshot"] = {
|
||||||
|
"component_type": "camera",
|
||||||
|
"name": "Snapshot",
|
||||||
|
"uniq_id": f"{self.service_slug}_{self.get_device_slug(device_id, 'snapshot')}",
|
||||||
|
"topic": self.get_state_topic(device_id, "snapshot"),
|
||||||
|
"image_encoding": "b64",
|
||||||
|
"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(),
|
||||||
|
"device": device_block,
|
||||||
|
}
|
||||||
|
if rtc_url:
|
||||||
|
modes["snapshot"]["url_topic"] = rtc_url
|
||||||
|
self.upsert_state(device_id, image={"snapshot": None})
|
||||||
|
|
||||||
|
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"),
|
||||||
|
"payload_on": "ON",
|
||||||
|
"payload_off": "OFF",
|
||||||
|
"device_class": "switch",
|
||||||
|
"icon": "mdi:camera-off",
|
||||||
|
"via_device": self.get_service_device(),
|
||||||
|
"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"),
|
||||||
|
"payload_on": "ON",
|
||||||
|
"payload_off": "OFF",
|
||||||
|
"device_class": "switch",
|
||||||
|
"icon": "mdi:motion-sensor",
|
||||||
|
"via_device": self.get_service_device(),
|
||||||
|
"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 }}",
|
||||||
|
"payload_on": True,
|
||||||
|
"payload_off": False,
|
||||||
|
"device_class": "motion",
|
||||||
|
"icon": "mdi:motion-sensor-alarm",
|
||||||
|
"via_device": self.get_service_device(),
|
||||||
|
"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 }}",
|
||||||
|
"icon": "mdi:location",
|
||||||
|
"via_device": self.get_service_device(),
|
||||||
|
"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 }}",
|
||||||
|
"device_class": "data_size",
|
||||||
|
"state_class": "measurement",
|
||||||
|
"unit_of_measurement": "GB",
|
||||||
|
"entity_category": "diagnostic",
|
||||||
|
"icon": "mdi:micro-sd",
|
||||||
|
"via_device": self.get_service_device(),
|
||||||
|
"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 }}",
|
||||||
|
"state_class": "measurement",
|
||||||
|
"unit_of_measurement": "%",
|
||||||
|
"entity_category": "diagnostic",
|
||||||
|
"icon": "mdi:micro-sd",
|
||||||
|
"via_device": self.get_service_device(),
|
||||||
|
"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 }}",
|
||||||
|
"device_class": "data_size",
|
||||||
|
"state_class": "measurement",
|
||||||
|
"unit_of_measurement": "GB",
|
||||||
|
"entity_category": "diagnostic",
|
||||||
|
"icon": "mdi:micro-sd",
|
||||||
|
"via_device": self.get_service_device(),
|
||||||
|
"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 }}",
|
||||||
|
"entity_category": "diagnostic",
|
||||||
|
"icon": "mdi:note",
|
||||||
|
"via_device": self.get_service_device(),
|
||||||
|
"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 }}",
|
||||||
|
"device_class": "timestamp",
|
||||||
|
"entity_category": "diagnostic",
|
||||||
|
"icon": "mdi:calendar",
|
||||||
|
"via_device": self.get_service_device(),
|
||||||
|
"device": device_block,
|
||||||
|
}
|
||||||
|
|
||||||
|
if device.get("is_doorbell", None):
|
||||||
|
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 }}",
|
||||||
|
"payload_on": "on",
|
||||||
|
"payload_off": "off",
|
||||||
|
"icon": "mdi:doorbell",
|
||||||
|
"via_device": self.get_service_device(),
|
||||||
|
"device": device_block,
|
||||||
|
}
|
||||||
|
|
||||||
|
if device.get("is_ad410", None):
|
||||||
|
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 }}",
|
||||||
|
"payload_on": "on",
|
||||||
|
"payload_off": "off",
|
||||||
|
"icon": "mdi:person",
|
||||||
|
"via_device": self.get_service_device(),
|
||||||
|
"device": device_block,
|
||||||
|
}
|
||||||
|
|
||||||
|
# defaults - which build_device_states doesn't update (events do)
|
||||||
|
self.upsert_state(
|
||||||
|
device_id,
|
||||||
|
internal={"discovered": False},
|
||||||
|
camera={"video": None, "snapshot": None},
|
||||||
|
binary_sensor={
|
||||||
|
"motion": False,
|
||||||
|
"doorbell": False,
|
||||||
|
"human": False,
|
||||||
|
},
|
||||||
|
sensor={
|
||||||
|
"motion_region": None,
|
||||||
|
"event_text": None,
|
||||||
|
"event_time": None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.upsert_device(device_id, component=component, modes=modes)
|
||||||
|
self.build_device_states(device_id)
|
||||||
|
|
||||||
|
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.publish_device_discovery(device_id)
|
||||||
|
self.publish_device_availability(device_id, online=True)
|
||||||
|
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)
|
||||||
@ -0,0 +1,353 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2025 Jeff Culverhouse
|
||||||
|
from amcrest import AmcrestCamera, AmcrestError, CommError, LoginError
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
import random
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from amcrest2mqtt.core import Amcrest2Mqtt
|
||||||
|
from amcrest2mqtt.interface import AmcrestServiceProtocol
|
||||||
|
|
||||||
|
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):
|
||||||
|
return self.api_calls
|
||||||
|
|
||||||
|
def get_last_call_date(self: Amcrest2Mqtt):
|
||||||
|
return self.last_call_date
|
||||||
|
|
||||||
|
def is_rate_limited(self: Amcrest2Mqtt):
|
||||||
|
return self.rate_limited
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def connect_to_devices(self: Amcrest2Mqtt) -> dict[str, Any]:
|
||||||
|
semaphore = asyncio.Semaphore(5)
|
||||||
|
|
||||||
|
async def _connect_device(host, name):
|
||||||
|
async with semaphore:
|
||||||
|
await asyncio.to_thread(self.get_device, host, name)
|
||||||
|
|
||||||
|
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))
|
||||||
|
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:
|
||||||
|
config = self.amcrest_config
|
||||||
|
return AmcrestCamera(host, config["port"], config["username"], config["password"], verbose=False).camera
|
||||||
|
|
||||||
|
def get_device(self, host: str, device_name: str) -> 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)
|
||||||
|
except Exception as err:
|
||||||
|
self.logger.error(f"Error with {host}: {err}")
|
||||||
|
return
|
||||||
|
|
||||||
|
device_type = camera.device_type.replace("type=", "").strip()
|
||||||
|
is_ad110 = device_type == "AD110"
|
||||||
|
is_ad410 = device_type == "AD410"
|
||||||
|
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()
|
||||||
|
sw_version = f"{version} ({build})"
|
||||||
|
|
||||||
|
network_config = dict(item.split("=") for item in camera.network_config.splitlines())
|
||||||
|
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:
|
||||||
|
self.logger.info(f"Connected to {host} with serial number {camera.serial_number}")
|
||||||
|
|
||||||
|
self.amcrest_devices[serial_number] = {
|
||||||
|
"camera": camera,
|
||||||
|
"config": {
|
||||||
|
"host": host,
|
||||||
|
"host_ip": host_ip,
|
||||||
|
"device_name": device_name,
|
||||||
|
"device_type": device_type,
|
||||||
|
"device_class": camera.device_class,
|
||||||
|
"is_ad110": is_ad110,
|
||||||
|
"is_ad410": is_ad410,
|
||||||
|
"is_doorbell": is_doorbell,
|
||||||
|
"serial_number": serial_number,
|
||||||
|
"software_version": sw_version,
|
||||||
|
"hardware_version": camera.hardware_version,
|
||||||
|
"vendor": camera.vendor_information,
|
||||||
|
"network": {
|
||||||
|
"interface": interface,
|
||||||
|
"ip_address": ip_address,
|
||||||
|
"mac": mac_address,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
self.get_privacy_mode(serial_number)
|
||||||
|
|
||||||
|
except LoginError:
|
||||||
|
self.logger.error(f'Invalid username/password to connect to device "{host}", fix in config.yaml')
|
||||||
|
except AmcrestError as err:
|
||||||
|
self.logger.error(f'Failed to connect to device "{host}", check config.yaml and restart to try again: {err}')
|
||||||
|
|
||||||
|
# Storage stats -------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_storage_stats(self, device_id: str) -> dict[str, str]:
|
||||||
|
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")
|
||||||
|
except LoginError:
|
||||||
|
self.logger.error(f"Failed to authenticate with device ({self.get_device_name(device_id)}) for storage stats")
|
||||||
|
|
||||||
|
return {
|
||||||
|
"used_percent": str(storage["used_percent"]),
|
||||||
|
"used": self.to_gb(storage["used"]),
|
||||||
|
"total": self.to_gb(storage["total"]),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Privacy config ------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_privacy_mode(self, device_id: str) -> bool:
|
||||||
|
device = self.amcrest_devices[device_id]
|
||||||
|
|
||||||
|
try:
|
||||||
|
privacy = device["camera"].privacy_config().split()
|
||||||
|
privacy_mode = True if privacy[0].split("=")[1] == "true" else False
|
||||||
|
device["privacy_mode"] = privacy_mode
|
||||||
|
except CommError:
|
||||||
|
self.logger.error(f"Failed to communicate with device ({self.get_device_name(device_id)}) to get privacy mode")
|
||||||
|
except LoginError:
|
||||||
|
self.logger.error(f"Failed to authenticate with device ({self.get_device_name(device_id)}) to get privacy mode")
|
||||||
|
|
||||||
|
return privacy_mode
|
||||||
|
|
||||||
|
def set_privacy_mode(self, device_id: str, switch: bool) -> str:
|
||||||
|
device = self.amcrest_devices[device_id]
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = 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:
|
||||||
|
self.logger.error(f"Failed to authenticate with device ({self.get_device_name(device_id)}) to set privacy mode")
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Motion detection config ---------------------------------------------------------------------
|
||||||
|
|
||||||
|
def get_motion_detection(self, device_id: str) -> bool:
|
||||||
|
device = self.amcrest_devices[device_id]
|
||||||
|
|
||||||
|
try:
|
||||||
|
motion_detection = 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")
|
||||||
|
except LoginError:
|
||||||
|
self.logger.error(f"Failed to authenticate with device ({self.get_device_name(device_id)}) to get motion detection")
|
||||||
|
|
||||||
|
return motion_detection
|
||||||
|
|
||||||
|
def set_motion_detection(self, device_id: str, switch: bool) -> str:
|
||||||
|
device = self.amcrest_devices[device_id]
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = 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")
|
||||||
|
except LoginError:
|
||||||
|
self.logger.error(f"Failed to authenticate with device ({self.get_device_name(device_id)}) to set motion detections")
|
||||||
|
|
||||||
|
return response
|
||||||
|
|
||||||
|
# Snapshots -----------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
async def collect_all_device_snapshots(self: Amcrest2Mqtt) -> None:
|
||||||
|
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:
|
||||||
|
device = self.amcrest_devices[device_id]
|
||||||
|
|
||||||
|
# Respect privacy mode (default False if missing)
|
||||||
|
if device.get("privacy_mode", False):
|
||||||
|
self.logger.info(f"Snapshot: skip {self.get_device_name(device_id)} (privacy mode ON)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
camera = device.get("camera")
|
||||||
|
if camera is None:
|
||||||
|
self.logger.error(f"Snapshot: device {self.get_device_name(device_id)} has no 'camera' object")
|
||||||
|
return None
|
||||||
|
|
||||||
|
for attempt in range(1, SNAPSHOT_MAX_TRIES + 1):
|
||||||
|
try:
|
||||||
|
image_bytes = await asyncio.wait_for(camera.async_snapshot(), timeout=SNAPSHOT_TIMEOUT_S)
|
||||||
|
if not image_bytes:
|
||||||
|
self.logger.warning(f"Snapshot: empty image from {self.get_device_name(device_id)}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
encoded_b = base64.b64encode(image_bytes)
|
||||||
|
encoded = encoded_b.decode("ascii")
|
||||||
|
self.upsert_state(
|
||||||
|
device_id,
|
||||||
|
camera={"snapshot": encoded},
|
||||||
|
sensor={
|
||||||
|
"event_text": "Got snapshot",
|
||||||
|
"event_time": datetime.now(timezone.utc).isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.publish_device_state(device_id)
|
||||||
|
|
||||||
|
self.logger.debug(f"Snapshot: {self.get_device_name(device_id)} {len(image_bytes)} raw bytes -> {len(encoded)} b64 chars")
|
||||||
|
return encoded
|
||||||
|
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
# Let shutdown propagate
|
||||||
|
raise
|
||||||
|
|
||||||
|
except (CommError, LoginError, asyncio.TimeoutError) as err:
|
||||||
|
# Backoff with jitter before retrying
|
||||||
|
if attempt == SNAPSHOT_MAX_TRIES:
|
||||||
|
break
|
||||||
|
delay = SNAPSHOT_BASE_BACKOFF_S * (2 ** (attempt - 1))
|
||||||
|
delay += random.uniform(0, 0.25)
|
||||||
|
self.logger.debug(
|
||||||
|
f"Snapshot: attempt {attempt}/{SNAPSHOT_MAX_TRIES} failed for {self.get_device_name(device_id)}: {err!r}; retrying in {delay:.2f}s"
|
||||||
|
)
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
|
# Any other unexpected exception: log and stop
|
||||||
|
except Exception as err: # noqa: BLE001 (log-and-drop is intentional here)
|
||||||
|
self.logger.exception(f"Snapshot: unexpected error for {self.get_device_name(device_id)}: {err!r}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
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:
|
||||||
|
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) -> str | None:
|
||||||
|
device = self.amcrest_devices[device_id]
|
||||||
|
|
||||||
|
tries = 0
|
||||||
|
while tries < 3:
|
||||||
|
try:
|
||||||
|
data_raw = device["camera"].download_file(file)
|
||||||
|
if data_raw:
|
||||||
|
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) < 100 * 1024 * 1024 * 1024:
|
||||||
|
return data_base64
|
||||||
|
else:
|
||||||
|
self.logger.error("Processed recording is too large")
|
||||||
|
return
|
||||||
|
except CommError:
|
||||||
|
tries += 1
|
||||||
|
except LoginError:
|
||||||
|
tries += 1
|
||||||
|
|
||||||
|
if tries == 3:
|
||||||
|
self.logger.error(f"Failed to communicate with device ({self.get_device_name(device_id)}) to get recorded file")
|
||||||
|
|
||||||
|
# Events --------------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
async def get_events_from_device(self, device_id: str) -> None:
|
||||||
|
device = self.amcrest_devices[device_id]
|
||||||
|
|
||||||
|
tries = 0
|
||||||
|
while tries < 3:
|
||||||
|
try:
|
||||||
|
async for code, payload in device["camera"].async_event_actions("All"):
|
||||||
|
await self.process_device_event(device_id, code, payload)
|
||||||
|
except CommError:
|
||||||
|
tries += 1
|
||||||
|
except LoginError:
|
||||||
|
tries += 1
|
||||||
|
|
||||||
|
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):
|
||||||
|
try:
|
||||||
|
device = self.amcrest_devices[device_id]
|
||||||
|
config = device["config"]
|
||||||
|
|
||||||
|
# if code != 'NewFile' and code != 'InterVideoAccess':
|
||||||
|
# self.logger.info(f'Event on {self.get_device_name(device_id)} - {code}: {payload}')
|
||||||
|
|
||||||
|
if (code == "ProfileAlarmTransmit" and config["is_ad110"]) or (code == "VideoMotion" and not config["is_ad110"]):
|
||||||
|
motion_payload = {"state": "on" if payload["action"] == "Start" else "off", "region": ", ".join(payload["data"]["RegionName"])}
|
||||||
|
self.events.append({"device_id": device_id, "event": "motion", "payload": motion_payload})
|
||||||
|
elif code == "CrossRegionDetection" and payload["data"]["ObjectType"] == "Human":
|
||||||
|
human_payload = "on" if payload["action"] == "Start" else "off"
|
||||||
|
self.events.append({"device_id": device_id, "event": "human", "payload": human_payload})
|
||||||
|
elif code == "_DoTalkAction_":
|
||||||
|
doorbell_payload = "on" if payload["data"]["Action"] == "Invite" else "off"
|
||||||
|
self.events.append({"device_id": device_id, "event": "doorbell", "payload": doorbell_payload})
|
||||||
|
elif code == "NewFile":
|
||||||
|
if (
|
||||||
|
"File" in payload["data"]
|
||||||
|
and "[R]" not in payload["data"]["File"]
|
||||||
|
and ("StoragePoint" not in payload["data"] or payload["data"]["StoragePoint"] != "Temporary")
|
||||||
|
):
|
||||||
|
file_payload = {"file": payload["data"]["File"], "size": payload["data"]["Size"]}
|
||||||
|
self.events.append({"device_id": device_id, "event": "recording", "payload": file_payload})
|
||||||
|
elif code == "LensMaskOpen":
|
||||||
|
device["privacy_mode"] = True
|
||||||
|
self.events.append({"device_id": device_id, "event": "privacy_mode", "payload": "on"})
|
||||||
|
elif code == "LensMaskClose":
|
||||||
|
device["privacy_mode"] = False
|
||||||
|
self.events.append({"device_id": device_id, "event": "privacy_mode", "payload": "off"})
|
||||||
|
# lets send these but not bother logging them here
|
||||||
|
elif code == "TimeChange":
|
||||||
|
self.events.append({"device_id": device_id, "event": code, "payload": payload["action"]})
|
||||||
|
elif code == "NTPAdjustTime":
|
||||||
|
self.events.append({"device_id": device_id, "event": code, "payload": payload["action"]})
|
||||||
|
elif code == "RtspSessionDisconnect":
|
||||||
|
self.events.append({"device_id": device_id, "event": code, "payload": payload["action"]})
|
||||||
|
# lets just ignore these
|
||||||
|
elif code == "InterVideoAccess": # I think this is US, accessing the API of the camera, lets not inception!
|
||||||
|
pass
|
||||||
|
elif code == "VideoMotionInfo":
|
||||||
|
pass
|
||||||
|
# save everything else as a 'generic' event
|
||||||
|
else:
|
||||||
|
self.logger.info(f"Event on {self.get_device_name(device_id)} - {code}: {payload}")
|
||||||
|
self.events.append({"device_id": device_id, "event": code, "payload": payload})
|
||||||
|
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:
|
||||||
|
return self.events.pop(0) if len(self.events) > 0 else None
|
||||||
@ -0,0 +1,70 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2025 Jeff Culverhouse
|
||||||
|
import asyncio
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from amcrest2mqtt.core import Amcrest2Mqtt
|
||||||
|
from amcrest2mqtt.interface import AmcrestServiceProtocol
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
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}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
device_id = device_event["device_id"]
|
||||||
|
event = device_event["event"]
|
||||||
|
payload = device_event["payload"]
|
||||||
|
|
||||||
|
device_states = self.states[device_id]
|
||||||
|
|
||||||
|
# if one of our known sensors
|
||||||
|
if event in ["motion", "human", "doorbell", "recording", "privacy_mode"]:
|
||||||
|
if event == "recording" and payload["file"].endswith(".jpg"):
|
||||||
|
image = self.get_recorded_file(device_id, payload["file"])
|
||||||
|
if image:
|
||||||
|
self.upsert_state(device_id, camera={"eventshot": image}, sensor={"event_time": datetime.now(timezone.utc).isoformat()})
|
||||||
|
else:
|
||||||
|
# only log details if not a recording
|
||||||
|
if event != "recording":
|
||||||
|
self.logger.info(f"Got event for {self.get_device_name(device_id)}: {event} - {payload}")
|
||||||
|
if event == "motion":
|
||||||
|
self.upsert_state(
|
||||||
|
device_id,
|
||||||
|
binary_sensor={"motion": payload["state"]},
|
||||||
|
sensor={"motion_region": payload["region"], "event_time": datetime.now(timezone.utc).isoformat()},
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
self.upsert_state(device_id, sensor={event: payload})
|
||||||
|
|
||||||
|
# other ways to infer "privacy mode" is off and needs updating
|
||||||
|
if event in ["motion", "human", "doorbell"] and device_states["switch"]["privacy"] != "OFF":
|
||||||
|
self.upsert_state(device_id, switch={"privacy_mode": "OFF"})
|
||||||
|
|
||||||
|
# send everything to the device's event_text/time
|
||||||
|
self.logger.debug(f'Got {{{event}: {payload}}} for "{self.get_device_name(device_id)}"')
|
||||||
|
self.upsert_state(
|
||||||
|
device_id,
|
||||||
|
sensor={
|
||||||
|
"event_text": f"{event}: {payload}",
|
||||||
|
"event_time": datetime.now(timezone.utc).isoformat(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
self.publish_device_state(device_id)
|
||||||
|
except Exception as err:
|
||||||
|
self.logger.error(err, exc_info=True)
|
||||||
@ -0,0 +1,120 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2025 Jeff Culverhouse
|
||||||
|
from deepmerge import Merger
|
||||||
|
import os
|
||||||
|
import signal
|
||||||
|
import threading
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from amcrest2mqtt.core import Amcrest2Mqtt
|
||||||
|
from amcrest2mqtt.interface import AmcrestServiceProtocol
|
||||||
|
|
||||||
|
|
||||||
|
class HelpersMixin:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
self: "AmcrestServiceProtocol"
|
||||||
|
|
||||||
|
def build_device_states(self: Amcrest2Mqtt, device_id: str) -> None:
|
||||||
|
storage = self.get_storage_stats(device_id)
|
||||||
|
privacy = self.get_privacy_mode(device_id)
|
||||||
|
motion_detection = self.get_motion_detection(device_id)
|
||||||
|
|
||||||
|
self.upsert_state(
|
||||||
|
device_id,
|
||||||
|
switch={
|
||||||
|
"privacy": "ON" if privacy else "OFF",
|
||||||
|
"motion_detection": "ON" if motion_detection else "OFF",
|
||||||
|
},
|
||||||
|
sensor={
|
||||||
|
"storage_used": storage["used"],
|
||||||
|
"storage_total": storage["total"],
|
||||||
|
"storage_used_pct": storage["used_percent"],
|
||||||
|
"last_update": self.get_last_update(device_id),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
# send command to Amcrest -----------------------------------------------------------------------
|
||||||
|
|
||||||
|
def send_command(self: Amcrest2Mqtt, device_id, response):
|
||||||
|
return
|
||||||
|
|
||||||
|
def handle_service_message(self: Amcrest2Mqtt, handler, message):
|
||||||
|
match handler:
|
||||||
|
case "storage_refresh":
|
||||||
|
self.device_interval = message
|
||||||
|
case "device_list_refresh":
|
||||||
|
self.device_list_interval = message
|
||||||
|
case "snapshot_refresh":
|
||||||
|
self.device_boost_interval = message
|
||||||
|
case "refresh_device_list":
|
||||||
|
if message == "refresh":
|
||||||
|
self.rediscover_all()
|
||||||
|
else:
|
||||||
|
self.logger.error("[handler] unknown [message]")
|
||||||
|
return
|
||||||
|
case _:
|
||||||
|
self.logger.error(f"Unrecognized message to {self.service_slug}: {handler} -> {message}")
|
||||||
|
return
|
||||||
|
self.publish_service_state()
|
||||||
|
|
||||||
|
def rediscover_all(self: Amcrest2Mqtt):
|
||||||
|
self.publish_service_state()
|
||||||
|
self.publish_service_discovery()
|
||||||
|
for device_id in self.devices:
|
||||||
|
if device_id == "service":
|
||||||
|
continue
|
||||||
|
self.publish_device_state(device_id)
|
||||||
|
self.publish_device_discovery(device_id)
|
||||||
|
|
||||||
|
# Utility functions ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _handle_signal(self: Amcrest2Mqtt, signum, frame=None):
|
||||||
|
"""Handle SIGTERM/SIGINT and exit cleanly or forcefully."""
|
||||||
|
sig_name = signal.Signals(signum).name
|
||||||
|
self.logger.warning(f"{sig_name} received - stopping service loop")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def _force_exit():
|
||||||
|
self.logger.warning("Force-exiting process after signal")
|
||||||
|
os._exit(0)
|
||||||
|
|
||||||
|
threading.Timer(5.0, _force_exit).start()
|
||||||
|
|
||||||
|
# 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."""
|
||||||
|
if isinstance(data, tuple):
|
||||||
|
raise TypeError(f"⚠️ Found tuple at {path}: {data!r}")
|
||||||
|
|
||||||
|
if isinstance(data, dict):
|
||||||
|
for key, value in data.items():
|
||||||
|
if isinstance(key, tuple):
|
||||||
|
raise TypeError(f"⚠️ Found tuple key at {path}: {key!r}")
|
||||||
|
self._assert_no_tuples(value, f"{path}.{key}")
|
||||||
|
elif isinstance(data, list):
|
||||||
|
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:
|
||||||
|
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})
|
||||||
|
# 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:
|
||||||
|
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})
|
||||||
|
self._assert_no_tuples(merged, f"state[{device_id}].{section} (post-merge)")
|
||||||
|
self.states[device_id] = merged
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2025 Jeff Culverhouse
|
||||||
|
import asyncio
|
||||||
|
import signal
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from amcrest2mqtt.core import Amcrest2Mqtt
|
||||||
|
from amcrest2mqtt.interface import AmcrestServiceProtocol
|
||||||
|
|
||||||
|
|
||||||
|
class LoopsMixin:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
self: "AmcrestServiceProtocol"
|
||||||
|
|
||||||
|
async def device_loop(self: Amcrest2Mqtt):
|
||||||
|
while self.running:
|
||||||
|
await self.refresh_all_devices()
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self.device_interval)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self.logger.debug("device_loop cancelled during sleep")
|
||||||
|
break
|
||||||
|
|
||||||
|
async def collect_events_loop(self: Amcrest2Mqtt):
|
||||||
|
while self.running:
|
||||||
|
await self.collect_all_device_events()
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self.logger.debug("collect_events_loop cancelled during sleep")
|
||||||
|
break
|
||||||
|
|
||||||
|
async def check_event_queue_loop(self: Amcrest2Mqtt):
|
||||||
|
while self.running:
|
||||||
|
await self.check_for_events()
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self.logger.debug("check_event_queue_loop cancelled during sleep")
|
||||||
|
break
|
||||||
|
|
||||||
|
async def collect_snapshots_loop(self: Amcrest2Mqtt):
|
||||||
|
while self.running:
|
||||||
|
await self.collect_all_device_snapshots()
|
||||||
|
try:
|
||||||
|
await asyncio.sleep(self.snapshot_update_interval)
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self.logger.debug("collect_snapshots_loop cancelled during sleep")
|
||||||
|
break
|
||||||
|
|
||||||
|
# main loop
|
||||||
|
async def main_loop(self: Amcrest2Mqtt):
|
||||||
|
await self.setup_device_list()
|
||||||
|
|
||||||
|
self.loop = asyncio.get_running_loop()
|
||||||
|
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||||
|
try:
|
||||||
|
signal.signal(sig, self._handle_signal)
|
||||||
|
except Exception:
|
||||||
|
self.logger.debug(f"Cannot install handler for {sig}")
|
||||||
|
|
||||||
|
self.running = True
|
||||||
|
|
||||||
|
tasks = [
|
||||||
|
asyncio.create_task(self.device_loop(), name="device_loop"),
|
||||||
|
asyncio.create_task(self.collect_events_loop(), name="collect events loop"),
|
||||||
|
asyncio.create_task(self.check_event_queue_loop(), name="check events queue loop"),
|
||||||
|
asyncio.create_task(self.collect_snapshots_loop(), name="collect snapshot loop"),
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
||||||
|
except asyncio.CancelledError:
|
||||||
|
self.logger.warning("Main loop cancelled — shutting down...")
|
||||||
|
except Exception as err:
|
||||||
|
self.logger.exception(f"Unhandled exception in main loop: {err}")
|
||||||
|
self.running = False
|
||||||
|
finally:
|
||||||
|
self.logger.info("All loops terminated — cleanup complete.")
|
||||||
@ -0,0 +1,206 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2025 Jeff Culverhouse
|
||||||
|
import json
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
from paho.mqtt.properties import Properties
|
||||||
|
from paho.mqtt.packettypes import PacketTypes
|
||||||
|
import ssl
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from amcrest2mqtt.core import Amcrest2Mqtt
|
||||||
|
from amcrest2mqtt.interface import AmcrestServiceProtocol
|
||||||
|
|
||||||
|
|
||||||
|
class MqttMixin:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
self: "AmcrestServiceProtocol"
|
||||||
|
|
||||||
|
def mqttc_create(self: Amcrest2Mqtt):
|
||||||
|
self.mqttc = mqtt.Client(
|
||||||
|
client_id=self.client_id,
|
||||||
|
callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
|
||||||
|
reconnect_on_failure=False,
|
||||||
|
protocol=mqtt.MQTTv5,
|
||||||
|
)
|
||||||
|
|
||||||
|
if self.mqtt_config.get("tls_enabled"):
|
||||||
|
self.mqttc.tls_set(
|
||||||
|
ca_certs=self.mqtt_config.get("tls_ca_cert"),
|
||||||
|
certfile=self.mqtt_config.get("tls_cert"),
|
||||||
|
keyfile=self.mqtt_config.get("tls_key"),
|
||||||
|
cert_reqs=ssl.CERT_REQUIRED,
|
||||||
|
tls_version=ssl.PROTOCOL_TLS,
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.mqttc.on_connect = self.mqtt_on_connect
|
||||||
|
self.mqttc.on_disconnect = self.mqtt_on_disconnect
|
||||||
|
self.mqttc.on_message = self.mqtt_on_message
|
||||||
|
self.mqttc.on_subscribe = self.mqtt_on_subscribe
|
||||||
|
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)
|
||||||
|
|
||||||
|
try:
|
||||||
|
host = self.mqtt_config.get("host")
|
||||||
|
port = self.mqtt_config.get("port")
|
||||||
|
self.logger.info(f"Connecting to MQTT broker at {host}:{port} as {self.client_id}")
|
||||||
|
|
||||||
|
props = Properties(PacketTypes.CONNECT)
|
||||||
|
props.SessionExpiryInterval = 0
|
||||||
|
|
||||||
|
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.mqttc.loop_start()
|
||||||
|
except ConnectionError as error:
|
||||||
|
self.logger.error(f"Failed to connect to MQTT host {host}: {error}")
|
||||||
|
self.running = False
|
||||||
|
except Exception as error:
|
||||||
|
self.logger.error(f"Network problem trying to connect to MQTT host {host}: {error}")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def mqtt_on_connect(self: Amcrest2Mqtt, client, userdata, flags, reason_code, properties):
|
||||||
|
if reason_code.value != 0:
|
||||||
|
self.logger.error(f"MQTT failed to connect ({reason_code.getName()})")
|
||||||
|
self.running = False
|
||||||
|
return
|
||||||
|
|
||||||
|
self.publish_service_discovery()
|
||||||
|
self.publish_service_availability()
|
||||||
|
self.publish_service_state()
|
||||||
|
|
||||||
|
self.logger.info("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/#")
|
||||||
|
|
||||||
|
def mqtt_on_disconnect(self: Amcrest2Mqtt, client, userdata, flags, reason_code, properties):
|
||||||
|
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):
|
||||||
|
# lets use a new client_id for a reconnect attempt
|
||||||
|
self.client_id = self.get_new_client_id()
|
||||||
|
self.mqttc_create()
|
||||||
|
else:
|
||||||
|
self.logger.info("MQTT disconnect — stopping service loop")
|
||||||
|
self.running = False
|
||||||
|
|
||||||
|
def mqtt_on_log(self: Amcrest2Mqtt, client, userdata, paho_log_level, msg):
|
||||||
|
if paho_log_level == mqtt.LogLevel.MQTT_LOG_ERR:
|
||||||
|
self.logger.error(f"MQTT logged: {msg}")
|
||||||
|
if paho_log_level == mqtt.LogLevel.MQTT_LOG_WARNING:
|
||||||
|
self.logger.warning(f"MQTT logged: {msg}")
|
||||||
|
|
||||||
|
def mqtt_on_message(self: Amcrest2Mqtt, client, userdata, msg):
|
||||||
|
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":
|
||||||
|
return self.handle_service_message(components[2], payload)
|
||||||
|
|
||||||
|
if components[0] == self.service_slug:
|
||||||
|
return self._handle_device_topic(components, payload)
|
||||||
|
|
||||||
|
# self.logger.debug(f"Ignoring unrelated MQTT topic: {topic}")
|
||||||
|
|
||||||
|
def _decode_payload(self: Amcrest2Mqtt, raw):
|
||||||
|
"""Try to decode MQTT payload as JSON, fallback to UTF-8 string, else None."""
|
||||||
|
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):
|
||||||
|
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)}")
|
||||||
|
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)}: {payload}")
|
||||||
|
self.send_command(device_id, payload)
|
||||||
|
|
||||||
|
def _parse_device_topic(self: Amcrest2Mqtt, components):
|
||||||
|
"""Extract (vendor, device_id, attribute) from an MQTT topic components list (underscore-delimited)."""
|
||||||
|
try:
|
||||||
|
if components[-1] != "set":
|
||||||
|
return (None, None, None)
|
||||||
|
|
||||||
|
# Example topics:
|
||||||
|
# amcrest2mqtt/light/amcrest2mqtt_2BEFD0C907BB6BF2/set
|
||||||
|
# amcrest2mqtt/light/amcrest2mqtt_2BEFD0C907BB6BF2/brightness/set
|
||||||
|
|
||||||
|
# Case 1: .../<device>/set
|
||||||
|
if len(components) >= 4 and "_" in components[-2]:
|
||||||
|
vendor, device_id = components[-2].split("_", 1)
|
||||||
|
attribute = None
|
||||||
|
|
||||||
|
# Case 2: .../<device>/<attribute>/set
|
||||||
|
elif len(components) >= 5 and "_" in components[-3]:
|
||||||
|
vendor, device_id = components[-3].split("_", 1)
|
||||||
|
attribute = components[-2]
|
||||||
|
|
||||||
|
else:
|
||||||
|
raise ValueError(f"Malformed topic (expected underscore): {'/'.join(components)}")
|
||||||
|
|
||||||
|
return (vendor, device_id, attribute)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"Malformed device topic: {components} ({e})")
|
||||||
|
return (None, None, None)
|
||||||
|
|
||||||
|
def safe_split_device(self: Amcrest2Mqtt, topic, segment):
|
||||||
|
"""Split a topic segment into (vendor, device_id) safely."""
|
||||||
|
try:
|
||||||
|
return segment.split("-", 1)
|
||||||
|
except ValueError:
|
||||||
|
self.logger.warning(f"Ignoring malformed topic: {topic}")
|
||||||
|
return (None, None)
|
||||||
|
|
||||||
|
def mqtt_on_subscribe(self: Amcrest2Mqtt, client, userdata, mid, reason_code_list, properties):
|
||||||
|
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):
|
||||||
|
if not topic:
|
||||||
|
raise ValueError(f"topic {topic} is empty, why bother")
|
||||||
|
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)
|
||||||
|
except Exception as e:
|
||||||
|
self.logger.warning(f"MQTT publish failed for {topic}: {e}")
|
||||||
@ -0,0 +1,33 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2025 Jeff Culverhouse
|
||||||
|
import asyncio
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from amcrest2mqtt.core import Amcrest2Mqtt
|
||||||
|
from amcrest2mqtt.interface import AmcrestServiceProtocol
|
||||||
|
|
||||||
|
|
||||||
|
class RefreshMixin:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
self: "AmcrestServiceProtocol"
|
||||||
|
|
||||||
|
async def refresh_all_devices(self: Amcrest2Mqtt):
|
||||||
|
self.logger.info(f"Refreshing all devices from Amcrest (every {self.device_interval} sec)")
|
||||||
|
|
||||||
|
semaphore = asyncio.Semaphore(5)
|
||||||
|
|
||||||
|
async def _refresh(device_id):
|
||||||
|
async with semaphore:
|
||||||
|
await asyncio.to_thread(self.build_device_states, device_id)
|
||||||
|
|
||||||
|
tasks = []
|
||||||
|
for device_id in self.devices:
|
||||||
|
if not self.running:
|
||||||
|
break
|
||||||
|
if device_id == "service" or device_id in self.boosted:
|
||||||
|
continue
|
||||||
|
tasks.append(_refresh(device_id))
|
||||||
|
|
||||||
|
if tasks:
|
||||||
|
await asyncio.gather(*tasks)
|
||||||
@ -0,0 +1,185 @@
|
|||||||
|
# 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,
|
||||||
|
)
|
||||||
@ -0,0 +1,123 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2025 Jeff Culverhouse
|
||||||
|
import random
|
||||||
|
import string
|
||||||
|
from typing import TYPE_CHECKING, Optional
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from amcrest2mqtt.core import Amcrest2Mqtt
|
||||||
|
from amcrest2mqtt.interface import AmcrestServiceProtocol
|
||||||
|
|
||||||
|
|
||||||
|
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 --------------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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_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}"
|
||||||
|
|
||||||
|
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_command_topic(self: Amcrest2Mqtt, device_id, category, command="set") -> str:
|
||||||
|
if device_id == "service":
|
||||||
|
return f"{self.service_slug}/service/{category}/{command}"
|
||||||
|
|
||||||
|
# if category is not passed in, device must exist already
|
||||||
|
if not category:
|
||||||
|
category = self.devices[device_id]["component"]["component_type"]
|
||||||
|
|
||||||
|
return f"{self.service_slug}/{category}/{self.get_device_slug(device_id)}/{command}"
|
||||||
|
|
||||||
|
# Device propertiesi --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
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):
|
||||||
|
component = self.get_mode(device_id, mode_name) if mode_name else self.get_component(device_id)
|
||||||
|
|
||||||
|
if component["component_type"] == "camera":
|
||||||
|
return component.get("topic", None)
|
||||||
|
else:
|
||||||
|
return component.get("stat_t", component.get("state_topic", None))
|
||||||
|
|
||||||
|
def get_device_availability_topic(self: Amcrest2Mqtt, device_id):
|
||||||
|
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}
|
||||||
|
|
||||||
|
if sku:
|
||||||
|
device["model"] = sku
|
||||||
|
|
||||||
|
if name == self.service_name:
|
||||||
|
device.update(
|
||||||
|
{
|
||||||
|
"suggested_area": "House",
|
||||||
|
"manufacturer": "weirdTangent",
|
||||||
|
"sw_version": self.config["version"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return device
|
||||||
@ -0,0 +1,149 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2025 Jeff Culverhouse
|
||||||
|
import ipaddress
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import yaml
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from amcrest2mqtt.core import Amcrest2Mqtt
|
||||||
|
from amcrest2mqtt.interface import AmcrestServiceProtocol
|
||||||
|
|
||||||
|
READY_FILE = os.getenv("READY_FILE", "/tmp/amcrest2mqtt.ready")
|
||||||
|
|
||||||
|
|
||||||
|
class UtilMixin:
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
self: "AmcrestServiceProtocol"
|
||||||
|
|
||||||
|
def read_file(self: Amcrest2Mqtt, file_name: str) -> str:
|
||||||
|
with open(file_name, "r") as file:
|
||||||
|
data = file.read().replace("\n", "")
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def to_gb(self: Amcrest2Mqtt, total: [int]) -> str:
|
||||||
|
return str(round(float(total[0]) / 1024 / 1024 / 1024, 2))
|
||||||
|
|
||||||
|
def is_ipv4(self: Amcrest2Mqtt, string: str) -> bool:
|
||||||
|
try:
|
||||||
|
ipaddress.IPv4Network(string)
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_ip_address(self: Amcrest2Mqtt, string: str) -> str:
|
||||||
|
if self.is_ipv4(string):
|
||||||
|
return string
|
||||||
|
try:
|
||||||
|
for i in socket.getaddrinfo(string, None):
|
||||||
|
if i[0] == socket.AddressFamily.AF_INET:
|
||||||
|
return 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):
|
||||||
|
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=None) -> list[str, Any]:
|
||||||
|
version = os.getenv("BLINK2MQTT_VERSION", self.read_file("VERSION"))
|
||||||
|
config_from = "env"
|
||||||
|
config = {}
|
||||||
|
|
||||||
|
# Determine config file path
|
||||||
|
config_path = config_arg or "/config"
|
||||||
|
config_path = os.path.expanduser(config_path)
|
||||||
|
config_path = os.path.abspath(config_path)
|
||||||
|
|
||||||
|
if os.path.isdir(config_path):
|
||||||
|
config_file = os.path.join(config_path, "config.yaml")
|
||||||
|
elif os.path.isfile(config_path):
|
||||||
|
config_file = config_path
|
||||||
|
config_path = os.path.dirname(config_file)
|
||||||
|
else:
|
||||||
|
# If it's not a valid path but looks like a filename, handle gracefully
|
||||||
|
if config_path.endswith(".yaml"):
|
||||||
|
config_file = config_path
|
||||||
|
else:
|
||||||
|
config_file = os.path.join(config_path, "config.yaml")
|
||||||
|
|
||||||
|
# Try to load from YAML
|
||||||
|
if os.path.exists(config_file):
|
||||||
|
try:
|
||||||
|
with open(config_file, "r") as f:
|
||||||
|
config = yaml.safe_load(f) or {}
|
||||||
|
config_from = "file"
|
||||||
|
except Exception as e:
|
||||||
|
logging.warning(f"Failed to load config from {config_file}: {e}")
|
||||||
|
else:
|
||||||
|
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", {})
|
||||||
|
|
||||||
|
# fmt: off
|
||||||
|
mqtt = {
|
||||||
|
"host": mqtt.get("host") or os.getenv("MQTT_HOST", "localhost"),
|
||||||
|
"port": int(mqtt.get("port") or os.getenv("MQTT_PORT", 1883)),
|
||||||
|
"qos": int(mqtt.get("qos") or os.getenv("MQTT_QOS", 0)),
|
||||||
|
"username": mqtt.get("username") or os.getenv("MQTT_USERNAME", ""),
|
||||||
|
"password": mqtt.get("password") or os.getenv("MQTT_PASSWORD", ""),
|
||||||
|
"tls_enabled": mqtt.get("tls_enabled") or (os.getenv("MQTT_TLS_ENABLED", "false").lower() == "true"),
|
||||||
|
"tls_ca_cert": mqtt.get("tls_ca_cert") or os.getenv("MQTT_TLS_CA_CERT"),
|
||||||
|
"tls_cert": mqtt.get("tls_cert") or os.getenv("MQTT_TLS_CERT"),
|
||||||
|
"tls_key": mqtt.get("tls_key") or os.getenv("MQTT_TLS_KEY"),
|
||||||
|
"prefix": mqtt.get("prefix") or os.getenv("MQTT_PREFIX", "amcrest2mqtt"),
|
||||||
|
"discovery_prefix": mqtt.get("discovery_prefix") or os.getenv("MQTT_DISCOVERY_PREFIX", "homeassistant"),
|
||||||
|
}
|
||||||
|
|
||||||
|
hosts = amcrest.get("hosts") or self._csv("AMCREST_HOSTS") or []
|
||||||
|
names = amcrest.get("names") or self._csv("AMCREST_NAMES") or []
|
||||||
|
sources = webrtc.get("sources") or self._csv("AMCREST_SOURCES") or []
|
||||||
|
|
||||||
|
amcrest = {
|
||||||
|
"hosts": hosts,
|
||||||
|
"names": names,
|
||||||
|
"port": int(amcrest.get("port") or os.getenv("AMCREST_PORT", 80)),
|
||||||
|
"username": amcrest.get("username") or os.getenv("AMCREST_USERNAME", ""),
|
||||||
|
"password": amcrest.get("password") or os.getenv("AMCREST_PASSWORD", ""),
|
||||||
|
"storage_update_interval": int(amcrest.get("storage_update_interval") or os.getenv("AMCREST_STORAGE_UPDATE_INTERVAL", 900)),
|
||||||
|
"snapshot_update_interval": int(amcrest.get("snapshot_update_interval") or os.getenv("AMCREST_SNAPSHOT_UPDATE_INTERVAL", 60)),
|
||||||
|
"webrtc": {
|
||||||
|
"host": webrtc.get("host") or os.getenv("AMCREST_WEBRTC_HOST", ""),
|
||||||
|
"port": int(webrtc.get("port") or os.getenv("AMCREST_WEBRTC_PORT", 1984)),
|
||||||
|
"link": webrtc.get("link") or os.getenv("AMCREST_WEBRTC_LINK", "webrtc"),
|
||||||
|
"sources": sources,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
config = {
|
||||||
|
"mqtt": mqtt,
|
||||||
|
"amcrest": amcrest,
|
||||||
|
"debug": config.get("debug", os.getenv("DEBUG", "").lower() == "true"),
|
||||||
|
"hide_ts": config.get("hide_ts", os.getenv("HIDE_TS", "").lower() == "true"),
|
||||||
|
"timezone": config.get("timezone", os.getenv("TZ", "UTC")),
|
||||||
|
"config_from": config_from,
|
||||||
|
"config_path": config_path,
|
||||||
|
"version": version,
|
||||||
|
}
|
||||||
|
# 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")
|
||||||
|
|
||||||
|
# Ensure list lengths match (sources is optional)
|
||||||
|
if len(hosts) != len(names):
|
||||||
|
raise ValueError("`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")
|
||||||
|
|
||||||
|
return config
|
||||||
@ -0,0 +1,17 @@
|
|||||||
|
# SPDX-License-Identifier: MIT
|
||||||
|
# Copyright (c) 2025 Jeff Culverhouse
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
# /usr/local/bin/healthcheck.py
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import time
|
||||||
|
|
||||||
|
path = os.getenv("READY_FILE", "/tmp/amcrest2mqtt.ready")
|
||||||
|
max_age = int(os.getenv("HEALTH_MAX_AGE", "90")) # seconds
|
||||||
|
|
||||||
|
try:
|
||||||
|
st = os.stat(path)
|
||||||
|
sys.exit(0 if time.time() - st.st_mtime < max_age else 1)
|
||||||
|
except FileNotFoundError:
|
||||||
|
sys.exit(1)
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
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, 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_service_message(self: Amcrest2Mqtt, handler, message): ...
|
||||||
|
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=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 send_command(self: Amcrest2Mqtt, device_id, response): ...
|
||||||
|
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: ...
|
||||||
@ -0,0 +1,3 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
|
||||||
|
((gtimeout 1 mosquitto_sub -h mosquitto -t '#' -v) | grep -E '^(amcrest2mqtt/|homeassistant/[^/]+/amcrest2mqtt_)' | awk '{print $1}' | xargs -I TOPIC mosquitto_pub -h mosquitto -t TOPIC -r -n) 2>/dev/null
|
||||||
@ -1,121 +0,0 @@
|
|||||||
import logging
|
|
||||||
import os
|
|
||||||
import socket
|
|
||||||
import yaml
|
|
||||||
|
|
||||||
def get_ip_address(hostname: str) -> str:
|
|
||||||
"""
|
|
||||||
Resolve a hostname to an IP address (IPv4 or IPv6).
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
str: The resolved IP address, or the original hostname if resolution fails.
|
|
||||||
"""
|
|
||||||
if not hostname:
|
|
||||||
return hostname
|
|
||||||
|
|
||||||
try:
|
|
||||||
# Try both IPv4 and IPv6 (AF_UNSPEC)
|
|
||||||
infos = socket.getaddrinfo(
|
|
||||||
hostname, None, family=socket.AF_UNSPEC, type=socket.SOCK_STREAM
|
|
||||||
)
|
|
||||||
# Prefer IPv4 addresses if available
|
|
||||||
for family, _, _, _, sockaddr in infos:
|
|
||||||
if family == socket.AF_INET:
|
|
||||||
return sockaddr[0]
|
|
||||||
# Otherwise, fallback to first valid IPv6
|
|
||||||
return infos[0][4][0] if infos else hostname
|
|
||||||
except socket.gaierror as e:
|
|
||||||
logging.debug(f"DNS lookup failed for {hostname}: {e}")
|
|
||||||
except Exception as e:
|
|
||||||
logging.debug(f"Unexpected error resolving {hostname}: {e}")
|
|
||||||
|
|
||||||
return hostname
|
|
||||||
|
|
||||||
|
|
||||||
def to_gb(bytes_value):
|
|
||||||
"""Convert bytes to a rounded string in gigabytes."""
|
|
||||||
try:
|
|
||||||
gb = float(bytes_value) / (1024**3)
|
|
||||||
return f"{gb:.2f} GB"
|
|
||||||
except Exception:
|
|
||||||
return "0.00 GB"
|
|
||||||
|
|
||||||
|
|
||||||
def read_file(file_name, strip_newlines=True, default=None, encoding="utf-8"):
|
|
||||||
try:
|
|
||||||
with open(file_name, "r", encoding=encoding) as f:
|
|
||||||
data = f.read()
|
|
||||||
return data.replace("\n", "") if strip_newlines else data
|
|
||||||
except FileNotFoundError:
|
|
||||||
if default is not None:
|
|
||||||
return default
|
|
||||||
raise
|
|
||||||
|
|
||||||
|
|
||||||
def read_version():
|
|
||||||
base_dir = os.path.dirname(os.path.abspath(__file__))
|
|
||||||
version_path = os.path.join(base_dir, "VERSION")
|
|
||||||
try:
|
|
||||||
with open(version_path, "r") as f:
|
|
||||||
return f.read().strip() or "unknown"
|
|
||||||
except FileNotFoundError:
|
|
||||||
env_version = os.getenv("APP_VERSION")
|
|
||||||
return env_version.strip() if env_version else "unknown"
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(path=None):
|
|
||||||
"""Load and normalize configuration from YAML file or directory."""
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
|
||||||
default_path = "/config/config.yaml"
|
|
||||||
|
|
||||||
# Resolve config path
|
|
||||||
config_path = path or default_path
|
|
||||||
if os.path.isdir(config_path):
|
|
||||||
config_path = os.path.join(config_path, "config.yaml")
|
|
||||||
|
|
||||||
if not os.path.exists(config_path):
|
|
||||||
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
||||||
|
|
||||||
with open(config_path, "r") as f:
|
|
||||||
config = yaml.safe_load(f) or {}
|
|
||||||
|
|
||||||
# --- Normalization helpers ------------------------------------------------
|
|
||||||
def to_bool(value):
|
|
||||||
"""Coerce common truthy/falsey forms to proper bool."""
|
|
||||||
if isinstance(value, bool):
|
|
||||||
return value
|
|
||||||
if isinstance(value, (int, float)):
|
|
||||||
return bool(value)
|
|
||||||
if value is None:
|
|
||||||
return False
|
|
||||||
return str(value).strip().lower() in ("true", "1", "yes", "on")
|
|
||||||
|
|
||||||
def normalize_section(section, bool_keys):
|
|
||||||
"""Normalize booleans within a nested section."""
|
|
||||||
if not isinstance(config.get(section), dict):
|
|
||||||
return
|
|
||||||
for key in bool_keys:
|
|
||||||
if key in config[section]:
|
|
||||||
config[section][key] = to_bool(config[section][key])
|
|
||||||
|
|
||||||
# --- Global defaults + normalization -------------------------------------
|
|
||||||
config.setdefault("version", "1.0.0")
|
|
||||||
config.setdefault("debug", False)
|
|
||||||
config.setdefault("hide_ts", False)
|
|
||||||
config.setdefault("timezone", "UTC")
|
|
||||||
|
|
||||||
# normalize top-level flags
|
|
||||||
config["debug"] = to_bool(config.get("debug"))
|
|
||||||
config["hide_ts"] = to_bool(config.get("hide_ts"))
|
|
||||||
|
|
||||||
# Example: normalize booleans within sections
|
|
||||||
normalize_section("mqtt", ["tls", "retain", "clean_session"])
|
|
||||||
normalize_section("amcrest", ["webrtc", "verify_ssl"])
|
|
||||||
normalize_section("service", ["enabled", "auto_restart"])
|
|
||||||
|
|
||||||
# Add metadata for debugging/logging
|
|
||||||
config["config_path"] = os.path.abspath(config_path)
|
|
||||||
config["config_from"] = "file" if os.path.exists(config_path) else "defaults"
|
|
||||||
|
|
||||||
return config
|
|
||||||
@ -0,0 +1,665 @@
|
|||||||
|
version = 1
|
||||||
|
revision = 3
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "amcrest"
|
||||||
|
version = "1.9.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "argcomplete" },
|
||||||
|
{ name = "httpx" },
|
||||||
|
{ name = "requests" },
|
||||||
|
{ name = "typing-extensions" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/cd/0c/a0230ceff6bc344fbb936fd644ef757fbf4cf5e8494d605242aeb3ae1748/amcrest-1.9.9.tar.gz", hash = "sha256:bc97dfb3f946c8b6d368fcf6fc8541e09a2a3f4a63d0171edf3a1b4a5715883e", size = 45654, upload-time = "2025-07-14T16:41:45.104Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/12/d0ef02741cf372d2f9f2650cb09366d78f57189ddac6202ae06ee7c9451f/amcrest-1.9.9-py3-none-any.whl", hash = "sha256:ee6e1a39de2421a5eca2a79cb2d4c538c1ecd8d233f338b876986c7e8d422550", size = 54901, upload-time = "2025-07-14T16:41:43.916Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "amcrest2mqtt"
|
||||||
|
source = { editable = "." }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "amcrest" },
|
||||||
|
{ name = "deepmerge" },
|
||||||
|
{ name = "json-logging-graystorm" },
|
||||||
|
{ name = "paho-mqtt" },
|
||||||
|
{ name = "pathlib" },
|
||||||
|
{ name = "pyyaml" },
|
||||||
|
{ name = "requests" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.optional-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "attrs" },
|
||||||
|
{ name = "black" },
|
||||||
|
{ name = "jsonschema" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "pytest-asyncio" },
|
||||||
|
{ name = "pytest-cov" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.dev-dependencies]
|
||||||
|
dev = [
|
||||||
|
{ name = "black" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
{ name = "ruff" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[package.metadata]
|
||||||
|
requires-dist = [
|
||||||
|
{ name = "amcrest", specifier = ">=1.9.9" },
|
||||||
|
{ 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 = "jsonschema", marker = "extra == 'dev'", specifier = ">=4.25.1" },
|
||||||
|
{ name = "packaging", marker = "extra == 'dev'", specifier = ">=25.0" },
|
||||||
|
{ name = "paho-mqtt", specifier = ">=2.1.0" },
|
||||||
|
{ name = "pathlib", specifier = ">=1.0.1" },
|
||||||
|
{ name = "pytest", marker = "extra == 'dev'", specifier = ">=8.4.2" },
|
||||||
|
{ name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=1.2.0" },
|
||||||
|
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.0.0" },
|
||||||
|
{ name = "pyyaml", specifier = ">=6.0.3" },
|
||||||
|
{ name = "requests", specifier = ">=2.32.5" },
|
||||||
|
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.6.9" },
|
||||||
|
]
|
||||||
|
provides-extras = ["dev"]
|
||||||
|
|
||||||
|
[package.metadata.requires-dev]
|
||||||
|
dev = [
|
||||||
|
{ name = "black", specifier = ">=25.9.0" },
|
||||||
|
{ name = "pytest", specifier = ">=8.4.2" },
|
||||||
|
{ name = "ruff", specifier = ">=0.14.1" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyio"
|
||||||
|
version = "4.11.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "sniffio" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c6/78/7d432127c41b50bccba979505f272c16cbcadcc33645d5fa3a738110ae75/anyio-4.11.0.tar.gz", hash = "sha256:82a8d0b81e318cc5ce71a5f1f8b5c4e63619620b63141ef8c995fa0db95a57c4", size = 219094, upload-time = "2025-09-23T09:19:12.58Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/b3/9b1a8074496371342ec1e796a96f99c82c945a339cd81a8e73de28b4cf9e/anyio-4.11.0-py3-none-any.whl", hash = "sha256:0287e96f4d26d4149305414d4e3bc32f0dcd0862365a4bddea19d7a1ec38c4fc", size = 109097, upload-time = "2025-09-23T09:19:10.601Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argcomplete"
|
||||||
|
version = "3.6.3"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/38/61/0b9ae6399dd4a58d8c1b1dc5a27d6f2808023d0b5dd3104bb99f45a33ff6/argcomplete-3.6.3.tar.gz", hash = "sha256:62e8ed4fd6a45864acc8235409461b72c9a28ee785a2011cc5eb78318786c89c", size = 73754, upload-time = "2025-10-20T03:33:34.741Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/f5/9373290775639cb67a2fce7f629a1c240dce9f12fe927bc32b2736e16dfc/argcomplete-3.6.3-py3-none-any.whl", hash = "sha256:f5007b3a600ccac5d25bbce33089211dfd49eab4a7718da3f10e3082525a92ce", size = 43846, upload-time = "2025-10-20T03:33:33.021Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "attrs"
|
||||||
|
version = "25.4.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6b/5c/685e6633917e101e5dcb62b9dd76946cbb57c26e133bae9e0cd36033c0a9/attrs-25.4.0.tar.gz", hash = "sha256:16d5969b87f0859ef33a48b35d55ac1be6e42ae49d5e853b597db70c35c57e11", size = 934251, upload-time = "2025-10-06T13:54:44.725Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3a/2a/7cc015f5b9f5db42b7d48157e23356022889fc354a2813c15934b7cb5c0e/attrs-25.4.0-py3-none-any.whl", hash = "sha256:adcf7e2a1fb3b36ac48d97835bb6d8ade15b8dcce26aba8bf1d14847b57a3373", size = 67615, upload-time = "2025-10-06T13:54:43.17Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "black"
|
||||||
|
version = "25.9.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "click" },
|
||||||
|
{ name = "mypy-extensions" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pathspec" },
|
||||||
|
{ name = "platformdirs" },
|
||||||
|
{ name = "pytokens" },
|
||||||
|
]
|
||||||
|
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" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "certifi"
|
||||||
|
version = "2025.10.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/4c/5b/b6ce21586237c77ce67d01dc5507039d444b630dd76611bbca2d8e5dcd91/certifi-2025.10.5.tar.gz", hash = "sha256:47c09d31ccf2acf0be3f701ea53595ee7e0b8fa08801c6624be771df09ae7b43", size = 164519, upload-time = "2025-10-05T04:12:15.808Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/e4/37/af0d2ef3967ac0d6113837b44a4f0bfe1328c2b9763bd5b1744520e5cfed/certifi-2025.10.5-py3-none-any.whl", hash = "sha256:0f212c2744a9bb6de0c56639a6f68afe01ecd92d91f14ae897c4fe7bbeeef0de", size = 163286, upload-time = "2025-10-05T04:12:14.03Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "charset-normalizer"
|
||||||
|
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" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ba/33/0ad65587441fc730dc7bd90e9716b30b4702dc7b617e6ba4997dc8651495/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:752944c7ffbfdd10c074dc58ec2d5a8a4cd9493b314d367c14d24c17684ddd14", size = 160779, upload-time = "2025-10-14T04:41:37.229Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ed/331d6b249259ee71ddea93f6f2f0a56cfebd46938bde6fcc6f7b9a3d0e09/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d1f13550535ad8cff21b8d757a3257963e951d96e20ec82ab44bc64aeb62a191", size = 159035, upload-time = "2025-10-14T04:41:38.368Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/67/ff/f6b948ca32e4f2a4576aa129d8bed61f2e0543bf9f5f2b7fc3758ed005c9/charset_normalizer-3.4.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ecaae4149d99b1c9e7b88bb03e3221956f68fd6d50be2ef061b2381b61d20838", size = 152542, upload-time = "2025-10-14T04:41:39.862Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/85/276033dcbcc369eb176594de22728541a925b2632f9716428c851b149e83/charset_normalizer-3.4.4-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cb6254dc36b47a990e59e1068afacdcd02958bdcce30bb50cc1700a8b9d624a6", size = 149524, upload-time = "2025-10-14T04:41:41.319Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/9e/f2/6a2a1f722b6aba37050e626530a46a68f74e63683947a8acff92569f979a/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c8ae8a0f02f57a6e61203a31428fa1d677cbe50c93622b4149d5c0f319c1d19e", size = 150395, upload-time = "2025-10-14T04:41:42.539Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/60/bb/2186cb2f2bbaea6338cad15ce23a67f9b0672929744381e28b0592676824/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:47cc91b2f4dd2833fddaedd2893006b0106129d4b94fdb6af1f4ce5a9965577c", size = 143680, upload-time = "2025-10-14T04:41:43.661Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7d/a5/bf6f13b772fbb2a90360eb620d52ed8f796f3c5caee8398c3b2eb7b1c60d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:82004af6c302b5d3ab2cfc4cc5f29db16123b1a8417f2e25f9066f91d4411090", size = 162045, upload-time = "2025-10-14T04:41:44.821Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/df/c5/d1be898bf0dc3ef9030c3825e5d3b83f2c528d207d246cbabe245966808d/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:2b7d8f6c26245217bd2ad053761201e9f9680f8ce52f0fcd8d0755aeae5b2152", size = 149687, upload-time = "2025-10-14T04:41:46.442Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/42/90c1f7b9341eef50c8a1cb3f098ac43b0508413f33affd762855f67a410e/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:799a7a5e4fb2d5898c60b640fd4981d6a25f1c11790935a44ce38c54e985f828", size = 160014, upload-time = "2025-10-14T04:41:47.631Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/76/be/4d3ee471e8145d12795ab655ece37baed0929462a86e72372fd25859047c/charset_normalizer-3.4.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:99ae2cffebb06e6c22bdc25801d7b30f503cc87dbd283479e7b606f70aff57ec", size = 154044, upload-time = "2025-10-14T04:41:48.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b0/6f/8f7af07237c34a1defe7defc565a9bc1807762f672c0fde711a4b22bf9c0/charset_normalizer-3.4.4-cp314-cp314-win32.whl", hash = "sha256:f9d332f8c2a2fcbffe1378594431458ddbef721c1769d78e2cbc06280d8155f9", size = 99940, upload-time = "2025-10-14T04:41:49.946Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4b/51/8ade005e5ca5b0d80fb4aff72a3775b325bdc3d27408c8113811a7cbe640/charset_normalizer-3.4.4-cp314-cp314-win_amd64.whl", hash = "sha256:8a6562c3700cce886c5be75ade4a5db4214fda19fede41d9792d100288d8f94c", size = 107104, upload-time = "2025-10-14T04:41:51.051Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/5f/6b8f83a55bb8278772c5ae54a577f3099025f9ade59d0136ac24a0df4bde/charset_normalizer-3.4.4-cp314-cp314-win_arm64.whl", hash = "sha256:de00632ca48df9daf77a2c65a484531649261ec9f25489917f09e455cb09ddb2", size = 100743, upload-time = "2025-10-14T04:41:52.122Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "click"
|
||||||
|
version = "8.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/46/61/de6cd827efad202d7057d93e0fed9294b96952e188f7384832791c7b2254/click-8.3.0.tar.gz", hash = "sha256:e7b8232224eba16f4ebe410c25ced9f7875cb5f3263ffc93cc3e8da705e229c4", size = 276943, upload-time = "2025-09-18T17:32:23.696Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/db/d3/9dcc0f5797f070ec8edf30fbadfb200e71d9db6b84d211e3b2085a7589a0/click-8.3.0-py3-none-any.whl", hash = "sha256:9b9f285302c6e3064f4330c05f05b81945b2a39544279343e6e7c5f27a9baddc", size = 107295, upload-time = "2025-09-18T17:32:22.42Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorama"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "coverage"
|
||||||
|
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" },
|
||||||
|
{ 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/5f/04/642c1d8a448ae5ea1369eac8495740a79eb4e581a9fb0cbdce56bbf56da1/coverage-7.11.0-py3-none-any.whl", hash = "sha256:4b7589765348d78fb4e5fb6ea35d07564e387da2fc5efff62e0222971f155f68", size = 207761, upload-time = "2025-10-15T15:15:06.439Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "deepmerge"
|
||||||
|
version = "2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a8/3a/b0ba594708f1ad0bc735884b3ad854d3ca3bdc1d741e56e40bbda6263499/deepmerge-2.0.tar.gz", hash = "sha256:5c3d86081fbebd04dd5de03626a0607b809a98fb6ccba5770b62466fe940ff20", size = 19890, upload-time = "2024-08-30T05:31:50.308Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "h11"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpcore"
|
||||||
|
version = "1.0.9"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "h11" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "httpx"
|
||||||
|
version = "0.28.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "anyio" },
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "httpcore" },
|
||||||
|
{ name = "idna" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "idna"
|
||||||
|
version = "3.11"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/6f/6d/0703ccc57f3a7233505399edb88de3cbd678da106337b9fcde432b65ed60/idna-3.11.tar.gz", hash = "sha256:795dafcc9c04ed0c1fb032c2aa73654d8e8c5023a7df64a53f39190ada629902", size = 194582, upload-time = "2025-10-12T14:55:20.501Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "iniconfig"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[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" }
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonschema"
|
||||||
|
version = "4.25.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "attrs" },
|
||||||
|
{ name = "jsonschema-specifications" },
|
||||||
|
{ name = "referencing" },
|
||||||
|
{ name = "rpds-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/74/69/f7185de793a29082a9f3c7728268ffb31cb5095131a9c139a74078e27336/jsonschema-4.25.1.tar.gz", hash = "sha256:e4a9655ce0da0c0b67a085847e00a3a51449e1157f4f75e9fb5aa545e122eb85", size = 357342, upload-time = "2025-08-18T17:03:50.038Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/bf/9c/8c95d856233c1f82500c2450b8c68576b4cf1c871db3afac5c34ff84e6fd/jsonschema-4.25.1-py3-none-any.whl", hash = "sha256:3fba0169e345c7175110351d456342c364814cfcf3b964ba4587f22915230a63", size = 90040, upload-time = "2025-08-18T17:03:48.373Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "jsonschema-specifications"
|
||||||
|
version = "2025.9.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "referencing" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855, upload-time = "2025-09-08T01:34:59.186Z" }
|
||||||
|
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 = "mypy-extensions"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "packaging"
|
||||||
|
version = "25.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a1/d4/1fc4078c65507b51b96ca8f8c3ba19e6a61c8253c72794544580a7b6c24d/packaging-25.0.tar.gz", hash = "sha256:d443872c98d677bf60f6a1f2f8c1cb748e8fe762d2bf9d3148b5599295b0fc4f", size = 165727, upload-time = "2025-04-19T11:48:59.673Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/20/12/38679034af332785aac8774540895e234f4d07f7545804097de4b666afd8/packaging-25.0-py3-none-any.whl", hash = "sha256:29572ef2b1f17581046b3a2227d5c611fb25ec70ca1ba8554b24b0e69331a484", size = 66469, upload-time = "2025-04-19T11:48:57.875Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paho-mqtt"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathlib"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ac/aa/9b065a76b9af472437a0059f77e8f962fe350438b927cb80184c32f075eb/pathlib-1.0.1.tar.gz", hash = "sha256:6940718dfc3eff4258203ad5021090933e5c04707d5ca8cc9e73c94a7894ea9f", size = 49298, upload-time = "2014-09-03T15:41:57.18Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/78/f9/690a8600b93c332de3ab4a344a4ac34f00c8f104917061f779db6a918ed6/pathlib-1.0.1-py3-none-any.whl", hash = "sha256:f35f95ab8b0f59e6d354090350b44a80a80635d22efdedfa84c7ad1cf0a74147", size = 14363, upload-time = "2022-05-04T13:37:20.585Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pathspec"
|
||||||
|
version = "0.12.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ca/bc/f35b8446f4531a7cb215605d100cd88b7ac6f44ab3fc94870c120ab3adbf/pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712", size = 51043, upload-time = "2023-12-10T22:30:45Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/cc/20/ff623b09d963f88bfde16306a54e12ee5ea43e9b597108672ff3a408aad6/pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08", size = 31191, upload-time = "2023-12-10T22:30:43.14Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "platformdirs"
|
||||||
|
version = "4.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/61/33/9611380c2bdb1225fdef633e2a9610622310fed35ab11dac9620972ee088/platformdirs-4.5.0.tar.gz", hash = "sha256:70ddccdd7c99fc5942e9fc25636a8b34d04c24b335100223152c2803e4063312", size = 21632, upload-time = "2025-10-08T17:44:48.791Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/73/cb/ac7874b3e5d58441674fb70742e6c374b28b0c7cb988d37d991cde47166c/platformdirs-4.5.0-py3-none-any.whl", hash = "sha256:e578a81bb873cbb89a41fcc904c7ef523cc18284b7e3b3ccf06aca1403b7ebd3", size = 18651, upload-time = "2025-10-08T17:44:47.223Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pluggy"
|
||||||
|
version = "1.6.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pygments"
|
||||||
|
version = "2.19.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/b0/77/a5b8c569bf593b0140bde72ea885a803b82086995367bf2037de0159d924/pygments-2.19.2.tar.gz", hash = "sha256:636cb2477cec7f8952536970bc533bc43743542f70392ae026374600add5b887", size = 4968631, upload-time = "2025-06-21T13:39:12.283Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c7/21/705964c7812476f378728bdf590ca4b771ec72385c533964653c68e86bdc/pygments-2.19.2-py3-none-any.whl", hash = "sha256:86540386c03d588bb81d44bc3928634ff26449851e99741617ecb9037ee5ec0b", size = 1225217, upload-time = "2025-06-21T13:39:07.939Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest"
|
||||||
|
version = "8.4.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||||
|
{ name = "iniconfig" },
|
||||||
|
{ name = "packaging" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pygments" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618, upload-time = "2025-09-04T14:34:22.711Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750, upload-time = "2025-09-04T14:34:20.226Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-asyncio"
|
||||||
|
version = "1.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/42/86/9e3c5f48f7b7b638b216e4b9e645f54d199d7abbbab7a64a13b4e12ba10f/pytest_asyncio-1.2.0.tar.gz", hash = "sha256:c609a64a2a8768462d0c99811ddb8bd2583c33fd33cf7f21af1c142e824ffb57", size = 50119, upload-time = "2025-09-12T07:33:53.816Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/04/93/2fa34714b7a4ae72f2f8dad66ba17dd9a2c793220719e736dda28b7aec27/pytest_asyncio-1.2.0-py3-none-any.whl", hash = "sha256:8e17ae5e46d8e7efe51ab6494dd2010f4ca8dae51652aa3c8d55acf50bfb2e99", size = 15095, upload-time = "2025-09-12T07:33:52.639Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytest-cov"
|
||||||
|
version = "7.0.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "coverage" },
|
||||||
|
{ name = "pluggy" },
|
||||||
|
{ name = "pytest" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/5e/f7/c933acc76f5208b3b00089573cf6a2bc26dc80a8aece8f52bb7d6b1855ca/pytest_cov-7.0.0.tar.gz", hash = "sha256:33c97eda2e049a0c5298e91f519302a1334c26ac65c1a483d6206fd458361af1", size = 54328, upload-time = "2025-09-09T10:57:02.113Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ee/49/1377b49de7d0c1ce41292161ea0f721913fa8722c19fb9c1e3aa0367eecb/pytest_cov-7.0.0-py3-none-any.whl", hash = "sha256:3b8e9558b16cc1479da72058bdecf8073661c7f57f7d3c5f22a1c23507f2d861", size = 22424, upload-time = "2025-09-09T10:57:00.695Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pytokens"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/d4/c2/dbadcdddb412a267585459142bfd7cc241e6276db69339353ae6e241ab2b/pytokens-0.2.0.tar.gz", hash = "sha256:532d6421364e5869ea57a9523bf385f02586d4662acbcc0342afd69511b4dd43", size = 15368, upload-time = "2025-10-15T08:02:42.738Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/89/5a/c269ea6b348b6f2c32686635df89f32dbe05df1088dd4579302a6f8f99af/pytokens-0.2.0-py3-none-any.whl", hash = "sha256:74d4b318c67f4295c13782ddd9abcb7e297ec5630ad060eb90abf7ebbefe59f8", size = 12038, upload-time = "2025-10-15T08:02:41.694Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "pyyaml"
|
||||||
|
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" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "referencing"
|
||||||
|
version = "0.37.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "attrs" },
|
||||||
|
{ name = "rpds-py" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766, upload-time = "2025-10-13T15:30:47.625Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "requests"
|
||||||
|
version = "2.32.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
dependencies = [
|
||||||
|
{ name = "certifi" },
|
||||||
|
{ name = "charset-normalizer" },
|
||||||
|
{ name = "idna" },
|
||||||
|
{ name = "urllib3" },
|
||||||
|
]
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/c9/74/b3ff8e6c8446842c3f5c837e9c3dfcfe2018ea6ecef224c710c85ef728f4/requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf", size = 134517, upload-time = "2025-08-18T20:46:02.573Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rpds-py"
|
||||||
|
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" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/13/bce4384d9f8f4989f1a9599c71b7a2d877462e5fd7175e1f69b398f729f4/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8a358a32dd3ae50e933347889b6af9a1bdf207ba5d1a3f34e1a38cd3540e6733", size = 382767, upload-time = "2025-10-22T22:23:21.787Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/23/e1/579512b2d89a77c64ccef5a0bc46a6ef7f72ae0cf03d4b26dcd52e57ee0a/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e80848a71c78aa328fefaba9c244d588a342c8e03bda518447b624ea64d1ff56", size = 517585, upload-time = "2025-10-22T22:23:23.699Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/62/3c/ca704b8d324a2591b0b0adcfcaadf9c862375b11f2f667ac03c61b4fd0a6/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f586db2e209d54fe177e58e0bc4946bea5fb0102f150b1b2f13de03e1f0976f8", size = 399828, upload-time = "2025-10-22T22:23:25.713Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/37/e84283b9e897e3adc46b4c88bb3f6ec92a43bd4d2f7ef5b13459963b2e9c/rpds_py-0.28.0-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5ae8ee156d6b586e4292491e885d41483136ab994e719a13458055bec14cf370", size = 375509, upload-time = "2025-10-22T22:23:27.32Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1a/c2/a980beab869d86258bf76ec42dec778ba98151f253a952b02fe36d72b29c/rpds_py-0.28.0-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a805e9b3973f7e27f7cab63a6b4f61d90f2e5557cff73b6e97cd5b8540276d3d", size = 392014, upload-time = "2025-10-22T22:23:29.332Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/da/b5/b1d3c5f9d3fa5aeef74265f9c64de3c34a0d6d5cd3c81c8b17d5c8f10ed4/rpds_py-0.28.0-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5d3fd16b6dc89c73a4da0b4ac8b12a7ecc75b2864b95c9e5afed8003cb50a728", size = 402410, upload-time = "2025-10-22T22:23:31.14Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/74/ae/cab05ff08dfcc052afc73dcb38cbc765ffc86f94e966f3924cd17492293c/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:6796079e5d24fdaba6d49bda28e2c47347e89834678f2bc2c1b4fc1489c0fb01", size = 553593, upload-time = "2025-10-22T22:23:32.834Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/70/80/50d5706ea2a9bfc9e9c5f401d91879e7c790c619969369800cde202da214/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:76500820c2af232435cbe215e3324c75b950a027134e044423f59f5b9a1ba515", size = 576925, upload-time = "2025-10-22T22:23:34.47Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/12/85a57d7a5855a3b188d024b099fd09c90db55d32a03626d0ed16352413ff/rpds_py-0.28.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bbdc5640900a7dbf9dd707fe6388972f5bbd883633eb68b76591044cfe346f7e", size = 542444, upload-time = "2025-10-22T22:23:36.093Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/65/10643fb50179509150eb94d558e8837c57ca8b9adc04bd07b98e57b48f8c/rpds_py-0.28.0-cp314-cp314-win32.whl", hash = "sha256:adc8aa88486857d2b35d75f0640b949759f79dc105f50aa2c27816b2e0dd749f", size = 207968, upload-time = "2025-10-22T22:23:37.638Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/84/0c11fe4d9aaea784ff4652499e365963222481ac647bcd0251c88af646eb/rpds_py-0.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:66e6fa8e075b58946e76a78e69e1a124a21d9a48a5b4766d15ba5b06869d1fa1", size = 218876, upload-time = "2025-10-22T22:23:39.179Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/0f/e0/3ab3b86ded7bb18478392dc3e835f7b754cd446f62f3fc96f4fe2aca78f6/rpds_py-0.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:a6fe887c2c5c59413353b7c0caff25d0e566623501ccfff88957fa438a69377d", size = 212506, upload-time = "2025-10-22T22:23:40.755Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/51/ec/d5681bb425226c3501eab50fc30e9d275de20c131869322c8a1729c7b61c/rpds_py-0.28.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:7a69df082db13c7070f7b8b1f155fa9e687f1d6aefb7b0e3f7231653b79a067b", size = 355433, upload-time = "2025-10-22T22:23:42.259Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/be/ec/568c5e689e1cfb1ea8b875cffea3649260955f677fdd7ddc6176902d04cd/rpds_py-0.28.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b1cde22f2c30ebb049a9e74c5374994157b9b70a16147d332f89c99c5960737a", size = 342601, upload-time = "2025-10-22T22:23:44.372Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/32/fe/51ada84d1d2a1d9d8f2c902cfddd0133b4a5eb543196ab5161d1c07ed2ad/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5338742f6ba7a51012ea470bd4dc600a8c713c0c72adaa0977a1b1f4327d6592", size = 372039, upload-time = "2025-10-22T22:23:46.025Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/07/c1/60144a2f2620abade1a78e0d91b298ac2d9b91bc08864493fa00451ef06e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e1460ebde1bcf6d496d80b191d854adedcc619f84ff17dc1c6d550f58c9efbba", size = 382407, upload-time = "2025-10-22T22:23:48.098Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/ed/091a7bbdcf4038a60a461df50bc4c82a7ed6d5d5e27649aab61771c17585/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e3eb248f2feba84c692579257a043a7699e28a77d86c77b032c1d9fbb3f0219c", size = 518172, upload-time = "2025-10-22T22:23:50.16Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/54/dd/02cc90c2fd9c2ef8016fd7813bfacd1c3a1325633ec8f244c47b449fc868/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3bbba5def70b16cd1c1d7255666aad3b290fbf8d0fe7f9f91abafb73611a91", size = 399020, upload-time = "2025-10-22T22:23:51.81Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/ab/81/5d98cc0329bbb911ccecd0b9e19fbf7f3a5de8094b4cda5e71013b2dd77e/rpds_py-0.28.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3114f4db69ac5a1f32e7e4d1cbbe7c8f9cf8217f78e6e002cedf2d54c2a548ed", size = 377451, upload-time = "2025-10-22T22:23:53.711Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b4/07/4d5bcd49e3dfed2d38e2dcb49ab6615f2ceb9f89f5a372c46dbdebb4e028/rpds_py-0.28.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4b0cb8a906b1a0196b863d460c0222fb8ad0f34041568da5620f9799b83ccf0b", size = 390355, upload-time = "2025-10-22T22:23:55.299Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/3f/79/9f14ba9010fee74e4f40bf578735cfcbb91d2e642ffd1abe429bb0b96364/rpds_py-0.28.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:cf681ac76a60b667106141e11a92a3330890257e6f559ca995fbb5265160b56e", size = 403146, upload-time = "2025-10-22T22:23:56.929Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/39/4c/f08283a82ac141331a83a40652830edd3a4a92c34e07e2bbe00baaea2f5f/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1e8ee6413cfc677ce8898d9cde18cc3a60fc2ba756b0dec5b71eb6eb21c49fa1", size = 552656, upload-time = "2025-10-22T22:23:58.62Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/61/47/d922fc0666f0dd8e40c33990d055f4cc6ecff6f502c2d01569dbed830f9b/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:b3072b16904d0b5572a15eb9d31c1954e0d3227a585fc1351aa9878729099d6c", size = 576782, upload-time = "2025-10-22T22:24:00.312Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d3/0c/5bafdd8ccf6aa9d3bfc630cfece457ff5b581af24f46a9f3590f790e3df2/rpds_py-0.28.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b670c30fd87a6aec281c3c9896d3bae4b205fd75d79d06dc87c2503717e46092", size = 544671, upload-time = "2025-10-22T22:24:02.297Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2c/37/dcc5d8397caa924988693519069d0beea077a866128719351a4ad95e82fc/rpds_py-0.28.0-cp314-cp314t-win32.whl", hash = "sha256:8014045a15b4d2b3476f0a287fcc93d4f823472d7d1308d47884ecac9e612be3", size = 205749, upload-time = "2025-10-22T22:24:03.848Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/d7/69/64d43b21a10d72b45939a28961216baeb721cc2a430f5f7c3bfa21659a53/rpds_py-0.28.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7a4e59c90d9c27c561eb3160323634a9ff50b04e4f7820600a2beb0ac90db578", size = 216233, upload-time = "2025-10-22T22:24:05.471Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ruff"
|
||||||
|
version = "0.14.2"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/ee/34/8218a19b2055b80601e8fd201ec723c74c7fe1ca06d525a43ed07b6d8e85/ruff-0.14.2.tar.gz", hash = "sha256:98da787668f239313d9c902ca7c523fe11b8ec3f39345553a51b25abc4629c96", size = 5539663, upload-time = "2025-10-23T19:37:00.956Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/16/dd/23eb2db5ad9acae7c845700493b72d3ae214dce0b226f27df89216110f2b/ruff-0.14.2-py3-none-linux_armv6l.whl", hash = "sha256:7cbe4e593505bdec5884c2d0a4d791a90301bc23e49a6b1eb642dd85ef9c64f1", size = 12533390, upload-time = "2025-10-23T19:36:18.044Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5a/8c/5f9acff43ddcf3f85130d0146d0477e28ccecc495f9f684f8f7119b74c0d/ruff-0.14.2-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:8d54b561729cee92f8d89c316ad7a3f9705533f5903b042399b6ae0ddfc62e11", size = 12887187, upload-time = "2025-10-23T19:36:22.664Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/99/fa/047646491479074029665022e9f3dc6f0515797f40a4b6014ea8474c539d/ruff-0.14.2-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5c8753dfa44ebb2cde10ce5b4d2ef55a41fb9d9b16732a2c5df64620dbda44a3", size = 11925177, upload-time = "2025-10-23T19:36:24.778Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/15/8b/c44cf7fe6e59ab24a9d939493a11030b503bdc2a16622cede8b7b1df0114/ruff-0.14.2-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d0bbeffb8d9f4fccf7b5198d566d0bad99a9cb622f1fc3467af96cb8773c9e3", size = 12358285, upload-time = "2025-10-23T19:36:26.979Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/45/01/47701b26254267ef40369aea3acb62a7b23e921c27372d127e0f3af48092/ruff-0.14.2-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7047f0c5a713a401e43a88d36843d9c83a19c584e63d664474675620aaa634a8", size = 12303832, upload-time = "2025-10-23T19:36:29.192Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2d/5c/ae7244ca4fbdf2bee9d6405dcd5bc6ae51ee1df66eb7a9884b77b8af856d/ruff-0.14.2-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3bf8d2f9aa1602599217d82e8e0af7fd33e5878c4d98f37906b7c93f46f9a839", size = 13036995, upload-time = "2025-10-23T19:36:31.861Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/27/4c/0860a79ce6fd4c709ac01173f76f929d53f59748d0dcdd662519835dae43/ruff-0.14.2-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:1c505b389e19c57a317cf4b42db824e2fca96ffb3d86766c1c9f8b96d32048a7", size = 14512649, upload-time = "2025-10-23T19:36:33.915Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/7f/7f/d365de998069720a3abfc250ddd876fc4b81a403a766c74ff9bde15b5378/ruff-0.14.2-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a307fc45ebd887b3f26b36d9326bb70bf69b01561950cdcc6c0bdf7bb8e0f7cc", size = 14088182, upload-time = "2025-10-23T19:36:36.983Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6c/ea/d8e3e6b209162000a7be1faa41b0a0c16a133010311edc3329753cc6596a/ruff-0.14.2-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:61ae91a32c853172f832c2f40bd05fd69f491db7289fb85a9b941ebdd549781a", size = 13599516, upload-time = "2025-10-23T19:36:39.208Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/fa/ea/c7810322086db68989fb20a8d5221dd3b79e49e396b01badca07b433ab45/ruff-0.14.2-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1967e40286f63ee23c615e8e7e98098dedc7301568bd88991f6e544d8ae096", size = 13272690, upload-time = "2025-10-23T19:36:41.453Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a9/39/10b05acf8c45786ef501d454e00937e1b97964f846bf28883d1f9619928a/ruff-0.14.2-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:2877f02119cdebf52a632d743a2e302dea422bfae152ebe2f193d3285a3a65df", size = 13496497, upload-time = "2025-10-23T19:36:43.61Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/59/a1/1f25f8301e13751c30895092485fada29076e5e14264bdacc37202e85d24/ruff-0.14.2-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e681c5bc777de5af898decdcb6ba3321d0d466f4cb43c3e7cc2c3b4e7b843a05", size = 12266116, upload-time = "2025-10-23T19:36:45.625Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/5c/fa/0029bfc9ce16ae78164e6923ef392e5f173b793b26cc39aa1d8b366cf9dc/ruff-0.14.2-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:e21be42d72e224736f0c992cdb9959a2fa53c7e943b97ef5d081e13170e3ffc5", size = 12281345, upload-time = "2025-10-23T19:36:47.618Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/ab/ece7baa3c0f29b7683be868c024f0838770c16607bea6852e46b202f1ff6/ruff-0.14.2-py3-none-musllinux_1_2_i686.whl", hash = "sha256:b8264016f6f209fac16262882dbebf3f8be1629777cf0f37e7aff071b3e9b92e", size = 12629296, upload-time = "2025-10-23T19:36:49.789Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a4/7f/638f54b43f3d4e48c6a68062794e5b367ddac778051806b9e235dfb7aa81/ruff-0.14.2-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:5ca36b4cb4db3067a3b24444463ceea5565ea78b95fe9a07ca7cb7fd16948770", size = 13371610, upload-time = "2025-10-23T19:36:51.882Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/35/3654a973ebe5b32e1fd4a08ed2d46755af7267da7ac710d97420d7b8657d/ruff-0.14.2-py3-none-win32.whl", hash = "sha256:41775927d287685e08f48d8eb3f765625ab0b7042cc9377e20e64f4eb0056ee9", size = 12415318, upload-time = "2025-10-23T19:36:53.961Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/71/30/3758bcf9e0b6a4193a6f51abf84254aba00887dfa8c20aba18aa366c5f57/ruff-0.14.2-py3-none-win_amd64.whl", hash = "sha256:0df3424aa5c3c08b34ed8ce099df1021e3adaca6e90229273496b839e5a7e1af", size = 13565279, upload-time = "2025-10-23T19:36:56.578Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sniffio"
|
||||||
|
version = "1.3.1"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372, upload-time = "2024-02-25T23:20:04.057Z" }
|
||||||
|
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 = "typing-extensions"
|
||||||
|
version = "4.15.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "urllib3"
|
||||||
|
version = "2.5.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599c47dd506532914ce044817c7752a79b6a51286319bc/urllib3-2.5.0.tar.gz", hash = "sha256:3fc47733c7e419d4bc3f6b3dc2b4f890bb743906a30d56ba4a5bfa4bbff92760", size = 393185, upload-time = "2025-06-18T14:07:41.644Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||||
|
]
|
||||||
Loading…
Reference in New Issue