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" },
+]