Skip to content

Latest commit

 

History

186 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

HEADROOM

The Outrun-grade vault for your hat collection.

logo

License: AGPL v3 Python 3.12+ React 19 FastAPI Self‑hosted

Headroom is a self-hosted inventory for serious hat collections. Snap a photo and Claude Vision identifies the brand, model, and colors, estimates retail price, and pulls live resale comps. The background is stripped automatically so every hat floats on a synthwave canvas. Built mobile-first, runs on a Raspberry Pi, secured for the open internet, looks like 1986.

Docs: Usage guide — the app end-to-end · Operations guide — deploy, configure, back up, upgrade, troubleshoot · CHANGELOG


Why it exists

Hundreds of hats in identical cases means two daily problems: "where is my light-blue one?" and "what is all of this actually worth?" Headroom answers both — perceptual color search over every photo, a location breadcrumb on every result, and three independent price signals per hat.

What it does

🧠 Identify

  • Claude Vision analysis — brand, specific model, colorway, tiered colors with hex, design notes. One tool-use call per photo, prompt caching enabled. Retail price is looked up, not guessed: a table of melin's real list prices by construction (cross-checked against order history) answers first, and Claude's estimate only fills in where the table has no row.
  • Works without any keys — background removal and dominant-color detection run locally (colors are read only from the hat's cutout mask, so the background can never contaminate them). Add a Google Vision key for logo-based brand detection as a fallback when Claude is unavailable.
  • Automatic background removalrembg (ONNX, runs on a Pi) turns every photo into a transparent PNG.
  • Colorway catalog — harvests every "Model – Colorway" name circulating on the melinrecap resale market (including years of sold-out drops) to power autocomplete and purchase matching.

🔎 Find

  • Search by color — tap a swatch (or pick any color) and hats rank by perceptual closeness (CIEDE2000) over their stored hex values, weighted by how much of the hat wears that color. A hat whose secondary color matches still surfaces, but never above a hat that is that color. Gray hats stay out of a purple search: the hue question is settled before distance is measured, because a distance metric alone will happily call charcoal "nearly purple". "Light blue" works no matter what the analyzer called it.
  • Text search — multi-term AND over name, brand, model, style, condition, size, colors, and room.
  • Find-it cards — every result shows the photo, the name, and where it physically lives: 📍 Case A-012 · Office.
  • QR & NFC tags — print label sheets for cases and hats, or write the same URL to an NFC sticker. Scanning a hat opens a one-tap "wore it today" screen; scanning a case opens its contents.

💰 Value

  • Three price signals per hat — the retail price (table first, Claude's estimate second), eBay live asking prices for comparable listings (Browse API — eBay publishes no sold history there), and a live median resale price from melinrecap's marketplace API (no scraping, no headless browser) matched to this product, condition and size — re-checked nightly without spending a Claude call.
  • Real cost basis — import order line items from your purchase emails; they match to hats and record what you actually paid, and when.
  • Valuation dashboard — totals, retention %, top hats by value, realized value from sales, and a wear-rotation nudge.
  • Insurance-grade inventory report — print-friendly HTML → Save as PDF.

🧢 Live with it

  • Rooms → Cases → Hats, or a hat straight in a room with no case — Caddies and Aviators don't fit a travel case. Per-case capacity (3 hats, or 6 beanies), type-exclusive cases, auto-sequenced display IDs.
  • 3D-printable case rack — a modular, stackable, supports-free rack that gives each Melin travel case its own slide-in bay. OpenSCAD source + STLs in hardware/melin-rack-v3.zip (OpenSCAD + STLs
    • ready-to-slice .3mf projects); print notes in hardware/.
  • Wear tracking — one tap logs a wear; get wear counts, cost-per-wear, and a list of hats that haven't seen the sun.
  • Three import paths — single photo with crop/rotate, bulk import (100 photos through a restart-surviving queue), or straight from the system share sheet (Android PWA share target; iOS Shortcut recipe included in-app).
  • Disposition tracking — sold / gifted / traded / lost / trashed, soft delete with undo; sale prices feed realized value.
  • Append-only activity log — every significant change, auto-pruned.

🔐 Ship it to the internet

  • Accounts — first-run owner setup, argon2id passwords, revocable sessions, login rate limiting.
  • Passkeys — sign in with Face ID / Touch ID (WebAuthn).
  • Everything gated — the API and the photo files require login; raw API keys never leave the server (masked reads only).
  • Read-only share links — show off the collection without handing out a login; revocable, expiring after 30 days unless you say otherwise.
  • Guest browsing — optionally let anyone on the LAN browse read-only (no prices, no notes) without a link at all. Off by default.
  • Download the collection as a zipindex.html plus an images folder. Opens in any browser, works offline, nothing to host, no login. For when the person you're showing it to can't reach the app: a share link only resolves on your own network. Prices are opt-in and off by default.
  • One-command HTTPS — a Caddy overlay with automatic Let's Encrypt certs.
  • Backups — scheduled rolling tarballs + one-click download; documented restore.

How a hat gets added

Taking a photo is the only step you do. Everything below happens on its own, and every enrichment step is allowed to fail without costing you the hat.

The upload returns immediately — it does not wait for any of this. The photo goes on a queue and a single in-process worker drains it, because rembg plus a full-resolution decode is the largest allocation this app makes and a Pi will not survive several at once. The hat shows up in the grid straight away wearing analysis_status = pending.

flowchart TD
    U["POST /api/hats/:id/photo<br/>streamed · size-capped"] --> N["Pillow · HEIC→JPEG · resize<br/>kept as original_path"]
    N --> Q{"queue accepted it?"}
    Q -->|yes| RET["return now · pending"]
    Q -->|"no worker"| INL["run inline<br/>work is never dropped"]
    RET --> W[["analysis worker · one at a time"]]
    W --> BG
    INL --> BG

    BG["rembg ONNX · transparent PNG<br/>now the canonical photo"] --> TH["320px WebP thumb<br/>what grids render"]
    TH --> K{"Anthropic key set?"}

    K -->|no| FB
    K -->|yes| CL["Claude Vision · 1 tool-use call<br/>brand · model · colorway<br/>colors · construction · retail"]
    CL -->|"API error"| FB["Fallback · mask colors<br/>+ Google Vision logo brand"]
    CL -->|ok| AP["apply to the hat record"]

    subgraph LOOKUPS ["price lookups · best-effort · Claude-gated"]
        EB["eBay Browse API<br/>comparable listing stats"]
        ME["melinrecap · Sharetribe<br/>median matched to this hat's<br/>model + condition + size"]
    end

    AP --> EB --> ME --> DONE[("commit · ok")]
    FB --> SKIP[("commit · fallback<br/>skipped or error")]
Loading

Things worth knowing from that picture:

  • The original photo is never thrown away. rembg output becomes the canonical image, but the flattened JPEG is retained as original_path — it is the only thing a re-cut can work from.
  • On upload, the price lookups are gated on Claude succeeding — both fallback branches return early, because without a model name there is nothing to look up comparables for, and guessing from the style alone would price every hat in a shape identically. After that, re-pricing is independent of analysis: a nightly sweep (and the Re-price buttons in Settings → Data) re-checks every hat that already has a model name against live asks, with no Claude call at all.
  • Failure is recorded, not hidden. analysis_status ends up ok, fallback, skipped or error, with the reason in analysis_error, and Settings surfaces the recent ones.
  • eBay and Melin failures are logged and ignored. A resale API being down is not a reason to lose a hat you just photographed.

Run it

Pick how you want to reach it — every Docker mode is the base compose file plus one front-door overlay (don't stack two). Below, C is shorthand for docker compose -f docker-compose.yml:

How you want to run it Command Reach it at Face ID / passkeys
Default (one host) docker compose up -d --build http://localhost:8000 · http://<ip>:8000 ✅ on localhost only
LAN name C -f docker-compose.mdns.yml up -d --build http://headroom.local:8000 ❌ (plain HTTP)
LAN, port 80 C -f docker-compose.http80.yml up -d --build http://headroom.local ❌ (plain HTTP)
LAN, HTTPS C -f docker-compose.https-lan.yml up -d --build https://headroom.local ✅ (trust Caddy's cert once)
Internet HEADROOM_DOMAIN=… C -f docker-compose.https.yml up -d --build https://your-domain ✅ (Let's Encrypt)
Bare metal (no Docker) ./scripts/setup.sh --no-docker → uvicorn http://localhost:8000 ✅ on localhost
Dev (hot reload) uvicorn --reload + npm run dev http://localhost:5173

Whichever front door you pick, upgrade with the same -f flags. Compose applies only the files you name, so a bare docker compose up -d --build on an overlay host is a switch back to the base config, not an upgrade — the sidecar stops and the app returns to :8000.

Run ./scripts/stamp-build.sh --install-hooks once and the footer will show the running commit (v2.18.0 · build a1b2c3d) after every pull. Details in OPERATIONS.md §5.

Passkeys/Face ID need a secure context — HTTPS or localhost — so the plain-HTTP LAN modes are password-login only. The LAN overlays use host networking (Linux/Pi only); details for each mode follow.

Docker (recommended — works on Mac, Linux, Pi)

# 1. Clone
git clone https://github.com/59psi/headroom.git && cd headroom

# 2. Install + start a Docker engine (skips itself if one is already running)
./scripts/setup.sh --docker-only

# 3. Build + run, attached so you can watch the first boot
docker compose up --build

Step 2 installs a complete, Docker-Desktop-free engine: colima + docker CLI + compose/buildx via Homebrew on macOS, native Docker Engine via apt/dnf on Linux. If docker info already works on your machine it changes nothing. Linux: the script adds you to the docker group — log out/in (or newgrp docker) before step 3.

When uvicorn reports it's listening, open http://localhost:8000 — the first visit creates your owner account, then head to Settings to paste your Anthropic API key. Once it works, Ctrl-C and relaunch in the background:

docker compose up --build -d    # detached; follow logs with: docker compose logs -f

The first build takes a few minutes (it pre-downloads the rembg model so your Pi doesn't have to); later builds are cached. With -d your terminal returns immediately while the container is still building and booting — give it a minute before declaring it broken.

Errors like unknown shorthand flag: 'd' in -d, docker: 'compose' is not a docker command, or Cannot connect to the Docker daemon all mean your Docker install is incomplete. Step 2 fixes all of them.

Putting it on the internet? Use the HTTPS overlay — Caddy sidecar with automatic Let's Encrypt certs, passkey identity configured from your domain:

HEADROOM_DOMAIN=hats.example.com \
  docker compose -f docker-compose.yml -f docker-compose.https.yml up -d --build

See the Operations guide for the full security posture.

Find it on your LAN — headroom.local

The app advertises itself over mDNS, so devices on your network can reach it at http://headroom.local:8000 — no IP address to remember. macOS, iOS, and Windows 10+ resolve .local names natively; on Linux install avahi-daemon + libnss-mdns. Rename it with HEADROOM_MDNS_HOSTNAME (disable with HEADROOM_MDNS_ENABLED=false).

Docker needs one extra flag: multicast can't cross Docker's bridge network, so stack the mDNS overlay (host networking — Linux/Pi only):

docker compose -f docker-compose.yml -f docker-compose.mdns.yml up -d --build

Host networking only claims the ports the app actually binds (8000 here, plus UDP 5353 for the mDNS responder) — the rest of the Pi is unaffected, and other services can keep running on their own ports.

If headroom.local won't resolve but the raw IP works: inside a Docker host-net container the responder binds the detected LAN interface only, so it can't leak onto docker0/veth and lose multicast (that leak is the usual cause). On a multi-homed host where auto-detection picks the wrong NIC, pin it with HEADROOM_MDNS_INTERFACE=<lan-ip>; set HEADROOM_MDNS_INTERFACE=all to fall back to advertising on every interface. GET /api/settings/mdns reports the advertised IP and any registration error.

Prefer a clean port 80, no HTTPS? Stack the plain-HTTP overlay instead — a Caddy sidecar serves http://headroom.local (and http://<host-ip>) on port 80 with no certificate to trust:

docker compose -f docker-compose.yml -f docker-compose.http80.yml up -d --build

Trade-off: http:// isn't a secure context, so passkeys / Face ID aren't offered — password login only. Use one front-door overlay at a time (mdns:8000, http80:80, or https-lan:443).

HTTPS on the LAN — Face ID / passkeys at https://headroom.local

Browsers only offer passkeys in a secure context, and Let's Encrypt can't issue certificates for .local names — so the LAN HTTPS overlay fronts the app with Caddy using its built-in local CA. You trust that CA once per device; after that the LAN name gets a real padlock and Face ID sign-in works. Password login never needed any of this.

Prerequisites: a Linux/Pi Docker host (the overlay uses host networking for mDNS), with ports 80, 443, and 8000 free on that host.

1. Start it — use this overlay instead of docker-compose.mdns.yml (mDNS + host networking are built in):

docker compose -f docker-compose.yml -f docker-compose.https-lan.yml up -d --build

On first boot Caddy mints a root CA and a headroom.local certificate, the app advertises https://headroom.local over mDNS, and the passkey identity (HEADROOM_RP_ID / HEADROOM_ORIGIN) is set to the LAN name automatically.

2. Get Caddy's root certificate onto the device. Easiest way — on the device itself, open:

http://headroom.local:8000/api/public/ca-certificate

That serves the root certificate with the right content type, so iOS offers to install it directly. It's also linked from Settings → Device → Trust this device, which only appears when a local CA exists.

Upgrading from an overlay older than 2.45? Recreate the stack (up -d again) before using that URL. It is served from a certificate the caddy-ca-export service copies out of Caddy's PKI, and without that service the endpoint returns 404 — Caddy's PKI is 0700 root, and the app container runs as a non-root user, so it cannot read the file in place.

Or copy it off the host manually:

# The `caddy` service only exists under the https-lan overlay, so it has to be
# named here too — without the -f flags this fails with "no such service".
docker compose -f docker-compose.yml -f docker-compose.https-lan.yml \
  cp caddy:/data/caddy/pki/authorities/local/root.crt headroom-ca.crt

Install root.crt, not intermediate.crt. They sit side by side in that directory and only the root is a trust anchor. An intermediate is presented by the server during the handshake and means nothing until its issuer is already trusted — so installing it appears to succeed and changes nothing, which is exactly what "the certificate won't install" looks like. The two .key files in there are private and never leave the Pi.

3. Trust it on each device (passkeys require a trusted certificate, not just any TLS):

  • iPhone / iPad: AirDrop or email headroom-ca.crt to the device, tap it, then Settings → Profile Downloaded → Install. Finally — easy to miss — enable it under Settings → General → About → Certificate Trust Settings.
  • Mac: one command does the whole thing, import and trust together — sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain headroom-ca.crt. Double-clicking works too, but it lands in whichever keychain Keychain Access last had selected, and it must be login or System — the iCloud keychain cannot hold certificates and refuses the import with Error: -26276, which reads like a bad file rather than a wrong destination. Then open it and set When using this certificate to Always Trust.
  • Android: Settings → Security → More → Encryption & credentials → Install a certificate → CA certificate.
  • Windows: right-click → Install Certificate → Local Machine → place in Trusted Root Certification Authorities.

4. Verify: open https://headroom.local — you should see a padlock and no warning. The Settings page's LAN Discovery card shows the exact URL being advertised. Then add a passkey under Settings → Account and sign in with Face ID.

Reaching it from off the LAN (Teleport, Tailscale, WireGuard, another subnet): headroom.local will not resolve out there. .local is mDNS, which is link-local multicast — no server holds that record, so there is no DNS to point at it. Give Caddy the address you will connect on:

HEADROOM_SITE_ADDRESSES="headroom.local, 10.0.111.4" \
  docker compose -f docker-compose.yml -f docker-compose.https-lan.yml up -d

Caddy then serves that address and puts it in the certificate (an IP SAN, signed by the same root), so devices that already trust the CA need no reinstall. Without it, connecting by IP fails the handshake before the certificate is even considered — Caddy rejects an SNI matching no site here.

Two caveats. Passkeys still only work on the origin in HEADROOM_ORIGIN — WebAuthn credentials are bound to an origin, so one registered at https://headroom.local isn't offered at https://10.0.111.4. That's WebAuthn working correctly; password login works on both. And if you just want in from a VPN with no configuration at all, http://<ip>:8000 already works — that's uvicorn directly, bypassing Caddy. Plain HTTP, so no padlock and no passkeys.

If something's off:

  • Works on the LAN, dead over the VPNheadroom.local is mDNS and mDNS cannot cross a VPN, a tunnel or a routed subnet, so the name doesn't resolve and nothing is wrong with the server. The IP isn't a fallback either: Caddy rejects a TLS connection whose SNI matches no site, and the certificate has no IP SAN. Worse, http://<ip>/ gets a 308 redirect to https://<ip>/ — the one address that can't work — which is what makes this look like an outage. Add the address to HEADROOM_SITE_ADDRESSES (above), or use http://<ip>:8000 to bypass Caddy entirely.
  • Name doesn't resolve — Linux clients need avahi-daemon + libnss-mdns; everything else resolves .local natively. Check the LAN Discovery card (or docker compose logs | grep -i mdns) to confirm the app is advertising.
  • Still a certificate warning — on iOS the profile install alone isn't enough; the Certificate Trust Settings toggle in step 3 must be on.
  • The certificate "installs" but nothing changes — you almost certainly installed intermediate.crt. Only root.crt is a trust anchor; see the note in step 2. A browser's "export certificate" button hands you the leaf or the intermediate, never the root — a root is self-signed and never sent during a handshake, so it can only come off the Pi.
  • macOS says Error: -26276 — the import is aimed at the iCloud keychain, which cannot store certificates. Pick login or System in the Keychain Access sidebar, or use the security add-trusted-cert command in step 3, which never has to guess.
  • Trusted the root and it is still "Not Secure" — check Settings → Device → Trust this device, which now reports the certificate actually being served. Leaf certificates are issued for 820 days (./Caddyfile) and Caddy renews them well before that; if renewal stops (a missing stored key will do it) Caddy keeps serving the old one and re-queues the renewal forever, and trusting the issuer cannot help. docker restart headroom-caddy makes it reissue against the same root, so devices that already trust the CA stay trusting.
  • /api/public/ca-certificate returns 404 while Caddy is plainly serving HTTPS — the caddy-ca-export service isn't running. Recreate the stack; see the note in step 2.
  • No Face ID prompt — passkeys are bound to the domain they were created on. One registered at localhost or a public domain won't be offered at headroom.local; add a new passkey while on the LAN name.
  • Port conflict on 80/443 — something else on the host owns them; stop it or fall back to the plain mDNS overlay (password login, no padlock).

Renaming the host (HEADROOM_MDNS_HOSTNAME=hats) carries through everything — cert, mDNS name, and passkey identity become hats.local on the next up --build.

Local (no Docker)

Prereqs: git + curl. The setup script installs everything else it needs — uv, Python, Node, backend and frontend deps — via Homebrew on macOS and apt/dnf on Linux. It pulls the same versions the Docker image runs (Python 3.14 via the .python-version pin, Node 26 on a fresh install), so bare metal doesn't drift from production. An existing Node 22.22+ is accepted as-is — that's react-router 8's engines floor, the highest any dependency declares, and the Node 20 line went EOL 2026-04-30 — and the package itself still supports Python 3.12+ if you bring your own interpreter. npm is upgraded to 12 to match the version the image builds the SPA with.

git clone https://github.com/59psi/headroom.git && cd headroom
./scripts/setup.sh --no-docker   # drop the flag to also install a Docker engine
uv run uvicorn headroom.app:app --host 0.0.0.0 --port 8000

That's it — setup builds the SPA, and the backend serves it at http://localhost:8000.

Dev mode (hot reload)

# terminal 1 — backend (port 8000)
uv run uvicorn headroom.app:app --reload

# terminal 2 — frontend dev server (port 5173, proxies /api + /uploads to :8000)
cd frontend && npm run dev

Updating

git pull
docker compose up --build -d     # Docker
# — or —
./scripts/setup.sh --no-docker   # bare metal: re-sync deps + rebuild SPA, then restart uvicorn

Schema changes are handled automatically — on every boot, init_db() applies inline SQLite migrations (ALTER TABLE for new columns, CREATE TABLE for new tables), so an old database upgrades itself the first time the new version starts. There's no separate migrate step — but there's no downgrade path either, so take a backup before major upgrades (Settings → Upkeep → Backups → ↓ Full Backup, or grab the latest scheduled tarball from /data/backups/). Your data always survives a rebuild: the database and photos live in the headroom-data volume, not the image.

The footer shows the running version — compare against the CHANGELOG. Details in OPERATIONS.md §5.

Start fresh — reset the database

To wipe everything and set up as a brand-new install (fresh database, no hats/cases/photos, first-run "create owner" screen returns), remove the headroom-data volume — it holds the database, photos, and the rolling backups, so download a backup first if you want to keep anything. Use the same -f flags you deploy with:

docker compose -f docker-compose.yml -f docker-compose.http80.yml down -v
docker compose -f docker-compose.yml -f docker-compose.http80.yml up -d --build

On the https-lan overlay, -v also clears Caddy's local CA (re-trust the cert once per device). To keep the cert, or to reset only the database while keeping photos, see the variants in OPERATIONS.md §4.


Configuring the AI features

The AI features need an Anthropic API key. The DB-stored key always wins over the environment variable, so you can ship a docker-compose default and let users override it from the UI.

Source When Set via
Database (preferred) Set from the Settings page; persists across restarts UI: Settings → Claude API Key
Environment (fallback) Useful as a default for fresh installs HEADROOM_ANTHROPIC_API_KEY

No Claude key? The fallback

Uploads never depend on Claude. Without a key (or when a Claude call fails), a basic fallback runs instead and the hat gets analysis_status = "fallback":

  • Colors — always available, no key needed. Dominant colors are extracted locally from the background-removed cutout's alpha mask, so only actual hat pixels count.
  • Brand — optional, via Google Cloud Vision logo detection. Create an API key at console.cloud.google.com (enable the Cloud Vision API, then Credentials → Create API key) and paste it in Settings → Google Vision Key. Free tier is 1,000 requests/month — plenty.

Model name, price estimate, and design notes stay empty in fallback mode — drop a Claude key in later and hit Reanalyze on any hat to upgrade.

Resale prices (Melin)

Melin hats get a live median asking price from melinrecap.com's public marketplace API (it's a Treet marketplace on Sharetribe Flex — we use the same anonymous API its own frontend uses), matched to your exact product — model and colorway — in your hat's condition and size, widening to the line only when nothing that specific is listed; plus a deep link to browse the comps. Degrades to link-only if the API is unreachable. Prices are re-checked nightly (HEADROOM_REPRICING_*) and on demand from Settings → Data → Re-pricing; neither spends a Claude call.


Environment variables

Variable Default Description
HEADROOM_DATABASE_URL sqlite+aiosqlite:///./headroom.db DB connection string
HEADROOM_UPLOAD_DIR uploads Where photos live on disk
HEADROOM_CORS_ORIGINS ["http://localhost:5173"] Allowed CORS origins (JSON list)
HEADROOM_ANTHROPIC_API_KEY (unset) Default API key (overridden by DB value)
HEADROOM_ANTHROPIC_MODEL claude-sonnet-5 Claude model for vision analysis
HEADROOM_GOOGLE_VISION_API_KEY (unset) Fallback brand (logo) detection. DB value wins
HEADROOM_MELIN_CLIENT_ID (baked in) Public Sharetribe client id for live Melin resale stats
HEADROOM_EBAY_APP_ID / HEADROOM_EBAY_CERT_ID (unset) eBay Browse API comps (Production keyset)
HEADROOM_SETUP_TOKEN (unset) When set, first-run setup also requires this token (the setup form's Setup token field). Closes the window where whoever reaches the host first can claim the owner account — worth setting on an internet-facing deployment, unnecessary on a LAN. See security posture
HEADROOM_RP_ID localhost Passkey relying-party id — must equal the serving domain (HTTPS overlay sets it)
HEADROOM_ORIGIN http://localhost:8000 Full origin for passkey verification (HTTPS overlay sets it)
HEADROOM_REMBG_MODEL isnet-general-use rembg model (~179MB; keeps hat bills. u2netp is 4.7MB and far faster but trims thin brims)
HEADROOM_HTTP_TIMEOUT 30.0 Outbound HTTP timeout in seconds (Claude, Google Vision, Melin Recap; eBay has its own fixed timeouts)
HEADROOM_REMBG_CONCURRENCY 1 Concurrent background-removal inferences — the app's largest allocation, so one at a time in a 1 GB container
HEADROOM_LOG_LEVEL INFO Log level when running uvicorn directly
HEADROOM_BACKUP_ENABLED true Scheduled backups on/off
HEADROOM_BACKUP_INTERVAL_HOURS 24 Scheduled backup cadence
HEADROOM_BACKUP_INCLUDE_CA true Fold Caddy's local CA — including its private keys — into each backup, under data/caddy-pki/. On by default because the root is installed by hand on every device and nothing can vouch for a replacement, so losing it means visiting them all. Set false if you would rather not have a key that can sign for any host sitting in an archive you upload off-box. See backups
HEADROOM_BACKUP_KEEP 5 How many rolling local backups to keep (a count, not days). HEADROOM_BACKUP_RETENTION_DAYS is still read, as a count, for older .env files
HEADROOM_SQLITE_SYNCHRONOUS FULL SQLite durability — FULL fsyncs every commit, so committed means committed through a power cut. NORMAL/EXTRA/OFF accepted; anything else falls back to FULL
HEADROOM_MAX_BODY_BYTES 2097152 Cap on non-multipart request bodies (uploads have their own, larger caps)
HEADROOM_DISK_MIN_FREE_MB 500 Below this free space, /health/ready fails
HEADROOM_DISK_WARN_PCT 15 Below this share of the volume, log a warning
HEADROOM_BACKUP_UPLOAD_CMD (unset) Ship each scheduled backup off-box; {path}/{dir}/{name} substituted. Overrides the provider chosen in the Settings UI — host access only, on purpose. See off-site backups + docker-compose.backup-rclone.yml / docker-compose.backup-rsync.yml
HEADROOM_BACKUP_UPLOAD_TIMEOUT 600 Seconds before the upload command is killed
HEADROOM_BACKUP_RSYNC_PASSWORD (unset) Password for the Synology / rsync-daemon provider; read from the host, never stored
HEADROOM_REPRICING_ENABLED true Nightly re-pricing of melin hats against live Melin Recap asks (no Claude call)
HEADROOM_REPRICING_INTERVAL_HOURS / _DELAY_SECONDS / _BATCH_LIMIT 24 / 1.0 / 0 Sweep cadence, pause between hats, cap per sweep (0 = all)
HEADROOM_MDNS_INTERFACE (detected LAN IP) Interface the mDNS responder binds; an IP pins one NIC, all restores all-interfaces
HEADROOM_IMPORT_WORKER_ENABLED true Bulk-import background worker
HEADROOM_ANALYSIS_WORKER_ENABLED true Photo-analysis background worker (off ⇒ the upload route runs the pipeline inline)
HEADROOM_ACTIVITY_LOG_RETENTION_DAYS 90 Audit rows kept (pruned daily)
HEADROOM_MDNS_ENABLED true Advertise headroom.local on the LAN (Docker: stack docker-compose.mdns.yml, or docker-compose.https-lan.yml for passkey-grade HTTPS)
HEADROOM_MDNS_HOSTNAME headroom mDNS host label — resolves as <label>.local
HEADROOM_MDNS_PORT 8000 Port the mDNS advertisement points at
HEADROOM_SITE_ADDRESSES <HEADROOM_MDNS_HOSTNAME>.local LAN HTTPS overlay only. Every name/address Caddy answers on and puts in the certificate, space-separated (Caddy refuses a bare comma). Add the LAN IP to reach it where .local can't resolve — a VPN, a tunnel, another subnet
HEADROOM_MEM_LIMIT 1g Container memory ceiling (compose mem_limit)
HEADROOM_BUILD_SHA (local git SHA) Build stamp shown in the footer; scripts/stamp-build.sh writes it into .env
FORWARDED_ALLOW_IPS 127.0.0.1 Which peers uvicorn trusts for X-Forwarded-*; the Let's Encrypt overlay pins the compose subnet

Under Docker every HEADROOM_* row above is forwarded into the container by docker-compose.yml — a .env beside the compose file is interpolation for Compose, not the container's environment, and a variable Compose does not forward reaches nothing. The full table, with the operational reasoning, is in OPERATIONS §2.


Running on a Raspberry Pi

The Docker image is multi-arch (amd64 + arm64). On a Pi 4/5 running 64-bit Raspberry Pi OS or Ubuntu Server:

# Build on the Pi (slow first build, fine after)
docker compose up --build -d

# Or build on a beefier machine and push:
docker buildx build --platform linux/arm64,linux/amd64 \
  -t your-registry/headroom:latest --push .

The default isnet-general-use rembg model is ~179MB and takes appreciably longer per photo on a Pi 4 than the old 4.7MB u2netp — a trade made once analysis moved off the request path, since nothing waits on it and u2netp cut the bills off hats. Build with REMBG_MODEL=u2netp docker compose up -d --build to go back. Photos, database, and backups live in the headroom-data volume — see OPERATIONS.md §4 for the backup and restore procedure.


Development

./scripts/setup.sh                           # One-shot setup (--help for flags)
uv run uvicorn headroom.app:app --reload     # Backend (port 8000)
cd frontend && npm run dev                   # Frontend (port 5173)
cd frontend && npm run build                 # Type-check + production SPA build
cd frontend && npm run typecheck             # Type-check only
uv run pytest                                # Backend tests
uv run pytest tests/test_search.py -k color  # Single backend test
cd frontend && npm test                      # Frontend tests
cd frontend && npm run test:watch            # Frontend tests, watch mode

Backend tests use in-memory SQLite, stub out rembg, authenticate through a seeded test session, and never call the Anthropic, Google, eBay, or Sharetribe APIs — every external boundary has a test seam.

Frontend tests run under Vitest + Testing Library in jsdom, with the API modules mocked at the module boundary. Test files live beside the components inside src/, so npm run typecheck covers them too. CI runs typecheck → tests → production build on every PR.

Architecture

Backend — Python 3.12+ (3.14 in the image), FastAPI, async SQLAlchemy + aiosqlite:

src/headroom/
├── app.py                       # factory, lifespan, SPA serving, auth gate
├── auth.py                      # session/token guards + gate middleware
├── config.py                    # pydantic-settings (HEADROOM_*)
├── database.py                  # async engine + inline DDL migrations
├── models/                      # User, Case, Hat, HatColor, WearLog, Purchase,
│                                #  ColorwayEntry, ShareLink, ImportJob, …
├── routes/                      # health, public, auth, hats, cases, rooms,
│   │                            #  search, meta, settings, import_jobs, share,
│   │                            #  share_links, guest, ca_cert
│   └── admin/                   # errors, backups, activity, reports, ebay,
│                                #  catalog, analysis, construction, prices,
│                                #  repricing, config — prefix + auth once
├── schemas/                     # Pydantic I/O, one module per route area
└── services/
    ├── claude_analysis.py       # Claude Vision tool-use → structured result
    ├── background_removal.py    # rembg (ONNX) → transparent PNG
    ├── color_extraction.py      # mask-only colors + LAB distance + palette
    ├── google_vision.py         # fallback brand via logo detection
    ├── melin_recap.py           # live resale median (Sharetribe public API)
    ├── catalog_service.py       # colorway harvest + purchase matching
    ├── auth_service.py          # argon2, sessions, rate limiting
    ├── passkey_service.py       # WebAuthn ceremonies
    ├── label_service.py         # QR label sheets, cases + hats (inline SVG)
    ├── tag_service.py           # What a QR/NFC tag points at, and the host in it
    ├── hat_analysis_pipeline.py # upload → bg-removal → analyze → price
    ├── import_service.py        # restart-surviving bulk-import worker
    └── backup_service.py        # scheduled + on-demand tar.gz

Frontend — React 19, Vite, TypeScript, TanStack Query, react-router 8, Vitest + Testing Library, zero UI framework: hand-rolled synthwave design system in two CSS files, PWA-installable, native <datalist> autocomplete, hand-rolled WebAuthn plumbing. No component library, no CSS framework, no state-management dependency.

frontend/src/
├── pages/                       # one per route (SettingsPage is a composition
│                                #  root over components/settings/)
├── components/
│   ├── layout/                  # AppShell, TopNav, BottomNav, Footer
│   ├── common/                  # spinner, badges, swatches, modals
│   ├── photos/                  # capture + cropper
│   ├── hats/                    # filter bar + hat-form fields shared by pages
│   └── settings/                # one card per Settings concern
├── api/                         # typed fetch clients
├── types/                       # interfaces mirroring the Pydantic schemas
└── test/                        # Vitest setup + renderWithProviders

Data model: Rooms → Cases → Hats, plus Rooms → Hats for anything kept out of a case. Cases are type-exclusive (regular or beanie) with per-case capacity. Exactly one room is the default — new cases go there and orphaned cases land there — so it's the only one that can't be deleted; designate another room as default to free it up. Disposed hats keep their history but free their slot.


License

GNU AGPL v3.0.

About

Headroom - Melin Hat Inventory System

Topics

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages