- Python 76.8%
- HTML 21.3%
- Dockerfile 1.9%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| tranymon-backend | ||
| .DS_Store | ||
| README.md | ||
Tranymon Backend
A small, self-contained lookup service for the Tranymon ChipScanner — an ESP32-based FDX-B pet-microchip reader. It does two jobs:
- Answers the scanner. An unauthenticated
POST /lookupendpoint 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 inAPI.mdexactly. - 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
- The privacy boundary
- Quick start (Docker Compose)
- Quick start (plain docker run)
- Pointing the scanner at it
- Creating the first user
- Configuration
- The lookup contract
- Security notes
- Command-line tool
- Running from source / tests
- Project layout
- How the chip ID is decoded
- Operating notes
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.mdused 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/lookupover 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=Laxadds 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; 900–998 = 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-datavolume (/data/tranymon.db, plus-wal/-shmwhile running). - One writer. The image runs a single uvicorn worker, which is correct for
SQLite. If you outgrow that, point
DATABASE_URLat 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 ifSECRET_KEYis unset. - Health.
GET /healthzreturns{"status":"ok"}; the container also has a DockerHEALTHCHECKwired to it.