From e230a7673f114a41e98d6f5a30999f4c336cab61 Mon Sep 17 00:00:00 2001 From: Jeff Culverhouse Date: Fri, 10 Oct 2025 04:17:49 -0400 Subject: [PATCH] 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. --- .dockerignore | 12 + .github/workflows/deploy.yaml | 121 +++- .gitignore | 3 +- CHANGELOG | 36 ++ Dockerfile | 71 +-- README.md | 2 +- amcrest_api.py | 437 -------------- app.py | 61 -- amcrest_mqtt.py => old/amcrest_mqtt.py.old | 199 ++---- pyproject.toml | 151 +++++ src/amcrest2mqtt/__init__.py | 2 + src/amcrest2mqtt/app.py | 53 ++ src/amcrest2mqtt/base.py | 110 ++++ src/amcrest2mqtt/core.py | 27 + src/amcrest2mqtt/mixins/amcrest.py | 390 ++++++++++++ src/amcrest2mqtt/mixins/amcrest_api.py | 353 +++++++++++ src/amcrest2mqtt/mixins/events.py | 70 +++ src/amcrest2mqtt/mixins/helpers.py | 120 ++++ src/amcrest2mqtt/mixins/loops.py | 84 +++ src/amcrest2mqtt/mixins/mqtt.py | 206 +++++++ src/amcrest2mqtt/mixins/refresh.py | 33 + src/amcrest2mqtt/mixins/service.py | 185 ++++++ src/amcrest2mqtt/mixins/topics.py | 123 ++++ src/amcrest2mqtt/mixins/util.py | 149 +++++ src/healthcheck.py | 17 + src/interface.py | 89 +++ tools/clear_mqtt.sh | 3 + util.py | 121 ---- uv.lock | 665 +++++++++++++++++++++ 29 files changed, 3073 insertions(+), 820 deletions(-) create mode 100644 .dockerignore delete mode 100644 amcrest_api.py delete mode 100644 app.py rename amcrest_mqtt.py => old/amcrest_mqtt.py.old (83%) create mode 100644 pyproject.toml create mode 100644 src/amcrest2mqtt/__init__.py create mode 100644 src/amcrest2mqtt/app.py create mode 100644 src/amcrest2mqtt/base.py create mode 100644 src/amcrest2mqtt/core.py create mode 100644 src/amcrest2mqtt/mixins/amcrest.py create mode 100644 src/amcrest2mqtt/mixins/amcrest_api.py create mode 100644 src/amcrest2mqtt/mixins/events.py create mode 100644 src/amcrest2mqtt/mixins/helpers.py create mode 100644 src/amcrest2mqtt/mixins/loops.py create mode 100644 src/amcrest2mqtt/mixins/mqtt.py create mode 100644 src/amcrest2mqtt/mixins/refresh.py create mode 100644 src/amcrest2mqtt/mixins/service.py create mode 100644 src/amcrest2mqtt/mixins/topics.py create mode 100644 src/amcrest2mqtt/mixins/util.py create mode 100755 src/healthcheck.py create mode 100644 src/interface.py create mode 100755 tools/clear_mqtt.sh delete mode 100644 util.py create mode 100644 uv.lock diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0a8c448 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.DS_Store +node_modules/ +.venv/ +__pycache__/ +.pytest_cache/ +.ruff_cache/ +dist/ +build/ +*.egg-info/ +tests/ +config/ \ No newline at end of file diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 85953dc..f6f3aa5 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -4,6 +4,7 @@ on: push: branches: - "main" + pull_request: workflow_dispatch: schedule: - cron: "0 3 1 * *" # Monthly rebuild at 03:00 UTC on the 1st @@ -15,20 +16,58 @@ permissions: packages: write 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: name: Semantic Release runs-on: ubuntu-latest + needs: [lint] if: github.event_name != 'schedule' 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: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 with: fetch-depth: 0 - name: Setup Node - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: node-version: 20 @@ -36,18 +75,25 @@ jobs: run: npm ci - name: Run semantic-release - id: semantic + id: semrel + uses: cycjimmy/semantic-release-action@v4 env: 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 - id: get_release + - name: Update VERSION file in repo + if: steps.semrel.outputs.new_release_published == 'true' run: | - TAG=$(git describe --tags --abbrev=0) - echo "$TAG" > VERSION - echo "release_tag=$TAG" >> $GITHUB_OUTPUT - echo "Found release tag: $TAG" + echo "${{ steps.semrel.outputs.new_release_version }}" > VERSION + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add VERSION + git diff --cached --quiet || git commit -m "chore: update VERSION to ${{ steps.semrel.outputs.new_release_version }}" + git push docker: name: Build and Push Docker Image @@ -55,11 +101,12 @@ jobs: if: github.event_name == 'schedule' || needs.release.result == 'success' runs-on: ubuntu-latest env: - RELEASE_TAG: ${{ needs.release.outputs.release_tag }} + RELEASE_VERSION: ${{ needs.release.outputs.version }} + RELEASE_TAG: ${{ needs.release.outputs.tag }} steps: - name: Checkout code - uses: actions/checkout@v4 + uses: actions/checkout@v5 - name: Login to Docker Hub uses: docker/login-action@v3 @@ -73,17 +120,51 @@ jobs: - name: Set up Docker Buildx 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 id: build-and-push uses: docker/build-push-action@v6 with: - sbom: true - provenance: true - platforms: linux/arm64,linux/amd64 - tags: | - graystorm/amcrest2mqtt:latest - graystorm/amcrest2mqtt:${{ env.RELEASE_TAG }} + context: . 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-to: type=gha,mode=max labels: | @@ -95,3 +176,5 @@ jobs: 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.licenses=MIT + sbom: true + provenance: true diff --git a/.gitignore b/.gitignore index 0690cb6..429b32a 100644 --- a/.gitignore +++ b/.gitignore @@ -19,11 +19,10 @@ venv/ config config/ config.yaml -govee2mqtt.dat npm-debug.log NOTES coverage/ dist/ # Apple -.DS_Store \ No newline at end of file +.DS_Store diff --git a/CHANGELOG b/CHANGELOG index d34cac5..78dbe8e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -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 - 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) diff --git a/Dockerfile b/Dockerfile index bab8c20..e889795 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,52 +1,47 @@ -# builder stage ----------------------------------------------------------------------------------- -FROM python:3-slim AS builder +# syntax=docker/dockerfile:1.7-labs +FROM python:3-slim +WORKDIR /app -RUN apt-get update && \ - 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/* +COPY pyproject.toml uv.lock ./ -RUN python3 -m ensurepip -RUN pip3 install --upgrade pip setuptools +# ---- Version injection support ---- +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 . -RUN python3 -m venv .venv -RUN .venv/bin/pip3 install --no-cache-dir --upgrade -r requirements.txt +# copy source +COPY --exclude=.git . . -# production stage -------------------------------------------------------------------------------- -FROM python:3-slim AS production +# Install dependencies (uses setup info, now src exists) +RUN uv sync --frozen --no-dev -RUN apt-get update && \ - apt-get install -y apt-transport-https && \ - 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 +# Install the package (if needed) +RUN uv pip install . +# Default build arguments (can be overridden at build time) ARG USER_ID=1000 ARG GROUP_ID=1000 -RUN addgroup --gid $GROUP_ID appuser && \ - adduser --uid $USER_ID --gid $GROUP_ID --disabled-password --gecos "" appuser +# Create the app user and group +RUN groupadd --gid "${GROUP_ID}" appuser && \ + useradd --uid "${USER_ID}" --gid "${GROUP_ID}" --create-home --shell /bin/bash appuser -RUN chown -R appuser:appuser . -RUN chown appuser:appuser /config/* -RUN chmod 0664 /config/* +# Ensure /config exists and is writable +RUN mkdir -p /config && chown -R appuser:appuser /config -USER appuser +# Optional: fix perms if files already copied there (won’t 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" ] -CMD [ "-c", "/config" ] +# ---- Runtime ---- +ENV SERVICE=amcrest2mqtt +ENTRYPOINT ["/app/.venv/bin/amcrest2mqtt"] +CMD ["-c", "/config"] diff --git a/README.md b/README.md index 82dcb47..27233a2 100644 --- a/README.md +++ b/README.md @@ -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. ## See also -* [govee2mqtt](https://github.com/weirdtangent/govee2mqtt) +* [amcrest2mqtt](https://github.com/weirdtangent/amcrest2mqtt) * [blink2mqtt](https://github.com/weirdtangent/blink2mqtt) ## Buy Me A Coffee diff --git a/amcrest_api.py b/amcrest_api.py deleted file mode 100644 index 745004a..0000000 --- a/amcrest_api.py +++ /dev/null @@ -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} diff --git a/app.py b/app.py deleted file mode 100644 index 54caa10..0000000 --- a/app.py +++ /dev/null @@ -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.") \ No newline at end of file diff --git a/amcrest_mqtt.py b/old/amcrest_mqtt.py.old similarity index 83% rename from amcrest_mqtt.py rename to old/amcrest_mqtt.py.old index 9410290..fa7d29c 100644 --- a/amcrest_mqtt.py +++ b/old/amcrest_mqtt.py.old @@ -42,7 +42,7 @@ class AmcrestMqtt(object): self.service_name = self.mqtt_config['prefix'] + ' service' self.service_slug = self.mqtt_config['prefix'] + '-service' - self.configs = {} + self.devices = {} self.states = {} def __enter__(self): @@ -63,19 +63,18 @@ class AmcrestMqtt(object): # MQTT Functions ------------------------------------------------------------------------------ - def mqtt_on_connect(self, client, userdata, flags, reason_code, properties): - if reason_code.value != 0: - self.logger.error(f'MQTT connection issue ({reason_code.getName()})') - self.running = False - return + def mqtt_on_connect(self, client, userdata, flags, rc, properties): + if rc != 0: + self.logger.error(f'MQTT connection issue ({rc})') + exit() self.logger.info(f'MQTT connected as {self.client_id}') client.subscribe("homeassistant/status") client.subscribe(self.get_device_sub_topic()) client.subscribe(self.get_attribute_sub_topic()) - def mqtt_on_disconnect(self, client, userdata, disconnect_flags, reason_code, properties): - self.logger.warning(f'MQTT disconnected: {reason_code.getName()} (flags={disconnect_flags})') + def mqtt_on_disconnect(self, client, userdata, flags, rc, properties): + self.logger.info('MQTT connection closed') self.mqttc.loop_stop() if self.running and time.time() > self.mqtt_connect_time + 10: @@ -89,6 +88,7 @@ class AmcrestMqtt(object): self.paused = False else: self.running = False + exit() def mqtt_on_log(self, client, userdata, paho_log_level, msg): if paho_log_level == mqtt.LogLevel.MQTT_LOG_ERR: @@ -169,10 +169,10 @@ class AmcrestMqtt(object): def mqttc_create(self): self.mqttc = mqtt.Client( - client_id=self.client_id, callback_api_version=mqtt.CallbackAPIVersion.VERSION2, + client_id=self.client_id, + clean_session=False, reconnect_on_failure=False, - protocol=mqtt.MQTTv5, ) if self.mqtt_config.get('tls_enabled'): @@ -183,8 +183,7 @@ class AmcrestMqtt(object): cert_reqs=ssl.CERT_REQUIRED, tls_version=ssl.PROTOCOL_TLS, ) - self.mqttc.tls_insecure_set(self.mqtt_config.get("tls_insecure", False)) - if self.mqtt_config.get('username'): + else: self.mqttc.username_pw_set( username=self.mqtt_config.get('username'), 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) 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( - host=self.mqtt_config.get('host'), + self.mqtt_config.get('host'), port=self.mqtt_config.get('port'), keepalive=60, ) self.mqtt_connect_time = time.time() self.mqttc.loop_start() - except Exception as error: - self.logger.error( - f"Failed to connect to MQTT broker {self.mqtt_config.get('host')}:{self.mqtt_config.get('port')} " - f"({type(error).__name__}: {error})", - exc_info=True, - ) - self.running = False + except ConnectionError as error: + self.logger.error(f'COULD NOT CONNECT TO MQTT {self.mqtt_config.get("host")}: {error}') + exit(1) # MQTT Topics --------------------------------------------------------------------------------- @@ -225,9 +216,9 @@ class AmcrestMqtt(object): return self.mqtt_config['prefix'] + '-' + ''.join(random.choices(string.ascii_lowercase + string.digits, k=8)) 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 self.configs[device_id]['device']['name'] + return self.devices[device_id]['device']['name'] def get_slug(self, device_id, type): return f"amcrest_{device_id.replace(':','')}_{type}" @@ -278,8 +269,8 @@ class AmcrestMqtt(object): return f"{self.service_slug}_{suffix}" def svc_topic(self, sub: str) -> str: - # Runtime topics (your own prefix), e.g. govee2mqtt/govee-service/state - pfx = self.mqtt_config.get('prefix', 'govee2mqtt') + # Runtime topics (your own prefix), e.g. amcrest2mqtt/amcrest-service/state + pfx = self.mqtt_config.get('prefix', 'amcrest2mqtt') return f"{pfx}/amcrest-service/{sub}" # Service Device ------------------------------------------------------------------------------ @@ -387,7 +378,7 @@ class AmcrestMqtt(object): async def setup_devices(self): 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() self.publish_service_device() @@ -396,16 +387,16 @@ class AmcrestMqtt(object): if 'device_type' in config: first = False - if device_id not in self.configs: + if device_id not in self.devices: first = True - self.configs[device_id] = {} + self.devices[device_id] = {} self.states[device_id] = config - self.configs[device_id]['qos'] = self.mqtt_config['qos'] - self.configs[device_id]['state_topic'] = self.get_discovery_topic(device_id, 'state') - self.configs[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]['qos'] = self.mqtt_config['qos'] + self.devices[device_id]['state_topic'] = self.get_discovery_topic(device_id, 'state') + self.devices[device_id]['availability_topic'] = self.get_discovery_topic('service', 'availability') + 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'], 'manufacturer': config['vendor'], 'model': config['device_type'], @@ -420,7 +411,7 @@ class AmcrestMqtt(object): 'configuration_url': 'http://' + config['host'] + '/', 'via_device': self.service_slug, } - self.configs[device_id]['origin'] = { + self.devices[device_id]['origin'] = { 'name': self.service_name, 'sw_version': self.version, '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.publish_device_discovery(device_id) 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: if first_time_through: @@ -454,7 +445,7 @@ class AmcrestMqtt(object): # add amcrest components to devices 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] components = {} @@ -495,33 +486,14 @@ class AmcrestMqtt(object): 'value_template': '{{ value_json.state }}', 'unique_id': self.get_slug(device_id, 'snapshot_camera'), } - # --- Safe WebRTC config handling ---------------------------------------- - webrtc_config = self.amcrest_config.get("webrtc") - - # Handle missing, boolean, or incomplete configs gracefully - if isinstance(webrtc_config, bool) or not webrtc_config: - self.logger.debug("No valid WebRTC config found; skipping WebRTC setup.") - else: - try: - 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 - ) + 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}' + device_config['device']['configuration_url'] = rtc_url # copy the snapshot camera for the eventshot camera, with a couple of changes components[self.get_slug(device_id, 'event_camera')] = { @@ -678,17 +650,17 @@ class AmcrestMqtt(object): self.mqttc.publish(publish_topic, payload, qos=self.mqtt_config['qos'], retain=True) def publish_device_discovery(self, device_id): - device_config = self.configs[device_id] + device_config = self.devices[device_id] payload = json.dumps(device_config) self.mqttc.publish(self.get_discovery_topic(device_id, 'config'), payload, qos=self.mqtt_config['qos'], retain=True) - # refresh * all devices ----------------------------------------------------------------------- + # refresh * all devices ----------------------------------------------------------------------- def refresh_storage_all_devices(self): 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 device_states = self.states[device_id] @@ -712,7 +684,7 @@ class AmcrestMqtt(object): def refresh_snapshot_all_devices(self): 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 self.refresh_snapshot(device_id,'snapshot') @@ -784,9 +756,9 @@ class AmcrestMqtt(object): def handle_service_message(self, attribute, message): match attribute: - case "storage_refresh": + case 'storage_refresh': 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': self.snapshot_update_interval = message self.logger.info(f'Updated SNAPSHOT_REFRESH_INTERVAL to be {message}') @@ -842,7 +814,7 @@ class AmcrestMqtt(object): def rediscover_all(self): self.publish_service_state() - for device_id in self.configs: + for device_id in self.devices: if device_id == 'service': continue self.publish_device_state(device_id) self.publish_device_discovery(device_id) @@ -879,85 +851,30 @@ class AmcrestMqtt(object): # main loop async def main_loop(self): - """Main event loop for Amcrest MQTT service.""" await self.setup_devices() loop = asyncio.get_running_loop() - - # Create async tasks with descriptive names tasks = [ - asyncio.create_task( - self.collect_storage_info(), name="collect_storage_info" - ), - asyncio.create_task(self.collect_events(), name="collect_events"), - asyncio.create_task(self.check_event_queue(), name="check_event_queue"), - asyncio.create_task(self.collect_snapshots(), name="collect_snapshots"), + asyncio.create_task(self.collect_storage_info()), + asyncio.create_task(self.collect_events()), + asyncio.create_task(self.check_event_queue()), + asyncio.create_task(self.collect_snapshots()), ] - # Graceful signal handler - 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) - + # setup signal handling for tasks for sig in (signal.SIGINT, signal.SIGTERM): - try: - loop.add_signal_handler(sig, _signal_handler, sig.name) - except NotImplementedError: - # Windows compatibility - self.logger.debug(f"Signal handling not supported on this platform.") + loop.add_signal_handler( + sig, lambda: asyncio.create_task(self._handle_signals(sig.name, loop)) + ) try: results = await asyncio.gather(*tasks, return_exceptions=True) - - # Handle task exceptions individually - 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, - ) + for result in results: + if isinstance(result, Exception): self.running = False + self.logger.error(f'Caught exception: {err}', exc_info=True) except asyncio.CancelledError: - self.logger.info("Main loop cancelled; shutting down...") + exit(1) except Exception as err: - self.logger.exception(f"Unhandled exception in main loop: {err}") self.running = False - finally: - 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.") + self.logger.error(f'Caught exception: {err}') \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b4c7b07 --- /dev/null +++ b/pyproject.toml @@ -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", +] diff --git a/src/amcrest2mqtt/__init__.py b/src/amcrest2mqtt/__init__.py new file mode 100644 index 0000000..990650f --- /dev/null +++ b/src/amcrest2mqtt/__init__.py @@ -0,0 +1,2 @@ +# SPDX-License-Identifier: MIT +# Copyright (c) 2025 Jeff Culverhouse diff --git a/src/amcrest2mqtt/app.py b/src/amcrest2mqtt/app.py new file mode 100644 index 0000000..223ea9f --- /dev/null +++ b/src/amcrest2mqtt/app.py @@ -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.") diff --git a/src/amcrest2mqtt/base.py b/src/amcrest2mqtt/base.py new file mode 100644 index 0000000..d32f537 --- /dev/null +++ b/src/amcrest2mqtt/base.py @@ -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") diff --git a/src/amcrest2mqtt/core.py b/src/amcrest2mqtt/core.py new file mode 100644 index 0000000..6649c72 --- /dev/null +++ b/src/amcrest2mqtt/core.py @@ -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 diff --git a/src/amcrest2mqtt/mixins/amcrest.py b/src/amcrest2mqtt/mixins/amcrest.py new file mode 100644 index 0000000..8ef6715 --- /dev/null +++ b/src/amcrest2mqtt/mixins/amcrest.py @@ -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) diff --git a/src/amcrest2mqtt/mixins/amcrest_api.py b/src/amcrest2mqtt/mixins/amcrest_api.py new file mode 100644 index 0000000..b700b34 --- /dev/null +++ b/src/amcrest2mqtt/mixins/amcrest_api.py @@ -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 diff --git a/src/amcrest2mqtt/mixins/events.py b/src/amcrest2mqtt/mixins/events.py new file mode 100644 index 0000000..0f862eb --- /dev/null +++ b/src/amcrest2mqtt/mixins/events.py @@ -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) diff --git a/src/amcrest2mqtt/mixins/helpers.py b/src/amcrest2mqtt/mixins/helpers.py new file mode 100644 index 0000000..04cef50 --- /dev/null +++ b/src/amcrest2mqtt/mixins/helpers.py @@ -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 diff --git a/src/amcrest2mqtt/mixins/loops.py b/src/amcrest2mqtt/mixins/loops.py new file mode 100644 index 0000000..98eae56 --- /dev/null +++ b/src/amcrest2mqtt/mixins/loops.py @@ -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.") diff --git a/src/amcrest2mqtt/mixins/mqtt.py b/src/amcrest2mqtt/mixins/mqtt.py new file mode 100644 index 0000000..b8bcfcc --- /dev/null +++ b/src/amcrest2mqtt/mixins/mqtt.py @@ -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: ...//set + if len(components) >= 4 and "_" in components[-2]: + vendor, device_id = components[-2].split("_", 1) + attribute = None + + # Case 2: ...///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}") diff --git a/src/amcrest2mqtt/mixins/refresh.py b/src/amcrest2mqtt/mixins/refresh.py new file mode 100644 index 0000000..d637072 --- /dev/null +++ b/src/amcrest2mqtt/mixins/refresh.py @@ -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) diff --git a/src/amcrest2mqtt/mixins/service.py b/src/amcrest2mqtt/mixins/service.py new file mode 100644 index 0000000..52f7237 --- /dev/null +++ b/src/amcrest2mqtt/mixins/service.py @@ -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, + ) diff --git a/src/amcrest2mqtt/mixins/topics.py b/src/amcrest2mqtt/mixins/topics.py new file mode 100644 index 0000000..93dcf6e --- /dev/null +++ b/src/amcrest2mqtt/mixins/topics.py @@ -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 diff --git a/src/amcrest2mqtt/mixins/util.py b/src/amcrest2mqtt/mixins/util.py new file mode 100644 index 0000000..5035660 --- /dev/null +++ b/src/amcrest2mqtt/mixins/util.py @@ -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 diff --git a/src/healthcheck.py b/src/healthcheck.py new file mode 100755 index 0000000..5f47241 --- /dev/null +++ b/src/healthcheck.py @@ -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) diff --git a/src/interface.py b/src/interface.py new file mode 100644 index 0000000..ed6bda8 --- /dev/null +++ b/src/interface.py @@ -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: ... diff --git a/tools/clear_mqtt.sh b/tools/clear_mqtt.sh new file mode 100755 index 0000000..ed6c2fc --- /dev/null +++ b/tools/clear_mqtt.sh @@ -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 diff --git a/util.py b/util.py deleted file mode 100644 index d520781..0000000 --- a/util.py +++ /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 diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..13edb59 --- /dev/null +++ b/uv.lock @@ -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" }, +]