Log iSpindel and GravityMon readings to a database you own
A guide for the iSpindel community. Every command and output below comes from a run against the service on 2026-10-04.
Your iSpindel (or a hydrometer running GravityMon) already knows how to post its readings over HTTP. Point that post at LakehouseBox and every reading of every batch lands as a row in a table you own: gravity, temperature, tilt angle, battery and Wi-Fi signal, with the time it arrived. There is no server, Raspberry Pi or database for you to run, and no retention limit beyond your storage. You query it with SQL from DuckDB (or any engine that reads Apache Iceberg): the gravity curve of a batch, the apparent attenuation so far, which spindle needs charging.
What you need
- An iSpindel on current firmware (7.3; its "HTTP" or "HTTPS Post" service), or a GravityMon device (its "HTTP Post" push in the default iSpindel format). The device must reach the internet over your Wi-Fi.
- A LakehouseBox account and the
lhboxcommand line (agent setup installs it and logs in). - DuckDB 1.5.5 or newer to query, as a command line or a Python module (DuckDB).
The run on this page (2026-10-04, on the live service) posted the bodies the firmwares build, with their method and headers, from a script: the iSpindel's from pio/src/iSpindel.cpp and Sender.cpp (universam1/iSpindel, 7.3 line), the GravityMon's from its push_templates.cpp (mp-se/gravitymon). It was not a physical spindle in a fermenter.
1. A catalog and a table
A catalog holds your tables. Make one for brewing, then the table the readings go to. The columns are the preset's (step 2): one row per reading, the GravityMon-only columns stay empty for an iSpindel.
lhbox catalog create brewing
lhbox table create brewing.fermentation.ispindel \
--column event_time:timestamptz:required --column device_name:string --column device_id:string \
--column gravity:double --column temperature:double --column temp_units:string --column angle:double \
--column battery:double --column rssi:long --column interval:long --column velocity:double \
--column corr_gravity:double --column gravity_unit:string --column run_time:double
2. A sink with the ispindel preset
A sink is the URL your device posts to. --device gives it a URL that needs no header; --preset ispindel turns each post into a row; --require-field token=… makes the sink accept only posts whose Token field (the one in the iSpindel's configuration page) has that value, and removes it before anything is stored. Choose your own token value.
lhbox sink create --catalog brewing --table fermentation.ispindel --name ispindel_in \
--device --require-field token=brew-7d1c --preset ispindel --roll-seconds 900 --save ~/ispindel-sink.json
Sink ispindel_in created: table fermentation.ispindel of catalog brewing, every 900 s (or 64 MiB, or 1000 batches).
...
device route: POST http://in.lakehousebox.com/d/********
no header needed; form, JSON, NDJSON or CSV, up to 256 KiB
the same path also answers over HTTPS on https://in.lakehousebox.com
device token: saved to ~/ispindel-sink.json with the URL (not shown; the API cannot show it again)
mapping: ispindel (0 rules), applied at each roll; try it: lhbox sink test ispindel_in <file>
The URL is the credential, so it is saved to the file and not printed. Read it when you configure the device:
jq -r .device_url_http ~/ispindel-sink.json # http://in.lakehousebox.com/d/<token>
--roll-seconds 900 commits what arrived every 15 minutes, which suits a spindle posting every 15 to 60 minutes; readings show up in the table at the next commit, not the second they arrive.
3. Point the device at it
iSpindel (configuration portal, double-press reset): choose one of the two services.
| field | HTTP | HTTPS Post |
|---|---|---|
| Service Type | HTTP | HTTPS Post |
| Token | your token (brew-7d1c above) |
your token |
| Server Address | in.lakehousebox.com |
https://in.lakehousebox.com |
| Server Port | 80 |
(not asked) |
| Path / URI | /d/<token> from the URL above |
/d/<token> |
The HTTPS Post service joins the server and the path into one URL, so its server field carries https://. It encrypts the post but does not check the certificate (the firmware calls BearSSL's setInsecure); plain HTTP lands the same rows.
GravityMon (Push settings, HTTP Post): URL http://in.lakehousebox.com/d/<token> (or https://…, which GravityMon uses SSL for), the Token setting = your token, and the default format, which is the iSpindel one.
Before the device posts, lhbox sink test shows what a body becomes, without storing anything. With an iSpindel body saved as reading.json:
lhbox sink test ispindel_in reading.json
(an excerpt of the answer)
"stripped": ["token"],
"rows": [{"device_name": "iSpindel000", "device_id": 1386672, "angle": 52.13285, "temperature": 19.6875,
"temp_units": "C", "battery": 4.059285, "gravity": 12.48211, "interval": 900, "rssi": -67,
"event_time": "2026-10-04T00:24:12.205Z"}],
"rejects": [],
"device_secret": "ok"
What the run posted: six iSpindel readings over plain HTTP (User-Agent: iSpindel, Content-Type: application/json, as the firmware sends them) and six GravityMon readings in Fahrenheit over HTTPS, each answered 202 {"accepted":1, …}. After the commit:
lhbox sink get ispindel_in
received {"batches_total": 12, "bytes_total": 2129, "rows_total": 12, "rows_rejected_total": 0}
last_roll {"at": "2026-10-04T00:39:21.124Z", "batches": 12, "rows": 12, "rejected_rows": 0, ...}
device {"enabled": true, ..., "require_field": "token", "strip": ["token"], "refused": {}}
A post with a wrong token is refused with 401 invalid_device_secret and counted under refused.
4. Query it
lhbox duckdb --catalog brewing opens a DuckDB shell with the catalog attached as brewing. The readings below are the run's (posted a few seconds apart, so the "fermentation" lasts two minutes); times are shown in the shell's zone.
The temperature is stored in the unit the device sends, with temp_units (C, F, or K on an iSpindel) beside it, so convert in the query. The latest reading of each device:
SELECT device_name, device_id, max(event_time) AS last_seen,
arg_max(gravity, event_time) AS gravity,
round(arg_max(CASE temp_units WHEN 'F' THEN (temperature - 32) * 5 / 9
WHEN 'K' THEN temperature - 273.15
ELSE temperature END, event_time), 1) AS temp_c,
arg_max(battery, event_time) AS battery_v, count(*) AS readings
FROM brewing.fermentation.ispindel
GROUP BY device_name, device_id
ORDER BY device_name;
┌─────────────┬───────────┬────────────────────────────┬─────────┬────────┬───────────┬──────────┐
│ device_name │ device_id │ last_seen │ gravity │ temp_c │ battery_v │ readings │
├─────────────┼───────────┼────────────────────────────┼─────────┼────────┼───────────┼──────────┤
│ gravmon │ 2E6753 │ 2026-10-04 02:26:18.293+02 │ 1.0112 │ 19.5 │ 3.67 │ 6 │
│ iSpindel000 │ 1386672 │ 2026-10-04 02:26:07.065+02 │ 3.18 │ 19.8 │ 4.04 │ 6 │
└─────────────┴───────────┴────────────────────────────┴─────────┴────────┴───────────┴──────────┘
The gravity curve of one spindle:
SELECT event_time, gravity, angle,
round(CASE temp_units WHEN 'F' THEN (temperature - 32) * 5 / 9
WHEN 'K' THEN temperature - 273.15
ELSE temperature END, 1) AS temp_c
FROM brewing.fermentation.ispindel
WHERE device_name = 'iSpindel000'
ORDER BY event_time;
┌────────────────────────────┬─────────┬────────┬────────┐
│ event_time │ gravity │ angle │ temp_c │
├────────────────────────────┼─────────┼────────┼────────┤
│ 2026-10-04 02:24:14.96+02 │ 12.51 │ 52.9 │ 19.4 │
│ 2026-10-04 02:24:37.392+02 │ 11.02 │ 50.6 │ 19.9 │
│ 2026-10-04 02:24:59.77+02 │ 8.73 │ 46.8 │ 20.4 │
│ 2026-10-04 02:25:22.178+02 │ 6.12 │ 43.0 │ 20.6 │
│ 2026-10-04 02:25:44.61+02 │ 4.37 │ 40.1 │ 20.3 │
│ 2026-10-04 02:26:07.065+02 │ 3.18 │ 38.2 │ 19.8 │
└────────────────────────────┴─────────┴────────┴────────┘
Apparent attenuation since brew day, from the first and the latest reading. An iSpindel reports gravity in whatever unit your calibration formula gives (Plato or SG; it does not say which), so the query treats a value under 2 as SG and anything else as Plato. A batch is a time range on one device: put your brew day in the WHERE.
WITH batch AS (
SELECT device_name,
arg_min(gravity, event_time) AS og,
arg_max(gravity, event_time) AS now_gravity
FROM brewing.fermentation.ispindel
WHERE event_time >= TIMESTAMPTZ '2026-10-04 00:00:00+00'
GROUP BY device_name)
SELECT device_name, og, now_gravity,
round(100 * CASE WHEN og < 2 THEN (og - now_gravity) / (og - 1)
ELSE (og - now_gravity) / og END, 1) AS apparent_attenuation_pct
FROM batch
ORDER BY device_name;
┌─────────────┬────────┬─────────────┬──────────────────────────┐
│ device_name │ og │ now_gravity │ apparent_attenuation_pct │
├─────────────┼────────┼─────────────┼──────────────────────────┤
│ gravmon │ 1.0498 │ 1.0112 │ 77.5 │
│ iSpindel000 │ 12.51 │ 3.18 │ 74.6 │
└─────────────┴────────┴─────────────┴──────────────────────────┘
What lands in the table
| column | from the post | notes |
|---|---|---|
event_time |
when the post arrived | the body carries no time of its own |
device_name, device_id |
name, ID |
the iSpindel's chip id is a number, GravityMon's is hex; both are stored as text |
gravity |
gravity |
as the device computed it (your polynomial's unit on an iSpindel) |
temperature, temp_units |
temperature, temp_units |
as sent: C, F or K |
angle, battery, rssi, interval |
angle, battery, RSSI, interval |
tilt in degrees, volts, dBm, seconds between posts |
velocity, corr_gravity, gravity_unit, run_time |
GravityMon's velocity, corr-gravity, gravity-unit, run-time |
empty for an iSpindel |
token never reaches the table: with --require-field it is removed at the door, and the preset keeps only the columns above, so a field you add to a GravityMon template is not stored either.
Limits and gotchas
- One sink, many devices. Each post carries the device's name and id, so several spindles can share one sink and one table. A sink takes 6 posts a minute sustained (bursts of 30), far above a spindle every 15 minutes.
- Readings appear at the commit, every
--roll-seconds(900 above, 60 at the least), not instantly. - A post repeated byte for byte within 5 minutes counts once: the device route treats it as a retry.
- The temperature unit is not converted on the way in: a sink's mapping cannot read one field to decide how to convert another, so the conversion is the
CASEin the queries above. - The table's columns are the preset's. A table missing one (for example without the GravityMon columns) is refused when the sink is created, naming the column.
- Any engine that reads Iceberg reads the same table (engines); this page ran DuckDB 1.5.6.
Next
- Ingest sinks: the device route, mappings and presets in full.
- DuckDB: connecting, and writing your own tables next to the readings.
- Files: keep brew-day photos and recipes in the same catalog.