diff --git a/snapshot/ftp-to-snapshot-cloud/.env.example b/snapshot/ftp-to-snapshot-cloud/.env.example new file mode 100644 index 0000000..74d181b --- /dev/null +++ b/snapshot/ftp-to-snapshot-cloud/.env.example @@ -0,0 +1,52 @@ +# Hikvision -> Plate Recognizer Snapshot bridge (Cloud or SDK) + +# --- Target Snapshot endpoint ------------------------------------------------ +# Set USE_SDK=true to forward to the on-premise Snapshot SDK instead of the +# Cloud API. In SDK mode PLATE_RECOGNIZER_TOKEN is not required. +USE_SDK=false +# Optional override for the full plate-reader URL. When set, this wins over +# the USE_SDK-driven default. +# Cloud default : https://api.platerecognizer.com/v1/plate-reader/ +# SDK default : http://localhost:8080/v1/plate-reader/ +SNAPSHOT_URL= + +# --- Plate Recognizer Snapshot Cloud ---------------------------------------- +# Required when USE_SDK is not set. Get your token from +# https://app.platerecognizer.com/service/snapshot-cloud/ +PLATE_RECOGNIZER_TOKEN= + +# --- FTP server ------------------------------------------------------------- +FTP_HOST=0.0.0.0 +FTP_PORT=2121 +FTP_USER=camera +FTP_PASSWORD=change-me +FTP_ROOT=./uploads +FTP_MAX_CONS=64 +FTP_MAX_CONS_PER_IP=8 +# Passive mode data-channel port range. Open this range in your firewall. +FTP_PASSIVE_PORTS=50000-50100 +# Set to your server's public IP if the FTP server is behind NAT, so the +# PASV reply advertises an address the camera can actually reach. +FTP_MASQUERADE_ADDRESS= + +# --- Upload filtering ------------------------------------------------------- +# Only files ending with this suffix are forwarded to the Snapshot Cloud API. +MATCH_SUFFIX=VEHICLE_DETECTION.jpg +# If false, files that do not match MATCH_SUFFIX are deleted after receipt. +KEEP_NON_MATCHING=true + +# --- Snapshot Cloud parameters --------------------------------------------- +# Comma-separated list of region codes, e.g. mx,us-ca. See +# https://guides.platerecognizer.com/docs/tech-references/country-codes +REGIONS= +# Optional camera identifier forwarded to Snapshot. +CAMERA_ID= +# Set to true to enable make/model/color prediction (requires mmc feature). +MMC=false +# Optional raw JSON string for engine config, e.g. {"mode":"fast","threshold_d":0.2} +CONFIG_JSON= + +# --- Misc ------------------------------------------------------------------- +REQUEST_TIMEOUT=30 +MAX_WORKERS=4 +LOG_LEVEL=INFO diff --git a/snapshot/ftp-to-snapshot-cloud/.gitignore b/snapshot/ftp-to-snapshot-cloud/.gitignore new file mode 100644 index 0000000..505a3b1 --- /dev/null +++ b/snapshot/ftp-to-snapshot-cloud/.gitignore @@ -0,0 +1,10 @@ +# Python-generated files +__pycache__/ +*.py[oc] +build/ +dist/ +wheels/ +*.egg-info + +# Virtual environments +.venv diff --git a/snapshot/ftp-to-snapshot-cloud/README.md b/snapshot/ftp-to-snapshot-cloud/README.md new file mode 100644 index 0000000..fcb9d0b --- /dev/null +++ b/snapshot/ftp-to-snapshot-cloud/README.md @@ -0,0 +1,435 @@ +# FTP to Snapshot Cloud Bridge + +A small, self-contained FTP server that receives snapshot uploads from +[Hikvision](https://www.hikvision.com/) ANPR/LPR cameras and forwards the matching +images to the [Plate Recognizer Snapshot](https://platerecognizer.com/snapshot/) +plate-reader API (`/v1/plate-reader/`). + +The bridge supports **both** Snapshot Cloud and the **on-premise Snapshot SDK**: + +* **Cloud** (default) — uploads to + `https://api.platerecognizer.com/v1/plate-reader/` and authenticates with an + API token (`PLATE_RECOGNIZER_TOKEN`, **required**). +* **SDK** — set `USE_SDK=true` to upload to + `http://localhost:8080/v1/plate-reader/` (or your own URL via + `SNAPSHOT_URL`). No token is required. + +When a Hikvision camera is configured to upload on ANPR detection, it typically +sends three files per event: + +| File | Purpose | +| --------------------------------- | --------------------------------------- | +| `...VEHICLE_DETECTION.jpg` | Full-frame image of the detected vehicle | +| `...VEHICLE_DETECTION_PLATE.jpg` | Cropped close-up of the license plate | +| `anpr.xml` | ANPR event metadata | + +By default the bridge forwards only the full-frame image +(`VEHICLE_DETECTION.jpg`) to the Snapshot endpoint and ignores the cropped plate +image and the XML. Received files are kept on disk under the configured upload +root. + +--- + +## Table of contents + +1. [How it works](#how-it-works) +2. [Requirements](#requirements) +3. [Installation](#installation) +4. [Configuration](#configuration) +5. [Network / firewall notes](#network--firewall-notes) +6. [Running the server](#running-the-server) +7. [Configuring the Hikvision camera](#configuring-the-hikvision-camera) +8. [Operation guide](#operation-guide) +9. [Logs and troubleshooting](#logs-and-troubleshooting) +10. [Project layout](#project-layout) + +--- + +## How it works + +``` ++--------------------+ FTP upload +-------------------------+ HTTP(S) POST +-------------------------+ +| Hikvision camera | -----------> | ftp-to-snapshot-cloud | ------------> | Snapshot Cloud or SDK | +| (ANPR triggers) | data + ports | (pyftpdlib) | /plate-reader/ | (Plate Recognizer) | ++--------------------+ +-------------------------+ +-------------------------+ + | + v + ./uploads/ (local disk) +``` + +* `pyftpdlib` accepts FTP uploads from the camera on the configured port. +* The handler inspects each received filename: + * Files whose name **ends with** the configured `MATCH_SUFFIX` + (default: `VEHICLE_DETECTION.jpg`) are uploaded to the Snapshot Cloud + plate-reader endpoint in a background thread. + * Empty matching files are skipped. + * Non-matching files are either kept on disk (`KEEP_NON_MATCHING=true`, the + default) or deleted after receipt. +* A small `ThreadPoolExecutor` is used to forward files concurrently, so FTP + STOR commands are not blocked on the HTTP upload. + +--- + +## Snapshot Cloud vs Snapshot SDK + +The bridge can forward to either Snapshot backend: + +| Backend | When to use | Default URL | `PLATE_RECOGNIZER_TOKEN` | +| ------------------ | ---------------------------------------------------------- | ------------------------------------------------------ | ------------------------ | +| **Snapshot Cloud** | Managed service. Just need an API token. | `https://api.platerecognizer.com/v1/plate-reader/` | **Required** | +| **Snapshot SDK** | Self-hosted on-premise. No outbound traffic to PR. | `http://localhost:8080/v1/plate-reader/` | _Not required_ | + +Switch with `USE_SDK`: + +* `USE_SDK=false` (default) → Cloud mode. `PLATE_RECOGNIZER_TOKEN` is + **required**; the URL defaults to the Cloud endpoint. +* `USE_SDK=true` → SDK mode. `PLATE_RECOGNIZER_TOKEN` is **not required**; + the URL defaults to the local SDK endpoint. + +Override the URL with `SNAPSHOT_URL`. This is useful when the SDK runs on a +different host/port, behind a reverse proxy, or when you want to forward to +the [Plate Recognizer webhook receiver](https://guides.platerecognizer.com/docs/snapshot/results/). + +Resolution order at startup: + +1. If `SNAPSHOT_URL` is set → use it. +2. Else if `USE_SDK=true` → use `http://localhost:8080/v1/plate-reader/`. +3. Else → use `https://api.platerecognizer.com/v1/plate-reader/` (Cloud). + +Validation: + +* `USE_SDK=false` and no token → exits with + `PLATE_RECOGNIZER_TOKEN environment variable is required when USE_SDK is not set`. +* `USE_SDK=true` → starts fine regardless of token; the token is simply not + sent as an `Authorization` header. + +--- + +## Requirements + +* **Python 3.13 or newer** (declared in `pyproject.toml`). +* Either **[uv](https://docs.astral.sh/uv/)** (recommended, a `uv.lock` is + shipped) or **pip** + a virtual environment. +* Network access to your target Snapshot endpoint: + * **Cloud** — outbound HTTPS to `api.platerecognizer.com`. + * **SDK** — outbound HTTP to the host where the SDK is running (default + `localhost:8080`; override with `SNAPSHOT_URL`). +* Inbound FTP access from the camera. See + [Network / firewall notes](#network--firewall-notes) — **passive ports must be + reachable**, not just the control port. +* For Cloud mode only: a Plate Recognizer API token with the Snapshot Cloud + feature enabled + (). Not required + for SDK mode. + +Python dependencies (installed automatically): + +* `pyftpdlib` — FTP server. +* `python-dotenv` — loads the `.env` file. +* `requests` — HTTPS client for the Snapshot Cloud API. + +--- + +## Installation + +### Option A — `uv` (recommended) + +```bash +cd snapshot/ftp-to-snapshot-cloud +uv sync # creates .venv/ and installs locked deps +cp .env.example .env # then edit .env (see Configuration) +``` + +### Option B — `pip` + venv + +```bash +cd snapshot/ftp-to-snapshot-cloud +python3.13 -m venv .venv +source .venv/bin/activate +pip install pyftpdlib "python-dotenv>=1.2.2" requests +cp .env.example .env # then edit .env (see Configuration) +``` + +### Verify the install + +```bash +python main.py --help # main.py has no CLI flags; you should see no error + # and a "Starting FTP server on ..." log line. +``` + +> The project uses `python-dotenv` to load environment variables from a `.env` +> file in the working directory automatically. You can skip `cp .env.example +> .env` and export the variables in your shell instead, but a `.env` file is the +> easiest path. + +--- + +## Configuration + +All configuration is done via environment variables (loaded from `.env` if +present). The table below lists every variable, its default, and what it does. + +### Snapshot endpoint + +| Variable | Required | Default | Description | +| ----------------------- | --------------- | --------------------- | -------------------------------------------------------------------------------------------------------- | +| `USE_SDK` | No | `false` | When `true`, the bridge forwards to the on-premise Snapshot SDK and does **not** require an API token. | +| `SNAPSHOT_URL` | No | Cloud or SDK default | Full `plate-reader` URL. Overrides the `USE_SDK`-driven default. Use to point at a custom SDK host/port or the Plate Recognizer webhook receiver. | +| `PLATE_RECOGNIZER_TOKEN` | When `USE_SDK=false` | — | API token from . Sent as `Authorization: Token …`. Ignored in SDK mode. | +| `REGIONS` | No | _(empty)_ | Comma-separated region codes forwarded as the `regions` parameter, e.g. `mx,us-ca`. | +| `CAMERA_ID` | No | _(empty)_ | Optional camera identifier forwarded as `camera_id`. | +| `MMC` | No | `false` | When `true`, forwards `mmc=true` to enable make/model/color (requires the feature on your account). | +| `CONFIG_JSON` | No | _(empty)_ | Raw JSON string for engine config, e.g. `{"mode":"fast","threshold_d":0.2}`. | + +Defaults: + +* `USE_SDK=false` → `SNAPSHOT_URL=https://api.platerecognizer.com/v1/plate-reader/` +* `USE_SDK=true` → `SNAPSHOT_URL=http://localhost:8080/v1/plate-reader/` + +### FTP server + +| Variable | Default | Description | +| -------------------- | ----------- | -------------------------------------------------------------------------------------------------------------------- | +| `FTP_HOST` | `""` | Interface to bind. Leave empty (or set `0.0.0.0`) to listen on all interfaces. | +| `FTP_PORT` | `2121` | Control-channel port. Open this in your firewall. | +| `FTP_USER` | `camera` | FTP username. | +| `FTP_PASSWORD` | `camera` | FTP password. **Change this** before pointing a camera at the server. | +| `FTP_ROOT` | `./uploads` | Local directory where received files are stored. Created on startup. | +| `FTP_MAX_CONS` | `64` | Maximum concurrent FTP connections to the server. | +| `FTP_MAX_CONS_PER_IP`| `8` | Maximum concurrent connections from a single IP. | +| `FTP_PASSIVE_PORTS` | `50000-50100` | **Passive-mode data-channel port range** — see [Network / firewall notes](#network--firewall-notes). | +| `FTP_MASQUERADE_ADDRESS` | _(empty)_ | Public IP the server advertises in the `PASV` reply. Set this if the server is behind NAT. | + +### Upload filtering + +| Variable | Default | Description | +| ------------------- | ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| `MATCH_SUFFIX` | `VEHICLE_DETECTION.jpg` | Only files whose **name ends with** this suffix are forwarded to Snapshot Cloud. | +| `KEEP_NON_MATCHING` | `true` | If `false`, files that don't match `MATCH_SUFFIX` are deleted after receipt (use `false` for storage-constrained deployments). | + +### Misc + +| Variable | Default | Description | +| ---------------- | ------- | --------------------------------------------------------------------------- | +| `LOG_LEVEL` | `INFO` | Standard Python log level (`DEBUG`, `INFO`, `WARNING`, …). | +| `REQUEST_TIMEOUT`| `30` | Seconds to wait for the Snapshot Cloud HTTP response. | +| `MAX_WORKERS` | `4` | Size of the thread pool that forwards matching files to Snapshot Cloud. | + +Boolean values accept any of `1`, `true`, `yes`, `on` (case-insensitive). + +--- + +## Network / firewall notes + +> **IMPORTANT — passive-mode data ports.** +> +> FTP in **passive mode** (which every modern Hikvision camera uses, including +> behind NAT) does **not** use the control port (`FTP_PORT`) for the actual file +> transfer. The client connects to the server's control port, then connects to a +> second, ephemeral **data port** in the `FTP_PASSIVE_PORTS` range to send the +> file. If that range is not reachable from the camera, uploads will hang and +> eventually time out — even though the control port is open. +> +> Make sure your firewall / cloud security group allows **inbound TCP** from the +> camera to **every port in `FTP_PASSIVE_PORTS`** (default `50000–50100`, +> 101 ports). It is not enough to open only `FTP_PORT`. + +Concrete checklist: + +1. **Inbound TCP `FTP_PORT`** (default `2121`) from the camera / camera subnet. +2. **Inbound TCP `FTP_PASSIVE_PORTS`** (default `50000–50100`) from the camera / + camera subnet — **required for passive-mode data transfer**. +3. **Outbound TCP 443** to `api.platerecognizer.com`. +4. If the server is behind NAT / has a private IP: + * Set `FTP_MASQUERADE_ADDRESS` to the **public IP** the camera reaches. + Without this, the server tells the camera to connect back to its private + address in the `PASV` response, which the camera cannot route to. +5. If you tighten the passive range, remember that **concurrent uploads from the + same camera can each consume one data port**. The default range of 101 ports + is comfortable for up to ~8 concurrent connections per camera with + `FTP_MAX_CONS_PER_IP=8`; lower the upper bound only if you understand the + trade-off. + +--- + +## Running the server + +### Foreground (development / quick test) + +```bash +python main.py +``` + +You should see something like: + +``` +… INFO ftp-to-snapshot Starting FTP server on 0.0.0.0:2121 (root=…/uploads, match_suffix=VEHICLE_DETECTION.jpg, regions=None, mmc=False, passive_ports=50000-50100, masquerade=None) +``` + +Stop with `Ctrl+C`; the handler thread pool shuts down cleanly. + +### As a systemd service (Linux) + +Create `/etc/systemd/system/ftp-to-snapshot-cloud.service`: + +```ini +[Unit] +Description=Hikvision -> Plate Recognizer Snapshot Cloud bridge +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=ftp-snapshot +WorkingDirectory=/opt/ftp-to-snapshot-cloud +ExecStart=/opt/ftp-to-snapshot-cloud/.venv/bin/python main.py +Restart=on-failure +RestartSec=5 +EnvironmentFile=/opt/ftp-to-snapshot-cloud/.env + +[Install] +WantedBy=multi-user.target +``` + +Then: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now ftp-to-snapshot-cloud +sudo journalctl -u ftp-to-snapshot-cloud -f +``` + +Make sure your firewall (and any cloud security group) opens +`FTP_PORT` **and** `FTP_PASSIVE_PORTS`. + +### In Docker + +A minimal `Dockerfile` (Python 3.13, uv) would look like: + +```dockerfile +FROM python:3.13-slim +WORKDIR /app +COPY pyproject.toml uv.lock ./ +RUN pip install --no-cache-dir uv \ + && uv sync --frozen --no-dev +COPY main.py ./ +ENV PYTHONUNBUFFERED=1 +EXPOSE 2121 50000-50100 +CMD ["uv", "run", "python", "main.py"] +``` + +```bash +docker build -t ftp-to-snapshot-cloud . +docker run --rm -d \ + --name ftp-snapshot \ + -p 2121:2121 \ + -p 50000-50100:50000-50100 \ + --env-file .env \ + -v "$PWD/uploads:/app/uploads" \ + ftp-to-snapshot-cloud +``` + +> Remember to publish the **whole passive-port range** with `-p`, not just the +> control port — see [Network / firewall notes](#network--firewall-notes). + +--- + +## Configuring the Hikvision camera + +The exact menu paths vary by firmware, but the general idea is: + +1. **Network → FTP**: configure an FTP server pointing at this bridge. + * Server address: IP of the bridge (or its public IP if the camera is + remote). + * Port: `FTP_PORT` (default `2121`). + * Username / password: `FTP_USER` / `FTP_PASSWORD`. + * **Passive mode** should be enabled (this is the camera's default for most + Hikvision firmwares). It must reach `FTP_PASSIVE_PORTS`. +2. **Event → ANPR / LPR**: enable ANPR and configure **upload on event**. +3. The camera will then upload three files per detection: + * `_VEHICLE_DETECTION.jpg` + * `_VEHICLE_DETECTION_PLATE.jpg` + * `anpr.xml` + +The bridge will forward only the `VEHICLE_DETECTION.jpg` (configurable via +`MATCH_SUFFIX`). + +--- + +## Operation guide + +### What gets stored locally + +Everything the camera uploads is written under `FTP_ROOT` (default +`./uploads`), preserving the camera's filenames. Override with `FTP_ROOT` if +you prefer a different path or want to mount a volume. + +### What gets forwarded + +Files whose name ends with `MATCH_SUFFIX` (default `VEHICLE_DETECTION.jpg`): + +* Are **only** uploaded if their size is > 0 bytes. +* Are uploaded in a background thread via `POST /v1/plate-reader/` using the + `upload` multipart field, plus any of `regions`, `camera_id`, `mmc`, `config` + you have set. +* Have their HTTP response logged: `Snapshot OK -> ` on + success, `Snapshot API error for : ` on failure. + +Files that do not match are either kept on disk (`KEEP_NON_MATCHING=true`, +default) or deleted after receipt (`KEEP_NON_MATCHING=false`). + +### Tuning throughput + +* `MAX_WORKERS` controls how many concurrent Snapshot Cloud uploads are in + flight. Snapshot Cloud has per-account rate limits; if you see + `429`-class errors, lower this. +* `REQUEST_TIMEOUT` caps how long the HTTP call is allowed to take before it + is abandoned. +* `FTP_MAX_CONS` / `FTP_MAX_CONS_PER_IP` cap concurrent FTP clients. Match + these to the number of cameras and their upload bursts. + +### Rotation / cleanup + +The bridge does **not** delete matched files from `FTP_ROOT` — it just uploads +them. Add a cron job or a small logrotate-style script to clean up old files: + +```bash +# Remove received files older than 7 days +find /opt/ftp-to-snapshot-cloud/uploads -type f -mtime +7 -delete +``` + +### Graceful shutdown + +`Ctrl+C` (or `systemctl stop`) triggers `server.close_all()` plus a clean +`ThreadPoolExecutor.shutdown(wait=True)`, so in-flight HTTP uploads complete +before the process exits. + +--- + +## Logs and troubleshooting + +| Symptom | Likely cause | +| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Camera uploads hang and then time out | `FTP_PASSIVE_PORTS` not reachable from the camera. Open the range in your firewall / cloud SG — see [Network / firewall notes](#network--firewall-notes). | +| Camera gets "Connection refused" on the data port | `FTP_MASQUERADE_ADDRESS` is wrong / not set; the camera is being told to connect to a private IP it can't route to. | +| `RuntimeError: PLATE_RECOGNIZER_TOKEN environment variable is required when USE_SDK is not set` | You're in Cloud mode but didn't set a token. Set `PLATE_RECOGNIZER_TOKEN`, or switch to SDK mode with `USE_SDK=true`. | +| `RuntimeError: FTP_PASSIVE_PORTS must be in the form START-END` | The passive-ports env var is malformed. Use e.g. `50000-50100`. | +| Files appear under `FTP_ROOT` but nothing reaches Snapshot Cloud | Filename suffix doesn't match `MATCH_SUFFIX` (e.g. your firmware names them differently), or `KEEP_NON_MATCHING=true` keeps the wrong files but does not forward them. | +| `Snapshot API error 401` | Token is wrong, revoked, or doesn't have the Snapshot Cloud feature enabled. | +| `Snapshot API error 429` | Snapshot Cloud rate limit hit — lower `MAX_WORKERS` or upgrade your plan. | +| `Ignoring empty matching file: …` | The camera wrote a 0-byte file; usually a transient network issue. Check your firewall and disk. | + +Set `LOG_LEVEL=DEBUG` for verbose pyftpdlib logs. + +--- + +## Project layout + +``` +ftp-to-snapshot-cloud/ +├── main.py # FTP server + Snapshot Cloud forwarder +├── pyproject.toml # Project metadata + dependencies +├── uv.lock # Locked dependency versions (uv) +├── .env.example # Sample environment file — copy to .env and edit +└── README.md # This file +``` diff --git a/snapshot/ftp-to-snapshot-cloud/main.py b/snapshot/ftp-to-snapshot-cloud/main.py new file mode 100644 index 0000000..3693b47 --- /dev/null +++ b/snapshot/ftp-to-snapshot-cloud/main.py @@ -0,0 +1,257 @@ +"""FTP server that forwards Hikvision ANPR snapshots to the Plate Recognizer Snapshot Cloud API. + +Hikvision cameras configured to upload on ANPR detection typically send three files per +event: a full-frame image ending in ``VEHICLE_DETECTION.jpg``, a cropped plate image +ending in ``VEHICLE_DETECTION_PLATE.jpg``, and an ``anpr.xml`` metadata file. Only the +matching images (default suffix: ``VEHICLE_DETECTION.jpg``) are forwarded to the Snapshot +Cloud API. All received files are kept on disk by default. + +Configuration is done via environment variables (see ``main`` for the full list). +""" + +from __future__ import annotations + +import json +import logging +import os +from concurrent.futures import ThreadPoolExecutor +from pathlib import Path + +import requests +from dotenv import load_dotenv +from pyftpdlib.authorizers import DummyAuthorizer +from pyftpdlib.handlers import FTPHandler +from pyftpdlib.servers import ThreadedFTPServer + +SNAPSHOT_CLOUD_URL = "https://api.platerecognizer.com/v1/plate-reader/" +SNAPSHOT_SDK_URL = "http://localhost:8080/v1/plate-reader/" + +log = logging.getLogger("ftp-to-snapshot") +session = requests.Session() + + +def _env_bool(name: str, default: bool = False) -> bool: + raw = os.environ.get(name) + if raw is None: + return default + return raw.strip().lower() in {"1", "true", "yes", "on"} + + +def _env_int(name: str, default: int) -> int: + raw = os.environ.get(name) + if raw is None or raw.strip() == "": + return default + return int(raw) + + +def _env_str(name: str, default: str) -> str: + raw = os.environ.get(name) + return default if raw is None else raw + + +def forward_to_snapshot( + file_path: Path, + *, + url: str, + token: str, + regions: list[str] | None, + camera_id: str | None, + mmc: bool, + config: dict | None, + timeout: float, +) -> None: + """Upload ``file_path`` to a Snapshot (Cloud or SDK) plate-reader endpoint.""" + headers: dict[str, str] = {} + if token: + headers["Authorization"] = f"Token {token}" + data: dict = {} + if regions: + data["regions"] = regions + if camera_id: + data["camera_id"] = camera_id + if mmc: + data["mmc"] = "true" + if config: + data["config"] = json.dumps(config) + + try: + with file_path.open("rb") as fp: + files = {"upload": (file_path.name, fp, "image/jpeg")} + response = session.post( + url, headers=headers, data=data, files=files, timeout=timeout + ) + except requests.RequestException as exc: + log.error("Snapshot API request failed for %s: %s", file_path.name, exc) + return + + if response.ok: + try: + payload = response.json() + except ValueError: + payload = response.text + log.info("Snapshot OK %s -> %s", file_path.name, payload) + else: + log.warning( + "Snapshot API error %s for %s: %s", + response.status_code, + file_path.name, + response.text, + ) + + +class SnapshotFTPHandler(FTPHandler): + """FTPHandler that forwards matching uploads to the Snapshot Cloud API.""" + + # Set by main() before the server starts. + snapshot_url: str + api_token: str + match_suffix: str + keep_non_matching: bool + regions: list[str] + camera_id: str | None + mmc: bool + config: dict | None + request_timeout: float + _executor: ThreadPoolExecutor + + def on_file_received(self, file: str) -> None: + path = Path(file) + name = path.name + if name.endswith(self.match_suffix): + try: + if path.stat().st_size == 0: + log.warning("Ignoring empty matching file: %s", name) + return + log.info("Forwarding %s to Snapshot Cloud", name) + except OSError as exc: + log.warning("Could not check file size for %s: %s", name, exc) + return + + try: + self._executor.submit( + forward_to_snapshot, + path, + url=self.snapshot_url, + token=self.api_token, + regions=self.regions or None, + camera_id=self.camera_id or None, + mmc=self.mmc, + config=self.config, + timeout=self.request_timeout, + ) + except RuntimeError as exc: + log.error("Could not schedule Snapshot upload for %s: %s", name, exc) + else: + log.info("Ignoring non-matching upload: %s", name) + if not self.keep_non_matching: + try: + path.unlink() + except OSError as exc: + log.warning("Failed to remove %s: %s", name, exc) + + +def main() -> None: + load_dotenv() + + logging.basicConfig( + level=_env_str("LOG_LEVEL", "INFO"), + format="%(asctime)s %(levelname)s %(name)s %(message)s", + ) + + host = _env_str("FTP_HOST", "") + port = _env_int("FTP_PORT", 2121) + user = _env_str("FTP_USER", "camera") + password = _env_str("FTP_PASSWORD", "camera") + root = Path(_env_str("FTP_ROOT", "./uploads")).resolve() + root.mkdir(parents=True, exist_ok=True) + + passive_range_raw = _env_str("FTP_PASSIVE_PORTS", "50000-50100") + try: + pstart, pend = (int(x) for x in passive_range_raw.split("-", 1)) + except ValueError as exc: + raise RuntimeError( + f"FTP_PASSIVE_PORTS must be in the form START-END, got {passive_range_raw!r}" + ) from exc + if not (0 < pstart < pend <= 65535): + raise RuntimeError(f"FTP_PASSIVE_PORTS out of range: {passive_range_raw!r}") + masquerade_address = _env_str("FTP_MASQUERADE_ADDRESS", "") or None + + token = _env_str("PLATE_RECOGNIZER_TOKEN", "") + use_sdk = _env_bool("USE_SDK", False) + snapshot_url_override = _env_str("SNAPSHOT_URL", "").strip() + if snapshot_url_override: + snapshot_url = snapshot_url_override + elif use_sdk: + snapshot_url = SNAPSHOT_SDK_URL + else: + snapshot_url = SNAPSHOT_CLOUD_URL + + if not use_sdk and not token: + raise RuntimeError( + "PLATE_RECOGNIZER_TOKEN environment variable is required when " + "USE_SDK is not set" + ) + + match_suffix = _env_str("MATCH_SUFFIX", "VEHICLE_DETECTION.jpg") + keep_non_matching = _env_bool("KEEP_NON_MATCHING", True) + regions = [r.strip() for r in _env_str("REGIONS", "").split(",") if r.strip()] + camera_id = _env_str("CAMERA_ID", "") or None + mmc = _env_bool("MMC", False) + config_raw = _env_str("CONFIG_JSON", "") + config = json.loads(config_raw) if config_raw.strip() else None + request_timeout = float(_env_str("REQUEST_TIMEOUT", "30")) + max_workers = _env_int("MAX_WORKERS", 4) + + authorizer = DummyAuthorizer() + authorizer.add_user(user, password, str(root), perm="elradfmwMT") + + SnapshotFTPHandler.authorizer = authorizer + SnapshotFTPHandler.snapshot_url = snapshot_url + SnapshotFTPHandler.api_token = token + SnapshotFTPHandler.match_suffix = match_suffix + SnapshotFTPHandler.keep_non_matching = keep_non_matching + SnapshotFTPHandler.regions = regions + SnapshotFTPHandler.camera_id = camera_id + SnapshotFTPHandler.mmc = mmc + SnapshotFTPHandler.config = config + SnapshotFTPHandler.request_timeout = request_timeout + SnapshotFTPHandler.banner = "Hikvision -> Snapshot Cloud bridge ready." + SnapshotFTPHandler.passive_ports = range(pstart, pend + 1) + if masquerade_address: + SnapshotFTPHandler.masquerade_address = masquerade_address + SnapshotFTPHandler._executor = ThreadPoolExecutor( + max_workers=max_workers, thread_name_prefix="snapshot" + ) + + address = (host, port) + server = ThreadedFTPServer(address, SnapshotFTPHandler) + server.max_cons = _env_int("FTP_MAX_CONS", 64) + server.max_cons_per_ip = _env_int("FTP_MAX_CONS_PER_IP", 8) + + log.info( + "Starting FTP server on %s:%d (root=%s, match_suffix=%s, regions=%s, mmc=%s, " + "passive_ports=%d-%d, masquerade=%s, snapshot_url=%s, use_sdk=%s)", + host or "0.0.0.0", + port, + root, + match_suffix, + regions or None, + mmc, + pstart, + pend, + masquerade_address, + snapshot_url, + use_sdk, + ) + + try: + server.serve_forever() + except KeyboardInterrupt: + log.info("Shutting down") + finally: + server.close_all() + SnapshotFTPHandler._executor.shutdown(wait=True) + + +if __name__ == "__main__": + main() diff --git a/snapshot/ftp-to-snapshot-cloud/pyproject.toml b/snapshot/ftp-to-snapshot-cloud/pyproject.toml new file mode 100644 index 0000000..9fea7b0 --- /dev/null +++ b/snapshot/ftp-to-snapshot-cloud/pyproject.toml @@ -0,0 +1,10 @@ +[project] +name = "ftp-to-snapshot-cloud" +version = "0.1.0" +description = "FTP server that forwards Hikvision ANPR snapshots to the Plate Recognizer Snapshot Cloud API" +requires-python = ">=3.13" +dependencies = [ + "pyftpdlib", + "python-dotenv>=1.2.2", + "requests", +] diff --git a/snapshot/ftp-to-snapshot-cloud/uv.lock b/snapshot/ftp-to-snapshot-cloud/uv.lock new file mode 100644 index 0000000..fa61c57 --- /dev/null +++ b/snapshot/ftp-to-snapshot-cloud/uv.lock @@ -0,0 +1,159 @@ +version = 1 +revision = 3 +requires-python = ">=3.13" + +[[package]] +name = "certifi" +version = "2026.6.17" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c9/c7/424b75da314c1045981bd9777432fad05a9e0c69daa4ed7e308bbaffe405/certifi-2026.6.17.tar.gz", hash = "sha256:024c88eeec92ca068db80f02b8b07c9cef7b9fe261d1d535abfd5abd6f6af432", size = 134594, upload-time = "2026-06-17T10:31:07.894Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ef/2f/c5464532e965badff2f4c4c1a3a83f5697f0d7c407ed0cda44aaa99bb451/certifi-2026.6.17-py3-none-any.whl", hash = "sha256:2227dcbaafe0d2f59279d1762ddddc37783ed4354594f194ffc31d20f41fc3db", size = 133289, upload-time = "2026-06-17T10:31:06.348Z" }, +] + +[[package]] +name = "charset-normalizer" +version = "3.4.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, + { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, + { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, + { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, + { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, + { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, + { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, + { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, + { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, + { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, + { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, + { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, + { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, + { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, + { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, + { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, + { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, + { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, + { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, + { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, + { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, + { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, + { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, + { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, + { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, + { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, + { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, + { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, + { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, + { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, + { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, + { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, + { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, + { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, + { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, + { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, + { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, + { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, + { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, + { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, + { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, + { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, + { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +] + +[[package]] +name = "ftp-to-snapshot-cloud" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "pyftpdlib" }, + { name = "python-dotenv" }, + { name = "requests" }, +] + +[package.metadata] +requires-dist = [ + { name = "pyftpdlib" }, + { name = "python-dotenv", specifier = ">=1.2.2" }, + { name = "requests" }, +] + +[[package]] +name = "idna" +version = "3.18" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, +] + +[[package]] +name = "pyasynchat" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasyncore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ec/d2/b41df9021c12ca314146abcde7bdd3d9d37d44cc01559d7f13df459ee586/pyasynchat-1.0.5.tar.gz", hash = "sha256:36665473ae730dac51e6d7dad70f8295962120c830ab692f0a31efba32687e24", size = 9959, upload-time = "2026-01-05T20:05:27.712Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/e8/e5ad498cb6a834c16af910e259926fd545dd7873a2da451f3a2bb228d7ee/pyasynchat-1.0.5-py3-none-any.whl", hash = "sha256:35b7859515693e479e8d95ebe9f32cbf4d6312ab7599ced39fc24699e51de46f", size = 7869, upload-time = "2026-01-05T20:05:26.613Z" }, +] + +[[package]] +name = "pyasyncore" +version = "1.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/43/035dfe0cb01687c1940fdc008f46a43c41067e226e862df49327469764a0/pyasyncore-1.0.5.tar.gz", hash = "sha256:dd483d5103a6d59b66b86e0ca2334ad43dca732ff23a0ac5d63c88c52510542e", size = 15854, upload-time = "2026-01-05T19:59:31.893Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1f/ab/b10cee56269ae150763f3f83b3e9305a11f42f50b3dcd58eeb8f7988f0bb/pyasyncore-1.0.5-py3-none-any.whl", hash = "sha256:269bbc5252671827387636822841a1fb721ec6e858b23a3e12cf92eb1f97da2a", size = 10237, upload-time = "2026-01-05T19:59:30.824Z" }, +] + +[[package]] +name = "pyftpdlib" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyasynchat" }, + { name = "pyasyncore" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f9/42/8751c5f58ae59b09e070da4fa322ae9693a340d2cc456b5a380b2c1ee47a/pyftpdlib-2.2.0.tar.gz", hash = "sha256:4ba0642078792df63dd3b2e9c8f838f2a3ecf428c7518d5921c0530d53512acf", size = 189150, upload-time = "2026-02-07T23:09:26.519Z" } + +[[package]] +name = "python-dotenv" +version = "1.2.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, +] + +[[package]] +name = "urllib3" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, +]