Nespresso-Stats
Docker app from FatzCat's Repository
Overview
Readme
View on GitHubNespresso Stats
Disclaimer: this repository is written and maintained with AI coding agents. Every change is reviewed by the maintainer before it lands, but expect AI-generated code and docs — check anything critical yourself. All images in the repo (capsule photos, artwork, favicons and the machine render) are AI-generated too; they are not official Nespresso assets.
A self-hosted dashboard for your Nespresso capsule inventory — Vertuo and Original Line: what you have, how many of each, brew history, and optional auto-decrement when the machine brews a coffee.
No dependencies — Python 3 standard library only.
python3 server.py # → http://127.0.0.1:8787
python3 server.py --selftest
Data lives in data/nespresso.db (SQLite). The first run seeds a starter
catalogue from capsules.json and opens a stock-take: enter how many of
each capsule you have, and capsules left at 0 are removed from your inventory
(add them back later from the capsule picker). Edit or delete the rows freely.
PORT=9000 HOST=0.0.0.0 python3 server.pyto expose it on your LAN. The server has no login — it is built for a trusted home network. Expose it to the internet only behind an authenticating reverse proxy.- The ntfy topic in
/api/stateis masked (https://ntfy.sh/•••) because anyone who knows a topic can publish to your phone; a/api/exportbackup still carries the real URL (it has to restore). A stored topic URL must behttp(s)— afile:URL would make the server read local paths. NESPRESSO_DB=/path/to.dbto move the database.
Docker
docker compose up -d # pulls the pinned release → http://<host>:8787
Releases are git tags: a v0.95 tag builds the immutable image tag :0.95, and
docker-compose.yml pins that — so docker compose pull only changes what you
run when you bump the pin. A moving :latest still exists for convenience;
never deploy from it, since anything pushed to main lands there.
git tag v0.96 && git push origin v0.96 # publish a release
# then bump the image tag in docker-compose.yml and: docker compose pull && docker compose up -d
Or without compose:
docker run -d --name nespresso-stats -p 8787:8787 \
-v "$PWD/data:/data" ghcr.io/avpnusr/nespresso-stats:0.95
The vision backend is configurable from the shell or a .env file beside the
compose file: VISION_BASE_URL (default http://127.0.0.1:11434/v1), VISION_MODEL
(default qwen3.5:4b) and VISION_API_KEY (default ollama, ignored by a local
Ollama). Inside the container 127.0.0.1 is the container itself, so to reach an
Ollama on the host set VISION_BASE_URL to the host's IP (host.docker.internal
on Docker Desktop). Set VISION_MODEL= (empty) to turn vision off and identify by
colour alone.
Two of the same-shape settings are about access:
PUBLIC_HOST— the hostname the dashboard is browsed by name (behind Caddy that'sncs.khmls.net). Requests to/api/*through any other name are refused: the write-guard trusts a request's origin only as far as the request's ownHostheader, and a DNS-rebinding page controls both (attacker's domain, attacker's DNS pointing at your machine). Browsing by IP address (<lan-ip>:8787),localhostand the healthcheck keep working without it. Set it in the.envnext to the compose file.TRUST_PROXY— off by default. Set it to the address(es) of the reverse proxy you control — comma-separated IPs/CIDRs, e.g.TRUST_PROXY=192.168.70.10— and only requests arriving from those addresses may setX-Forwarded-Host. The header is client-supplied, so trusting it from any peer would let a DNS-rebinding page pass thePUBLIC_HOSTallowlist. A value that names no address (the oldTRUST_PROXY=1) is ignored with a startup warning. A proxy that preserves the originalHost(Caddy's default) needs neither. Under Docker, list the proxy's container address (put the proxy on the compose network): a proxy running on the Docker host reaches the app through the bridge gateway, and trusting the gateway also trusts every other client on the host.AUTH_TOKEN— empty by default (the LAN-trust model, nothing changes). When set, every/api/*route requiresAuthorization: Bearer <token>(401 otherwise). The page asks for it once and keeps it in this browser (localStorage, like the theme), so a user who supplies the right token gets the normal dashboard. To hand it over in one go, openhttp://<host>:8787/#token=<AUTH_TOKEN>once — a fragment never reaches the server, and it is stripped from the address bar after it is read. (?token=<AUTH_TOKEN>works too, but a reverse proxy may log that query string.) Without a token the page still loads but shows the prompt instead of data;curl-style automation sends the header itself. Clearing the site data forgets the token.
The database lives in ./data (bind-mounted), so it survives rebuilds.
Delete data/nespresso.db to re-seed from capsules.json on next start.
The image runs as PUID/PGID (default 99:100, Unraid's nobody:users).
Set them to match your host; ./data is chowned to match on start.
The dashboard

- First-run stock-take: the seeded catalogue starts empty, so on first use a dialog asks how many of each capsule you have; anything left at 0 is removed and the grid starts with only your capsules (re-add them from the picker).
- Capsule cards grouped by cup size, each with the capsule's photo
(or your own photo URL; falls back to a drawn SVG if none), count,
+/−buttons; drag a tile to reorder them. +/−adjust stock; Brew lowers stock by 1 and logs a brew.- Filter box above the grid hides tiles whose name, cup size or notes don't match (drag-to-reorder is off while a filter is active).
- Coffee stats: brewed today / last 7 days, average per day, spend this month, a 12-week heatmap, most-brewed and per-family breakdowns, plus a burn-rate “~N days left” forecast for each capsule and for your whole stock.
- Running low list (per-capsule threshold) with one-tap restock to your sleeve size, and 🛒 Copy order list — the reorder amounts, prices and total as clipboard text.
- Recent activity lists brews and restocks; undo any entry or reassign a brew's capsule.
- Upkeep reminders for cleaning (default every 10 days) and descaling (default every 90 days, both editable in the Machine dialog): a banner appears when one is due, and logging it adds a Cleaning/Descaling entry to the activity list. 🧽 Cleaned / 🧴 Descaled log it in one tap.
- Add / edit / delete capsules, optionally prefilled from the known-capsule
catalogue (
capsules.json), with optional price per capsule and a 1–13 intensity shown as a scale on each tile. - Mark limited editions (★ badge), set your machine name, model, photo, capsule system and sleeve size (the one-tap restock amount), and pick a colour theme (auto / dark / light / midnight / forest / blush / high contrast).
- Backup: export the full database as JSON from the Machine dialog, and restore it back (replaces all current data).
- Installable (PWA): a web app manifest and icons let your browser add the dashboard to your phone's home screen and open it full-screen. No service worker — it still needs the server, online.
- Push notifications (optional): set an ntfy topic URL — public ntfy.sh or self-hosted — in the Machine dialog and test it with Send test. Whenever stock or upkeep changes, the server pushes “running low” and “cleaning/descaling due” reminders, at most once per episode (restocking or logging the task re-arms them). Works even if nobody opens the dashboard; it also catches up at startup. A catalogue pull that turns up genuinely new capsules for the machine's line pushes once, too — the first check after an upgrade baselines the whole store silently, so only releases from then on are announced.
- Price watch: a capsule's tile shows the store's current price when it moved away
from the price you paid (matched by
sku, scoped to the machine's line —store_priceon the capsule in/api/state). ↓ the store is cheaper now, ↑ it went up. The price you paid is not changed, so the spend KPIs stay what you actually spent.
Vertuo or Original Line
The dashboard runs one machine at a time. Pick its capsule system under ⚙ Machine → Capsule system:
| Vertuo | Original Line | |
|---|---|---|
| Cup sizes | Espresso, Double Espresso, Gran Lungo, Mug, Alto, Carafe, Alto XL | Ristretto, Espresso, Lungo |
| Capsules shown | the 50 Vertuo entries in capsules.json |
the 29 Original entries |
| Identify from photo | only Vertuo matches | only Original matches |
| Auto-decrement | cup size from the barcode, so the pod is narrowed down | never a cup size — a detected brew is queued as which capsule was it? |
The choice scopes the tiles, the cup-size dropdown, the known-capsule picker and the photo identification. Capsules of the other line stay in the database and come back when you switch back — so a new capsule should be added while its line is selected, and it is stamped with that line. An existing install keeps its Vertuo inventory and finds the Original capsules in the Known capsule picker (the one-shot seed never touches a populated database).
The Original entries cover the standard range (Ristretto / Espresso / Lungo), the Master Origins single origins and a snapshot of the current limited editions (seeded with the ★ badge; seasons come and go, so delete the ones that are gone and add the new ones from the picker).
Every catalogue entry carries the store's sku, the stable product id. Capsules are matched by sku first and name second, so a store rename — Nespresso renamed the Original range in 2026, Ispirazione Firenze Arpeggio is now Arpeggio — does not leave you with the same capsule twice. An existing database is migrated once on start: rows gain their sku and renamed rows are updated in place, keeping counts and brew history.
Why Original Line detection is coarser: a Vertuo machine scans the pod's
barcode and reports a cup size (lastCoffeeFamilyID), an Original machine has no
barcode and reports at most that it is brewing. Its brew therefore arrives
without a cup size, and every in-stock capsule of that line is offered in the
pending banner.
The cup-size colours for the Original entries in capsules.json are eyeballed
from product photos, not sampled from a real pod — no capsule photos ship for
that line yet. Identification by colour is only as good as those values, so swap
in sampled ones when you add photos.
New capsule hint
The dashboard also pulls the store's machine-readable catalogue from
nespresso-scanner (published
twice a day) and shows a banner of capsules that are not in your local
catalogue yet (new_capsules in /api/state, scoped to the machine's capsule
line, diffed by sku — the store renames capsules). Tapping a name opens the
add dialog pre-filled with the store's name, family, photo and price; dismissed
names stay dismissed in that browser. When a pull finds such capsules and a
push topic is configured, the server also announces them once (machine's line,
newest at the top of the banner). CATALOGUE_URL overrides the URL and
CATALOGUE_URL= (empty) switches the pull off for offline installs — the
dashboard keeps working, this hint just stays quiet (a down host is ignored and
the last good data is kept).
Identifying a capsule from a photo
The dashboard has 📷 Identify from photo (also captures straight from a phone camera). It works in two stages:
- The browser reads the photo's dominant colour on a
<canvas>(no image library needed) and the server ranks your catalogue by colour similarity. - If a vision backend is configured, the model is shown the photo plus that colour shortlist and asked to pick one — much easier than picking from the whole catalogue.
What decides the result is the photo, not the model. A Vertuo pod prints its
name and cup size around the aluminium base (VOLTESSO · ESPRESSO · 40 ml),
an Original capsule prints them on the top foil, and sleeves, boxes and product
shots print them too — so photograph the marked side, the sleeve or the box.
With the name in the frame, all the model has to do is transcribe what it can
see, which is the easy end of the job and exactly what the small default model
is for.
A top-down photo of the bare dome is the hard case: no text at all. There
the model can only vote on colour, and that is where even the large cloud models
in the table below are barely better than guessing. The default local
qwen3.5:4b has nothing to add on a dome shot — you still get the browser's
colour shortlist, but treat it as a guess. If you want identify to work without
fiddling, shoot the underside, not the top.
Colour ranking is only as good as the stored colours, so the seed colours in
capsules.json were sampled from the capsule photos, not guessed (the Original
Line entries are the one exception — see above).
Benchmarked on real capsule photos
24 AI-generated capsule photos, exact-name accuracy — the capsules' own product shots, names printed on the artwork:
| Approach | Correct |
|---|---|
colour shortlist (top 6) → kimi-k3 |
20/24 (83%) |
kimi-k3 on the full 45-name list |
16/24 (67%) |
qwen3.5:397b full list |
7/24 (29%) |
deepseek-v4.1-flash (10-photo set) |
4/10 |
gemma4:31b, mistral-large-3:675b (10-photo set) |
2/10 |
minimax-m3, local gemma4:12b/26b |
~0/10 |
| colour ranking alone | correct capsule in the top 5: 24/24 |
So kimi-k3 is worth using; the others are barely better than guessing.
Even kimi-k3 still collapses the brown/gold capsules onto each other
(Melozio, Orafio, Inizio, Caramelizio). Treat identify as a shortlist to
confirm, never an automatic answer. The only exact identifier is the
barcode ring on the flange — it isn't readable from a casual photo, and some
types share the same code. A photo of the sleeve or box, where the name
is printed, is far easier and the prompt asks the model to read text first.
Enabling a vision backend
Any OpenAI-compatible endpoint. The default is a local Ollama —
ollama pull qwen3.5:4b — on http://127.0.0.1:11434/v1:
VISION_BASE_URL=http://127.0.0.1:11434/v1 VISION_MODEL=qwen3.5:4b python3 server.py
Ollama Cloud, if you'd rather not run a model yourself (needs an API key):
VISION_BASE_URL=https://ollama.com/v1 \
VISION_MODEL=deepseek-v4.1-flash \
VISION_API_KEY=your-ollama-key \
python3 server.py
VISION_API_KEY defaults to ollama. With VISION_BASE_URL and VISION_MODEL
unset — in Docker, VISION_MODEL= empty — identify is colour-only and offline.
Auto-update: what the machine can and cannot tell you
Short version: you can detect that a coffee was brewed and its family, but not which exact capsule. The Vertuo machine scans the barcode on the capsule to choose brew parameters, but neither of the reverse-engineered interfaces exposes that decoded code.
| Path | Gives you | Needs |
|---|---|---|
Nespresso cloud API (nespresso.com/ecapi, what the app uses) |
machineStatus (incl. brewing), lastCoffeeFamilyID |
Your nespresso.com login tokens |
| Local BLE (Vertuo Next / Pop / Pop+ / Up / Creatista / Lattissima) | state incl. capsule_reading and brewing, water-tank-empty, capsule-container-full |
Machine in BLE range |
| Smart plug power monitoring | That a brew started, by power signature | A metered plug |
The cloud lastCoffeeFamilyID maps to: 1 Espresso, 2 Double Espresso,
3 Gran Lungo, 4 Mug, 5 Alto, 6 Carafe, 7 Alto XL. It is a Vertuo value — an
Original Line machine has no barcode reader and reports no cup size at all, so with
that system selected a detected brew is queued as a familyless pending (see
Vertuo or Original Line).
Existing Home Assistant integrations already solve the machine connection — use one of these and don't reimplement it:
piitaya/ha-nespresso-cloud— cloud, read-only.trust-the-log/ha-nespresso— cloud, read-only.renaudallard/homeassistant_nespresso_smart— local BLE.joostlek/nespresso-ble— Python BLE client.
Wiring a detector to this dashboard
Point any detector at POST /api/brew-detected:
curl -X POST http://127.0.0.1:8787/api/brew-detected \
-H 'content-type: application/json' \
-d '{"family":"Mug","source":"home-assistant"}'
family accepts the name (Mug) or the cloud id ("4"). Add capsule when
you know which pod it was — a Stream Deck key, or anything that can name it —
and the exact capsule is decremented with no family guessing (the name is
matched forgivingly, so a typo still lands):
curl -X POST http://127.0.0.1:8787/api/brew-detected \
-H 'content-type: application/json' \
-d '{"capsule":"Intenso","source":"streamdeck"}'
If capsule is omitted or resolves to nothing, the family rule below applies. The server then:
- one capsule of that family in stock → decrements it automatically;
- several in stock → queues a pending brew, and the dashboard shows a banner: “A Mug was just brewed — which one?” with one-tap buttons;
- none in stock → logs it as
out-of-stock, changes nothing.
Home Assistant automation example (cloud integration exposes the last brewed family as a sensor):
alias: Log Nespresso brew
trigger:
- platform: state
entity_id: sensor.my_vertuo_machine_status
to: "brewing"
action:
- service: rest_command.nespresso_stats_brew
data:
family: "{{ states('sensor.my_vertuo_last_coffee') }}"
# configuration.yaml
rest_command:
nespresso_stats_brew:
url: "http://127.0.0.1:8787/api/brew-detected"
method: POST
content_type: "application/json"
payload: '{"family":"{{ family }}","source":"home-assistant"}'
For a BLE setup, trigger on the state becoming brewing (or
capsule_reading) and post {"family":"..."} — or, if you don't want to
guess the family, post {"family":"Unknown"} just to log that a brew
happened, and tap the capsule in the dashboard.
Languages
The UI ships in English, German and French, with a language picker in the top right showing
the flag and short code (e.g. 🇬🇧 EN). The choice is remembered per browser
(localStorage); on a first visit the browser's language is used when a matching
translation exists, otherwise English.
Adding a language means adding one file — no code changes:
- Copy
static/i18n/en.jsontostatic/i18n/<code>.json(e.g.fr.json). - Set
"language"(full name, used as the tooltip) and"flag"(the emoji shown in the picker, e.g."🇫🇷"). The option itself shows the flag plus the uppercase file code, sofr.jsonrenders as🇫🇷 FR. - Translate the values under
"strings". Keys you leave out fall back to English, so a partial translation is fine.
The picker is built from GET /api/languages, which lists every static/i18n/*.json
and reads its language field — drop the file in and it appears.
API
| Method | Path | Body |
|---|---|---|
| GET | /api/state |
— (capsules, brews, pending, maintenance, machine, families, and known — catalogue capsules not in your inventory, used to prefill the add dialog; machine.ntfy_url is masked; capsules can carry store_price when the store's current price moved, see Price watch) |
| GET | /api/stats |
— (raw brew/restock events for the stats view, scoped to the machine's line) |
| GET | /api/languages |
— (available UI translations, [{code, label}]) |
| GET | /api/export |
— (full JSON backup of capsules, brews, settings) |
| POST | /api/import |
a /api/export backup — replaces all data |
| POST | /api/notify-test |
{url?} — test push; a client-chosen url must be ntfy.sh (https) or localhost, anything else tests the saved topic ({ok} only — the failure detail stays server-side) |
| POST | /api/capsules |
{id?, name, family, color, image, notes, count, price, threshold, intensity, special} (special = limited-edition ★) |
| DELETE | /api/capsules?id=N |
— (409 if the capsule has brew history: set its count to 0 instead) |
| POST | /api/brew |
{capsule_id, delta, log?, source?} (delta -1 = remove, +1 = restock; log:true also records a brew, source defaults to manual) |
| POST | /api/brew-detected |
{family?, capsule?, source?} — capsule names the exact pod and wins; family alone falls back to the rule below |
| POST | /api/brews |
{brew_id, capsule_id} — reassign/resolve a brew |
| DELETE | /api/brews?id=N |
— (undo a brew/restock and fix the count; 409 for a maintenance event) |
| POST | /api/identify |
{color, image?} — color is #rrggbb, image an optional data URL |
| POST | /api/resolve |
{capsule_id, brew_id?} |
| POST | /api/dismiss |
{brew_id} |
| POST | /api/settings |
{machine_name?, machine_model?, machine_image?, machine_line?, sleeve_size?, clean_days?, descale_days?, ntfy_url?} (ntfy_url must be an http(s) topic URL; empty clears it) |
| POST | /api/order |
{ids} — capsule ids in the user's drag order (the other line's order is kept) |
| POST | /api/maintenance |
{task} — clean or descale (a logged upkeep event cannot be undone) |
| POST | /api/setup |
{counts: {capsule_id: count}} — stock-take: applies the counts and deletes rows left at 0 unless a brew references them; also the re-runnable "count every capsule" action |
Every POST/DELETE is refused with 403 when the browser reports a foreign
Origin (a web page you merely visit must not be able to POST /api/import and
wipe the db). Clients that send no Origin — the Stream Deck plugin, curl — are
unaffected.
Files
server.py— HTTP server, SQLite storage, brew-matching logic.Dockerfile,docker-compose.yml,entrypoint.sh— container image, compose service, and thePUID/PGIDdrop-privileges wrapper.static/index.html— the dashboard (vanilla HTML/CSS/JS, no build).static/manifest.webmanifest,static/icon-*.png— PWA manifest and home-screen icons.static/i18n/*.json— UI translations (one file per language, self-labelled).screenshot.png— the dashboard picture above.capsules.json— starter catalogue of the capsules you own (name, sku, family, colour, intensity, and a photo understatic/capsules/).
Media gallery
Categories
Related apps
Explore more like this
Explore allDetails
ghcr.io/avpnusr/nespresso-stats:latestRuntime arguments
- Web UI
http://[IP]:[PORT:8787]- Network
bridge- Shell
sh- Privileged
- false
Template configuration
Port for the Nespresso Stats web dashboard.
- Target
- 8787
- Default
- 8787
- Value
- 8787
Holds the SQLite database (nespresso.db). Delete it to re-seed the starter capsule catalogue.
- Target
- /data
- Default
- /mnt/user/appdata/nespresso-stats
- Value
- /mnt/user/appdata/nespresso-stats
User ID the app runs as. Default 99 = nobody.
- Default
- 99
- Value
- 99
Group ID the app runs as. Default 100 = users.
- Default
- 100
- Value
- 100
OpenAI-compatible vision endpoint for photo identify. 127.0.0.1 is the container itself, so point this at your Ollama host, e.g. http://192.168.1.10:11434/v1.
- Default
- http://127.0.0.1:11434/v1
- Value
- http://127.0.0.1:11434/v1
Vision model name. Leave empty to turn vision off and identify capsules by colour alone.
- Default
- qwen3.5:4b
- Value
- qwen3.5:4b
API key for the vision endpoint. Ignored by a local Ollama; set your Ollama Cloud key when using https://ollama.com/v1.
- Default
- ollama
- Value
- ollama