API for the tranymon project
  • Python 76.8%
  • HTML 21.3%
  • Dockerfile 1.9%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
2026-08-12 16:31:02 +02:00
tranymon-backend inital commit 2026-08-12 16:31:02 +02:00
.DS_Store inital commit 2026-08-12 16:31:02 +02:00
README.md inital commit 2026-08-12 16:31:02 +02:00

Tranymon Backend

A small, self-contained lookup service for the Tranymon ChipScanner — an ESP32-based FDX-B pet-microchip reader. It does two jobs:

  1. Answers the scanner. An unauthenticated POST /lookup endpoint takes the chip's raw ID (as the firmware sends it) and returns one short text string to show on the OLED. This implements the contract in API.md exactly.
  2. Lets people manage the data. A small, password-protected web UI where authorised users add and edit chip records: pet name, species, breed, birthday, owner, links to social profiles, and a free-text note.

It ships as a Docker container with a file-based SQLite database on a persistent volume — no external database to run.

Built with FastAPI + SQLAlchemy + Jinja2. Server-rendered HTML, no JavaScript build step, no external CDN/font dependencies, so it runs happily on an isolated LAN.


Contents


How it fits together

   ┌───────────────┐   POST /lookup {"uid":"…"}    ┌───────────────────────┐
   │  ChipScanner   │ ────────────────────────────▶ │  Tranymon backend      │
   │  (ESP32 + OLED)│ ◀──────────────────────────── │  GET  /            (UI)│
   └───────────────┘   200 {"info":"Amy, Cat"}      │  POST /lookup  (device)│
                                                     │  …/admin/*     (UI)    │
   ┌───────────────┐   browser, login required      │                        │
   │   Operator     │ ◀──────────────────────────▶  │  SQLite on /data volume│
   │   (web UI)     │                                └───────────────────────┘
   └───────────────┘
  • The device path is plain HTTP, no authentication, tiny JSON in and out — because that is all the firmware can do today (no TLS on-device).
  • The human path is a normal cookie-session login over the web UI, ideally behind HTTPS (see Security notes).

Both read and write the same SQLite database.

The privacy boundary

The unauthenticated /lookup endpoint deliberately returns only the short display string (the pet's name/species, or an explicit display override). The richer fields a logged-in user enters — owner, birthday, social links, and the free-text note — are never sent to the device and are only visible in the authenticated web UI. Anyone who can reach the LAN endpoint can resolve a chip to its display label, and nothing more.


Quick start (Docker Compose)

The easiest way to run it.

# 1. Configure
cp .env.example .env
# Edit .env — at minimum set SECRET_KEY (and the ADMIN_* seed if you want one).
# Generate a strong secret:
python -c "import secrets; print(secrets.token_urlsafe(48))"

# 2. Build and start
docker compose up --build -d

# 3. Check it's alive
curl http://localhost:8000/healthz        # -> {"status":"ok"}

Open the web UI at http://localhost:8000/ and log in (see Creating the first user).

The database lives in the named volume tranymon-data, so it survives docker compose down and image rebuilds. To wipe everything, remove the volume: docker compose down -v.

Quick start (plain docker run)

If you would rather not use Compose:

# Build
docker build -t tranymon-backend .

# Run with a persistent volume and configuration via -e
docker run -d --name tranymon \
  -p 8000:8000 \
  -v tranymon-data:/data \
  -e SECRET_KEY="$(python -c 'import secrets; print(secrets.token_urlsafe(48))')" \
  -e ADMIN_USERNAME=admin \
  -e ADMIN_PASSWORD='choose-a-strong-password' \
  tranymon-backend

To remap the port (e.g. expose it on 8080), change the host side of -p: -p 8080:8000.


Pointing the scanner at it

In the firmware's secrets.h set API_URL (or set the api key in NVS settings) to this server's /lookup URL, reachable from the scanner's WiFi network:

API_URL = "http://192.168.1.50:8000/lookup"

Port note. This server listens on port 8000 by default. The example in API.md used 5000 (the Flask reference stub); use whatever host port you published with -p. The path is /lookup.

The scanner triggers a lookup on a key press after a scan (UP/DOWN on the result screen, OK on a saved reading). On success the returned string is shown, truncated to ~25 characters; any failure or empty result shows No data / offline.


Creating the first user

There is no open registration. You get the first account in one of two ways:

A) Seed it at startup (simplest). Set ADMIN_USERNAME and ADMIN_PASSWORD in the environment. On boot, if no user with that name exists, it is created. Change the password after first login. (This is what .env.example sets up.)

B) Create it with the CLI. Leave the seed blank and run the bundled CLI inside the container:

docker compose exec tranymon python -m app.cli create-user alice
# (prompts for a password; or pass --password '…' non-interactively)

Once you have one account, you can create more users from the web UI under Users.


Configuration

All configuration is via environment variables (see .env.example for a copy-paste template). Defaults are production-reasonable except SECRET_KEY.

Variable Default Purpose
SECRET_KEY (random per start) Signs the session cookie. Set this — otherwise restarts log everyone out.
ADMIN_USERNAME (unset) If set with ADMIN_PASSWORD, seed this admin on first boot.
ADMIN_PASSWORD (unset) Password for the seeded admin.
DATABASE_URL sqlite:////data/tranymon.db SQLAlchemy URL. Points at the volume in Docker.
APP_NAME Tranymon Registry Name shown in the UI header/title.
COOKIE_SECURE false Set true when the UI is served over HTTPS, so the cookie is marked Secure.
SESSION_MAX_AGE 43200 (12h) Login lifetime, seconds.
BCRYPT_ROUNDS 12 bcrypt cost factor for password hashing.
MIN_PASSWORD_LENGTH 10 Minimum length enforced when creating users.
LOOKUP_NOT_FOUND_STATUS 200 200 (empty info) or 404 for unknown chips — the device treats both as "no data".
HOST / PORT 0.0.0.0 / 8000 Bind address inside the container.

The interactive API documentation (OpenAPI/Swagger) for the device endpoint is served at /api-docs.


The lookup contract

This is a summary; API.md is the authoritative spec.

Request — the firmware sends the 7 raw FDX-B ID bytes, base64-encoded:

POST /lookup
Content-Type: application/json

{"uid":"ARQW77FcxA=="}

Response — HTTP 200 with a JSON object. The firmware reads info, falling back to name:

{"info":"Amy, Cat","name":"Amy, Cat"}

An unknown chip returns 200 with empty strings (or 404 if you set LOOKUP_NOT_FOUND_STATUS=404). The endpoint is defensive: malformed base64, a missing uid, or a non-JSON body all return 200 with empty info rather than an error, because the firmware treats any non-200 as "offline".

Worked example (from the spec):

Item Value
15-digit ID 276098510658756
7 ID bytes (hex) 01 14 16 EF B1 5C C4
uid (base64) ARQW77FcxA==

Records are keyed on the 15-digit ID, so the same physical chip always resolves to the same record regardless of the FDX-B status-flag bits.


Security notes

  • The device endpoint is intentionally unauthenticated and plain HTTP. That is the firmware's current capability (no WiFiClientSecure). Keep the scanner and server on a trusted LAN. The endpoint only ever returns the short display label — never owner/birthday/social/note.
  • Protect the admin UI with HTTPS. The login form and session cookie should not travel in clear text over anything but a trusted local network. Put a TLS reverse proxy (Caddy, nginx, Traefik) in front of the container for the UI, and set COOKIE_SECURE=true. You can still expose /lookup over plain HTTP on the LAN for the device while serving the rest over HTTPS.
  • Passwords are stored only as bcrypt hashes (per-password salt). Plaintext is never written or logged.
  • Login is hardened against username enumeration (generic error message, constant-time-ish comparison even for unknown users) and CSRF (every state-changing form carries a token validated against the session; SameSite=Lax adds defence in depth).
  • Set SECRET_KEY. Without it the cookie-signing key is random per process, which is safe but invalidates sessions on every restart.

Command-line tool

A small CLI ships in the image for operational tasks. Run it with docker compose exec tranymon python -m app.cli <command> (or python -m app.cli … from a source checkout):

python -m app.cli create-user <username> [--password PW]   # add a user (prompts if no --password)
python -m app.cli list-users                                # list usernames
python -m app.cli decode ARQW77FcxA==                       # uid  -> 15-digit ID / country / national
python -m app.cli encode 276098510658756                    # 15-digit ID -> uid

decode/encode are handy for testing the device contract by hand.

Running from source / tests

You do not need Docker to develop or run the test suite.

python -m venv .venv && . .venv/bin/activate
pip install -r requirements-dev.txt          # runtime deps + pytest/httpx

# Run the tests
pytest                                        # 38 tests: FDX-B codec + HTTP API

# Run the server locally (creates ./data/tranymon.db)
export SECRET_KEY=dev-secret ADMIN_USERNAME=admin ADMIN_PASSWORD=dev-password-123
uvicorn app.main:app --reload

The suite (tests/test_fdxb.py, tests/test_api.py) pins the byte-level FDX-B decoding against the spec's worked example and exercises login, CSRF, the full chip CRUD lifecycle, the privacy boundary (device response leaks nothing), and user management.

Project layout

app/
  main.py          FastAPI app, middleware, lifespan (DB init + admin seed),
                   exception handlers (auth redirect, CSRF page)
  config.py        Settings loaded from environment variables
  database.py      SQLAlchemy engine/session, init_db()
  models.py        User and Chip ORM models
  security.py      bcrypt password hashing, sessions, CSRF helpers
  crud.py          Data-access functions (users + chips)
  fdxb.py          FDX-B uid <-> 15-digit ID codec (pure, dependency-free)
  iso3166.py       Numeric country-code labels
  routes_device.py POST /lookup — the unauthenticated device endpoint
  routes_web.py    The authenticated HTML routes (/admin/*)
  templating.py    Jinja2 setup, flash messages, render() helper
  cli.py           Operational CLI (users + decode/encode)
  templates/       Server-rendered HTML (inline CSS, no external assets)
tests/             pytest suite
Dockerfile         Production image (non-root, healthcheck, /data volume)
docker-compose.yml One-command run with a persistent volume
requirements.txt   Pinned runtime dependencies

Every module carries a docstring explaining its responsibility and the design choices behind it; this README is the high-level companion to those.

How the chip ID is decoded

An ISO 11784/11785 FDX-B tag sends a 64-bit data block; the firmware extracts the 7 ID bytes and base64-encodes them as uid. The server decodes them (app/fdxb.py) like the spec describes:

  • country = ((b[0] << 8) | b[1]) & 0x3FF — ISO 3166-1 numeric (e.g. 276 = Germany; 900998 = ICAR manufacturer codes; 999 = test transponder).
  • national = big-endian(b[2..6]) & 0x3FFFFFFFFF — the 38-bit animal number.
  • 15-digit ID = "%03d%012d" % (country, national).

The high bits of b[0] that fall outside the 10-bit country field carry FDX-B status flags; masking them off makes the 15-digit identity stable, which is why records are keyed on the 15-digit ID rather than the raw uid bytes.

Operating notes

  • Backups are a file copy: the data is a single SQLite database on the tranymon-data volume (/data/tranymon.db, plus -wal/-shm while running).
  • One writer. The image runs a single uvicorn worker, which is correct for SQLite. If you outgrow that, point DATABASE_URL at PostgreSQL/MySQL and scale workers/replicas behind a proxy — the ORM layer makes that a configuration change, not a rewrite.
  • Logs stream to stdout (docker compose logs -f tranymon). Startup prints a line when it seeds the admin user, and a warning if SECRET_KEY is unset.
  • Health. GET /healthz returns {"status":"ok"}; the container also has a Docker HEALTHCHECK wired to it.