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
Jeff Culverhouse 4 months ago
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/

@ -4,6 +4,7 @@ on:
push: push:
branches: branches:
- "main" - "main"
pull_request:
workflow_dispatch: workflow_dispatch:
schedule: schedule:
- cron: "0 3 1 * *" # Monthly rebuild at 03:00 UTC on the 1st - cron: "0 3 1 * *" # Monthly rebuild at 03:00 UTC on the 1st
@ -15,20 +16,58 @@ permissions:
packages: write packages: write
jobs: jobs:
lint:
name: Lint (ruff/black)
runs-on: ubuntu-latest
if: github.event_name != 'schedule'
strategy:
fail-fast: false
max-parallel: 2
matrix:
python-version: ['3.9', '3.10', '3.11', '3.12']
steps:
- uses: actions/checkout@v5
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Set up uv
uses: astral-sh/setup-uv@v3
with:
version: "latest"
# If your dev tools (ruff/black/rst-lint/rich/etc.) are in optional-dependencies
# like [project.optional-dependencies.dev], this installs them.
- name: Install project (dev)
run: uv sync --all-extras --dev
- name: Ruff
run: uv run ruff check src
- name: Black
run: |
uv run black --version
uv run black --check --color --diff .
release: release:
name: Semantic Release name: Semantic Release
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [lint]
if: github.event_name != 'schedule' if: github.event_name != 'schedule'
outputs: outputs:
release_tag: ${{ steps.get_release.outputs.release_tag }} published: ${{ steps.semrel.outputs.new_release_published }}
version: ${{ steps.semrel.outputs.new_release_version }}
tag: ${{ steps.semrel.outputs.new_release_git_tag }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Setup Node - name: Setup Node
uses: actions/setup-node@v4 uses: actions/setup-node@v6
with: with:
node-version: 20 node-version: 20
@ -36,18 +75,25 @@ jobs:
run: npm ci run: npm ci
- name: Run semantic-release - name: Run semantic-release
id: semantic id: semrel
uses: cycjimmy/semantic-release-action@v4
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: npx semantic-release with:
extra_plugins: |
@semantic-release/changelog
@semantic-release/git
@semantic-release/npm
- name: Get latest release tag - name: Update VERSION file in repo
id: get_release if: steps.semrel.outputs.new_release_published == 'true'
run: | run: |
TAG=$(git describe --tags --abbrev=0) echo "${{ steps.semrel.outputs.new_release_version }}" > VERSION
echo "$TAG" > VERSION git config user.name "github-actions[bot]"
echo "release_tag=$TAG" >> $GITHUB_OUTPUT git config user.email "github-actions[bot]@users.noreply.github.com"
echo "Found release tag: $TAG" git add VERSION
git diff --cached --quiet || git commit -m "chore: update VERSION to ${{ steps.semrel.outputs.new_release_version }}"
git push
docker: docker:
name: Build and Push Docker Image name: Build and Push Docker Image
@ -55,11 +101,12 @@ jobs:
if: github.event_name == 'schedule' || needs.release.result == 'success' if: github.event_name == 'schedule' || needs.release.result == 'success'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
RELEASE_TAG: ${{ needs.release.outputs.release_tag }} RELEASE_VERSION: ${{ needs.release.outputs.version }}
RELEASE_TAG: ${{ needs.release.outputs.tag }}
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@v4 uses: actions/checkout@v5
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v3 uses: docker/login-action@v3
@ -73,17 +120,51 @@ jobs:
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3 uses: docker/setup-buildx-action@v3
- name: Docker meta
id: meta
uses: docker/metadata-action@v5
with:
images: graystorm/amcrest2mqtt
tags: |
type=raw,value=${{ env.RELEASE_TAG }}
type=raw,value=latest
labels: |
org.opencontainers.image.title=amcrest2mqtt
org.opencontainers.image.description=Publishes Amcrest device data to MQTT for Home Assistant
org.opencontainers.image.url=https://www.graystorm.com
org.opencontainers.image.documentation=https://github.com/${{ github.repository }}#readme
org.opencontainers.image.source=https://github.com/${{ github.repository }}
org.opencontainers.image.revision=${{ github.sha }}
- name: Fetch tags
run: git fetch --tags --force
- name: Ensure VERSION is set
id: version-fallback
run: |
if [ -z "${{ needs.release.outputs.version }}" ]; then
TAG=$(git tag --sort=-creatordate | head -n 1)
if [ -z "$TAG" ]; then
echo "No tags found — defaulting to 0.0.0"
TAG="0.0.0"
fi
echo "Using existing tag: $TAG"
echo "VERSION=$TAG" >> $GITHUB_ENV
else
echo "VERSION=${{ needs.release.outputs.version }}" >> $GITHUB_ENV
fi
- name: Build and push - name: Build and push
id: build-and-push id: build-and-push
uses: docker/build-push-action@v6 uses: docker/build-push-action@v6
with: with:
sbom: true context: .
provenance: true
platforms: linux/arm64,linux/amd64
tags: |
graystorm/amcrest2mqtt:latest
graystorm/amcrest2mqtt:${{ env.RELEASE_TAG }}
push: true push: true
build-args: |
VERSION=${{ env.VERSION }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
platforms: linux/arm64,linux/amd64
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
labels: | labels: |
@ -95,3 +176,5 @@ jobs:
org.opencontainers.image.documentation=https://github.com/${{ github.repository }}#readme org.opencontainers.image.documentation=https://github.com/${{ github.repository }}#readme
org.opencontainers.image.description=Publishes Amcrest camera events, snapshots, and status updates via MQTT for Home Assistant auto-discovery org.opencontainers.image.description=Publishes Amcrest camera events, snapshots, and status updates via MQTT for Home Assistant auto-discovery
org.opencontainers.image.licenses=MIT org.opencontainers.image.licenses=MIT
sbom: true
provenance: true

1
.gitignore vendored

@ -19,7 +19,6 @@ venv/
config config
config/ config/
config.yaml config.yaml
govee2mqtt.dat
npm-debug.log npm-debug.log
NOTES NOTES
coverage/ coverage/

@ -1,3 +1,39 @@
🚀 Version 3.0.0 — Major Refactor and Rearchitecture
This release represents a complete modernization of amcrest2mqtt, bringing cleaner structure, better MQTT handling, and richer event data.
Highlights
- Modularized codebase under src/amcrest2mqtt/
- Brand-new MqttMixin with resilient reconnect, structured logs, and HA rediscovery support
- HelpersMixin for device-state building and service-level control commands
- AmcrestApiMixin replaces direct device calls with consolidated error handling
- New sensor.event_time (timestamp) and sensor.event_text entities for human-readable event tracking
- Added doorbell and human detection binary sensors for supported models (AD110/AD410)
- Proper Home Assistant schema compliance: ISO 8601 timestamps, availability templates, and via-device linkage
- Clean shutdown on SIGTERM/SIGINT and improved signal management
- Full developer environment setup (black, ruff, pytest, coverage settings)
- Utility script tools/clear_mqtt.sh for clearing retained topics
- Docker image metadata updated with links, license, and version labels
Breaking Changes
- Moved all code to src/ package layout — update imports and mount paths if using bind mounts.
- MQTT topics slightly restructured for consistency across entities.
- Deprecated util.py; its helpers are now integrated into mixins.
1.0.1 1.0.1
- lookup camera hostnames to get ip at setup time, so we aren't doing - lookup camera hostnames to get ip at setup time, so we aren't doing
100k lookups every day (in my 4 camera setup, for example) 100k lookups every day (in my 4 camera setup, for example)

@ -1,52 +1,47 @@
# builder stage ----------------------------------------------------------------------------------- # syntax=docker/dockerfile:1.7-labs
FROM python:3-slim AS builder FROM python:3-slim
WORKDIR /app
RUN apt-get update && \ COPY pyproject.toml uv.lock ./
apt-get install -y apt-transport-https && \
apt-get -y upgrade && \
apt-get install --no-install-recommends -y build-essential && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
RUN python3 -m ensurepip # ---- Version injection support ----
RUN pip3 install --upgrade pip setuptools ARG VERSION
ENV AMCREST2MQTT_VERSION=${VERSION}
ENV SETUPTOOLS_SCM_PRETEND_VERSION_FOR_AMCREST2MQTT=${VERSION}
WORKDIR /usr/src/app # Install uv
RUN pip install uv
COPY requirements.txt . # copy source
RUN python3 -m venv .venv COPY --exclude=.git . .
RUN .venv/bin/pip3 install --no-cache-dir --upgrade -r requirements.txt
# production stage -------------------------------------------------------------------------------- # Install dependencies (uses setup info, now src exists)
FROM python:3-slim AS production RUN uv sync --frozen --no-dev
RUN apt-get update && \ # Install the package (if needed)
apt-get install -y apt-transport-https && \ RUN uv pip install .
apt-get -y upgrade && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*
WORKDIR /usr/src/app
COPY . .
COPY --from=builder /usr/src/app/.venv .venv
RUN mkdir /config
RUN touch /config/config.yaml
# Default build arguments (can be overridden at build time)
ARG USER_ID=1000 ARG USER_ID=1000
ARG GROUP_ID=1000 ARG GROUP_ID=1000
RUN addgroup --gid $GROUP_ID appuser && \ # Create the app user and group
adduser --uid $USER_ID --gid $GROUP_ID --disabled-password --gecos "" appuser RUN groupadd --gid "${GROUP_ID}" appuser && \
useradd --uid "${USER_ID}" --gid "${GROUP_ID}" --create-home --shell /bin/bash appuser
RUN chown -R appuser:appuser . # Ensure /config exists and is writable
RUN chown appuser:appuser /config/* RUN mkdir -p /config && chown -R appuser:appuser /config
RUN chmod 0664 /config/*
USER appuser # Optional: fix perms if files already copied there (wont break if empty)
RUN find /config -type f -exec chmod 0664 {} + || true
# Ensure /app is owned by the app user
RUN chown -R appuser:appuser /app
ENV PATH="/usr/src/app/.venv/bin:$PATH" # Drop privileges
USER appuser
ENTRYPOINT [ "python3", "./app.py" ] # ---- Runtime ----
CMD [ "-c", "/config" ] ENV SERVICE=amcrest2mqtt
ENTRYPOINT ["/app/.venv/bin/amcrest2mqtt"]
CMD ["-c", "/config"]

@ -93,7 +93,7 @@ CMD [ "python", "-u", "./app.py", "-c", "/config" ]
Docker is the only supported way of deploying the application. The app should run directly via Python but this is not supported. Docker is the only supported way of deploying the application. The app should run directly via Python but this is not supported.
## See also ## See also
* [govee2mqtt](https://github.com/weirdtangent/govee2mqtt) * [amcrest2mqtt](https://github.com/weirdtangent/amcrest2mqtt)
* [blink2mqtt](https://github.com/weirdtangent/blink2mqtt) * [blink2mqtt](https://github.com/weirdtangent/blink2mqtt)
## Buy Me A Coffee ## Buy Me A Coffee

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

@ -42,7 +42,7 @@ class AmcrestMqtt(object):
self.service_name = self.mqtt_config['prefix'] + ' service' self.service_name = self.mqtt_config['prefix'] + ' service'
self.service_slug = self.mqtt_config['prefix'] + '-service' self.service_slug = self.mqtt_config['prefix'] + '-service'
self.configs = {} self.devices = {}
self.states = {} self.states = {}
def __enter__(self): def __enter__(self):
@ -63,19 +63,18 @@ class AmcrestMqtt(object):
# MQTT Functions ------------------------------------------------------------------------------ # MQTT Functions ------------------------------------------------------------------------------
def mqtt_on_connect(self, client, userdata, flags, reason_code, properties): def mqtt_on_connect(self, client, userdata, flags, rc, properties):
if reason_code.value != 0: if rc != 0:
self.logger.error(f'MQTT connection issue ({reason_code.getName()})') self.logger.error(f'MQTT connection issue ({rc})')
self.running = False exit()
return
self.logger.info(f'MQTT connected as {self.client_id}') self.logger.info(f'MQTT connected as {self.client_id}')
client.subscribe("homeassistant/status") client.subscribe("homeassistant/status")
client.subscribe(self.get_device_sub_topic()) client.subscribe(self.get_device_sub_topic())
client.subscribe(self.get_attribute_sub_topic()) client.subscribe(self.get_attribute_sub_topic())
def mqtt_on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties): def mqtt_on_disconnect(self, client, userdata, flags, rc, properties):
self.logger.warning(f'MQTT disconnected: {reason_code.getName()} (flags={disconnect_flags})') self.logger.info('MQTT connection closed')
self.mqttc.loop_stop() self.mqttc.loop_stop()
if self.running and time.time() > self.mqtt_connect_time + 10: if self.running and time.time() > self.mqtt_connect_time + 10:
@ -89,6 +88,7 @@ class AmcrestMqtt(object):
self.paused = False self.paused = False
else: else:
self.running = False self.running = False
exit()
def mqtt_on_log(self, client, userdata, paho_log_level, msg): def mqtt_on_log(self, client, userdata, paho_log_level, msg):
if paho_log_level == mqtt.LogLevel.MQTT_LOG_ERR: if paho_log_level == mqtt.LogLevel.MQTT_LOG_ERR:
@ -169,10 +169,10 @@ class AmcrestMqtt(object):
def mqttc_create(self): def mqttc_create(self):
self.mqttc = mqtt.Client( self.mqttc = mqtt.Client(
client_id=self.client_id,
callback_api_version=mqtt.CallbackAPIVersion.VERSION2, callback_api_version=mqtt.CallbackAPIVersion.VERSION2,
client_id=self.client_id,
clean_session=False,
reconnect_on_failure=False, reconnect_on_failure=False,
protocol=mqtt.MQTTv5,
) )
if self.mqtt_config.get('tls_enabled'): if self.mqtt_config.get('tls_enabled'):
@ -183,8 +183,7 @@ class AmcrestMqtt(object):
cert_reqs=ssl.CERT_REQUIRED, cert_reqs=ssl.CERT_REQUIRED,
tls_version=ssl.PROTOCOL_TLS, tls_version=ssl.PROTOCOL_TLS,
) )
self.mqttc.tls_insecure_set(self.mqtt_config.get("tls_insecure", False)) else:
if self.mqtt_config.get('username'):
self.mqttc.username_pw_set( self.mqttc.username_pw_set(
username=self.mqtt_config.get('username'), username=self.mqtt_config.get('username'),
password=self.mqtt_config.get('password'), password=self.mqtt_config.get('password'),
@ -200,24 +199,16 @@ class AmcrestMqtt(object):
self.mqttc.will_set(self.get_discovery_topic('service', 'availability'), payload="offline", qos=self.mqtt_config['qos'], retain=True) self.mqttc.will_set(self.get_discovery_topic('service', 'availability'), payload="offline", qos=self.mqtt_config['qos'], retain=True)
try: try:
self.logger.info(
f"Connecting to MQTT broker at {self.mqtt_config.get('host')}:{self.mqtt_config.get('port')} "
f"as {self.client_id}"
)
self.mqttc.connect( self.mqttc.connect(
host=self.mqtt_config.get('host'), self.mqtt_config.get('host'),
port=self.mqtt_config.get('port'), port=self.mqtt_config.get('port'),
keepalive=60, keepalive=60,
) )
self.mqtt_connect_time = time.time() self.mqtt_connect_time = time.time()
self.mqttc.loop_start() self.mqttc.loop_start()
except Exception as error: except ConnectionError as error:
self.logger.error( self.logger.error(f'COULD NOT CONNECT TO MQTT {self.mqtt_config.get("host")}: {error}')
f"Failed to connect to MQTT broker {self.mqtt_config.get('host')}:{self.mqtt_config.get('port')} " exit(1)
f"({type(error).__name__}: {error})",
exc_info=True,
)
self.running = False
# MQTT Topics --------------------------------------------------------------------------------- # MQTT Topics ---------------------------------------------------------------------------------
@ -225,9 +216,9 @@ class AmcrestMqtt(object):
return self.mqtt_config['prefix'] + '-' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=8)) return self.mqtt_config['prefix'] + '-' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=8))
def get_device_name(self, device_id): def get_device_name(self, device_id):
if device_id not in self.configs or 'device' not in self.configs[device_id] or 'name' not in self.configs[device_id]['device']: if device_id not in self.devices or 'device' not in self.devices[device_id] or 'name' not in self.devices[device_id]['device']:
return f'<{device_id}>' return f'<{device_id}>'
return self.configs[device_id]['device']['name'] return self.devices[device_id]['device']['name']
def get_slug(self, device_id, type): def get_slug(self, device_id, type):
return f"amcrest_{device_id.replace(':','')}_{type}" return f"amcrest_{device_id.replace(':','')}_{type}"
@ -278,8 +269,8 @@ class AmcrestMqtt(object):
return f"{self.service_slug}_{suffix}" return f"{self.service_slug}_{suffix}"
def svc_topic(self, sub: str) -> str: def svc_topic(self, sub: str) -> str:
# Runtime topics (your own prefix), e.g. govee2mqtt/govee-service/state # Runtime topics (your own prefix), e.g. amcrest2mqtt/amcrest-service/state
pfx = self.mqtt_config.get('prefix', 'govee2mqtt') pfx = self.mqtt_config.get('prefix', 'amcrest2mqtt')
return f"{pfx}/amcrest-service/{sub}" return f"{pfx}/amcrest-service/{sub}"
# Service Device ------------------------------------------------------------------------------ # Service Device ------------------------------------------------------------------------------
@ -387,7 +378,7 @@ class AmcrestMqtt(object):
async def setup_devices(self): async def setup_devices(self):
self.logger.info(f'Setup devices') self.logger.info(f'Setup devices')
first_time_through = True if len(self.configs) == 0 else False first_time_through = True if len(self.devices) == 0 else False
devices = await self.amcrestc.connect_to_devices() devices = await self.amcrestc.connect_to_devices()
self.publish_service_device() self.publish_service_device()
@ -396,16 +387,16 @@ class AmcrestMqtt(object):
if 'device_type' in config: if 'device_type' in config:
first = False first = False
if device_id not in self.configs: if device_id not in self.devices:
first = True first = True
self.configs[device_id] = {} self.devices[device_id] = {}
self.states[device_id] = config self.states[device_id] = config
self.configs[device_id]['qos'] = self.mqtt_config['qos'] self.devices[device_id]['qos'] = self.mqtt_config['qos']
self.configs[device_id]['state_topic'] = self.get_discovery_topic(device_id, 'state') self.devices[device_id]['state_topic'] = self.get_discovery_topic(device_id, 'state')
self.configs[device_id]['availability_topic'] = self.get_discovery_topic('service', 'availability') self.devices[device_id]['availability_topic'] = self.get_discovery_topic('service', 'availability')
self.configs[device_id]['command_topic'] = self.get_discovery_topic(device_id, 'set') self.devices[device_id]['command_topic'] = self.get_discovery_topic(device_id, 'set')
self.configs[device_id]['device'] = { self.devices[device_id]['device'] = {
'name': config['device_name'], 'name': config['device_name'],
'manufacturer': config['vendor'], 'manufacturer': config['vendor'],
'model': config['device_type'], 'model': config['device_type'],
@ -420,7 +411,7 @@ class AmcrestMqtt(object):
'configuration_url': 'http://' + config['host'] + '/', 'configuration_url': 'http://' + config['host'] + '/',
'via_device': self.service_slug, 'via_device': self.service_slug,
} }
self.configs[device_id]['origin'] = { self.devices[device_id]['origin'] = {
'name': self.service_name, 'name': self.service_name,
'sw_version': self.version, 'sw_version': self.version,
'support_url': 'https://github.com/weirdtangent/amcrest2mqtt', 'support_url': 'https://github.com/weirdtangent/amcrest2mqtt',
@ -441,7 +432,7 @@ class AmcrestMqtt(object):
self.logger.info(f'Adding device: "{config['device_name']}" [Amcrest {config["device_type"]}] ({device_id})') self.logger.info(f'Adding device: "{config['device_name']}" [Amcrest {config["device_type"]}] ({device_id})')
self.publish_device_discovery(device_id) self.publish_device_discovery(device_id)
else: else:
self.logger.debug(f'Updated device: {self.configs[device_id]['device']['name']}') self.logger.debug(f'Updated device: {self.devices[device_id]['device']['name']}')
else: else:
if first_time_through: if first_time_through:
@ -454,7 +445,7 @@ class AmcrestMqtt(object):
# add amcrest components to devices # add amcrest components to devices
def add_components_to_device(self, device_id): def add_components_to_device(self, device_id):
device_config = self.configs[device_id] device_config = self.devices[device_id]
device_states = self.states[device_id] device_states = self.states[device_id]
components = {} components = {}
@ -495,33 +486,14 @@ class AmcrestMqtt(object):
'value_template': '{{ value_json.state }}', 'value_template': '{{ value_json.state }}',
'unique_id': self.get_slug(device_id, 'snapshot_camera'), 'unique_id': self.get_slug(device_id, 'snapshot_camera'),
} }
# --- Safe WebRTC config handling ---------------------------------------- if 'webrtc' in self.amcrest_config:
webrtc_config = self.amcrest_config.get("webrtc") webrtc_config = self.amcrest_config['webrtc']
rtc_host = webrtc_config['host']
# Handle missing, boolean, or incomplete configs gracefully rtc_port = webrtc_config['port']
if isinstance(webrtc_config, bool) or not webrtc_config: rtc_link = webrtc_config['link']
self.logger.debug("No valid WebRTC config found; skipping WebRTC setup.") rtc_source = webrtc_config['sources'].pop(0)
else: rtc_url = f'http://{rtc_host}:{rtc_port}/{rtc_link}?src={rtc_source}'
try: device_config['device']['configuration_url'] = rtc_url
rtc_host = webrtc_config.get("host")
rtc_port = webrtc_config.get("port")
rtc_link = webrtc_config.get("link")
rtc_sources = webrtc_config.get("sources", [])
rtc_source = rtc_sources[0] if rtc_sources else None
if rtc_host and rtc_port and rtc_link and rtc_source:
rtc_url = f"http://{rtc_host}:{rtc_port}/{rtc_link}?src={rtc_source}"
device_config["device"]["configuration_url"] = rtc_url
self.logger.debug(f"Added WebRTC config URL for {device_id}: {rtc_url}")
else:
self.logger.warning(
f"Incomplete WebRTC config for {device_id}: {webrtc_config}"
)
except Exception as e:
self.logger.warning(
f"Failed to apply WebRTC config for {device_id}: {e}", exc_info=True
)
# copy the snapshot camera for the eventshot camera, with a couple of changes # copy the snapshot camera for the eventshot camera, with a couple of changes
components[self.get_slug(device_id, 'event_camera')] = { components[self.get_slug(device_id, 'event_camera')] = {
@ -678,7 +650,7 @@ class AmcrestMqtt(object):
self.mqttc.publish(publish_topic, payload, qos=self.mqtt_config['qos'], retain=True) self.mqttc.publish(publish_topic, payload, qos=self.mqtt_config['qos'], retain=True)
def publish_device_discovery(self, device_id): def publish_device_discovery(self, device_id):
device_config = self.configs[device_id] device_config = self.devices[device_id]
payload = json.dumps(device_config) payload = json.dumps(device_config)
self.mqttc.publish(self.get_discovery_topic(device_id, 'config'), payload, qos=self.mqtt_config['qos'], retain=True) self.mqttc.publish(self.get_discovery_topic(device_id, 'config'), payload, qos=self.mqtt_config['qos'], retain=True)
@ -688,7 +660,7 @@ class AmcrestMqtt(object):
def refresh_storage_all_devices(self): def refresh_storage_all_devices(self):
self.logger.info(f'Refreshing storage info for all devices (every {self.storage_update_interval} sec)') self.logger.info(f'Refreshing storage info for all devices (every {self.storage_update_interval} sec)')
for device_id in self.configs: for device_id in self.devices:
if not self.running: break if not self.running: break
device_states = self.states[device_id] device_states = self.states[device_id]
@ -712,7 +684,7 @@ class AmcrestMqtt(object):
def refresh_snapshot_all_devices(self): def refresh_snapshot_all_devices(self):
self.logger.info(f'Collecting snapshots for all devices (every {self.snapshot_update_interval} sec)') self.logger.info(f'Collecting snapshots for all devices (every {self.snapshot_update_interval} sec)')
for device_id in self.configs: for device_id in self.devices:
if not self.running: break if not self.running: break
self.refresh_snapshot(device_id,'snapshot') self.refresh_snapshot(device_id,'snapshot')
@ -784,9 +756,9 @@ class AmcrestMqtt(object):
def handle_service_message(self, attribute, message): def handle_service_message(self, attribute, message):
match attribute: match attribute:
case "storage_refresh": case 'storage_refresh':
self.storage_update_interval = message self.storage_update_interval = message
self.logger.info(f"Updated STORAGE_REFRESH_INTERVAL to be {message}") self.logger.info(f'Updated STORAGE_REFRESH_INTERVAL to be {message}')
case 'snapshot_refresh': case 'snapshot_refresh':
self.snapshot_update_interval = message self.snapshot_update_interval = message
self.logger.info(f'Updated SNAPSHOT_REFRESH_INTERVAL to be {message}') self.logger.info(f'Updated SNAPSHOT_REFRESH_INTERVAL to be {message}')
@ -842,7 +814,7 @@ class AmcrestMqtt(object):
def rediscover_all(self): def rediscover_all(self):
self.publish_service_state() self.publish_service_state()
for device_id in self.configs: for device_id in self.devices:
if device_id == 'service': continue if device_id == 'service': continue
self.publish_device_state(device_id) self.publish_device_state(device_id)
self.publish_device_discovery(device_id) self.publish_device_discovery(device_id)
@ -879,85 +851,30 @@ class AmcrestMqtt(object):
# main loop # main loop
async def main_loop(self): async def main_loop(self):
"""Main event loop for Amcrest MQTT service."""
await self.setup_devices() await self.setup_devices()
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
# Create async tasks with descriptive names
tasks = [ tasks = [
asyncio.create_task( asyncio.create_task(self.collect_storage_info()),
self.collect_storage_info(), name="collect_storage_info" asyncio.create_task(self.collect_events()),
), asyncio.create_task(self.check_event_queue()),
asyncio.create_task(self.collect_events(), name="collect_events"), asyncio.create_task(self.collect_snapshots()),
asyncio.create_task(self.check_event_queue(), name="check_event_queue"),
asyncio.create_task(self.collect_snapshots(), name="collect_snapshots"),
] ]
# Graceful signal handler # setup signal handling for tasks
def _signal_handler(signame):
"""Immediate, aggressive shutdown handler for Ctrl+C or SIGTERM."""
self.logger.warning(f"{signame} received — initiating shutdown NOW...")
self.running = False
# Cancel *all* asyncio tasks, even those not tracked manually
loop = asyncio.get_event_loop()
for task in asyncio.all_tasks(loop):
if not task.done():
task.cancel(f"{signame} received")
# Force-stop ProcessPoolExecutor if present
try:
if hasattr(self, "api") and hasattr(self.api, "executor"):
self.logger.debug("Force-shutting down process pool...")
self.api.executor.shutdown(wait=False, cancel_futures=True)
except Exception as e:
self.logger.debug(f"Error force-stopping process pool: {e}")
# Stop the loop immediately after a short delay
loop.call_later(0.05, loop.stop)
for sig in (signal.SIGINT, signal.SIGTERM): for sig in (signal.SIGINT, signal.SIGTERM):
try: loop.add_signal_handler(
loop.add_signal_handler(sig, _signal_handler, sig.name) sig, lambda: asyncio.create_task(self._handle_signals(sig.name, loop))
except NotImplementedError: )
# Windows compatibility
self.logger.debug(f"Signal handling not supported on this platform.")
try: try:
results = await asyncio.gather(*tasks, return_exceptions=True) results = await asyncio.gather(*tasks, return_exceptions=True)
for result in results:
# Handle task exceptions individually if isinstance(result, Exception):
for t, result in zip(tasks, results):
if isinstance(result, asyncio.CancelledError):
self.logger.info(f"Task '{t.get_name()}' cancelled.")
elif isinstance(result, Exception):
self.logger.error(
f"Task '{t.get_name()}' raised an exception: {result}",
exc_info=True,
)
self.running = False self.running = False
self.logger.error(f'Caught exception: {err}', exc_info=True)
except asyncio.CancelledError: except asyncio.CancelledError:
self.logger.info("Main loop cancelled; shutting down...") exit(1)
except Exception as err: except Exception as err:
self.logger.exception(f"Unhandled exception in main loop: {err}")
self.running = False self.running = False
finally: self.logger.error(f'Caught exception: {err}')
self.logger.info("All loops terminated, performing final cleanup...")
try:
# Save final state or cleanup hooks if needed
if hasattr(self, "save_state"):
self.save_state()
except Exception as e:
self.logger.warning(f"Error during save_state: {e}")
# Disconnect MQTT cleanly
if self.mqttc and self.mqttc.is_connected():
try:
self.mqttc.disconnect()
except Exception as e:
self.logger.warning(f"Error during MQTT disconnect: {e}")
self.logger.info("Main loop complete.")

@ -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…
Cancel
Save