Keep your Sensor.Community sensor's history in your own database
A guide for the Sensor.Community community. Every command and output below comes from a run against the service on 2026-10-04.
Your sensor (the airrohr / Luftdaten kit: an ESP8266 with an SDS011, SPS30 or other particulate sensor, plus a BME280, DHT22 or other climate sensor) already posts to sensor.community. The firmware can also send every measurement to a second server of your choice: the Send data to custom API option. Point it at LakehouseBox and every post becomes rows in a table you own: one row per reading, with the sensor's chip id, kept for as long as you like. You can query the history with SQL (DuckDB, Python, Spark, anything that reads Apache Iceberg), export it, or publish it. Your upload to sensor.community keeps running as before. You do not need to run anything at home: no Raspberry Pi, no relay, no script.
What you need
A sensor running the Sensor.Community firmware (airrohr-firmware; this guide follows
NRZ-2024-135). The setting is on the sensor's own configuration page,http://<the sensor's IP>/config, on the APIs tab.A LakehouseBox account (sign up, free) and the CLI, one Python 3 file:
curl -fsSL https://lakehousebox.com/install.sh | sh lhbox login # approve once in the browser; the key is saved, never shownFor queries on your machine, DuckDB 1.5.5 or newer (the run used 1.5.6).
lhbox queryworks without it.
How this was verified. The 2026-10-04 run posted to the production service with the firmware's exact request: the method, URL, headers and the JSON body, byte for byte as airrohr-firmware.ino builds it. Three sensor kits were covered (SDS011 + BME280, SDS011 + DHT22, SPS30 + SHT3X). No physical sensor was part of that run.
1. A catalog and a table
A catalog holds your tables. The table has the six columns the sensor-community preset fills: one row per reading. They are the same columns as the Ecowitt guide's, so a weather station and an air sensor can share one table.
lhbox catalog create air
lhbox table create air.sensors.readings \
--column event_time:timestamptz:required --column sensor_id:string:required \
--column sensor_type:string:required --column metric_name:string:required \
--column metric_value:double:required --column metric_unit:string
2. A sink with a device route and the sensor-community preset
A sink receives posts and commits them to the table every 5 minutes. --device gives it a URL the sensor can post to with no extra header; that URL is the credential. --preset sensor-community turns each post into tidy rows.
lhbox sink create --catalog air --table sensors.readings --name airrohr \
--device --preset sensor-community --save ~/airrohr-sink.json
The run's output (2026-10-04):
Sink airrohr created: table sensors.readings of catalog air, every 300 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
the URL is the credential: 44 characters for the device's server + path fields
device token: saved to airrohr-sink.json with the URL (not shown; the API cannot show it again)
mapping: sensor-community (44 rules), applied at each roll; try it: lhbox sink test airrohr <file>
~/airrohr-sink.json (mode 0600) holds the device URL. If you lose it, run lhbox sink device-token airrohr --save ~/airrohr-sink.json: that makes a new URL, and the old one stops working at once.
3. Point the sensor at it
Print the plain-HTTP URL once. Treat it as a secret: it is your sensor's password.
jq -r .device_url_http ~/airrohr-sink.json # http://in.lakehousebox.com/d/<token>
On the sensor's configuration page, APIs tab, in the Send data to custom API section:
| Field | Value |
|---|---|
| Send data to custom API | ticked |
| HTTPS | not ticked |
| Server | in.lakehousebox.com (no http://) |
| Path | /d/<token> (the 22 characters after /d/ in the URL) |
| Port | 80 |
| User, Password | empty |
Then click Save configuration and restart. From the next measuring interval on, the sensor posts to both sensor.community and your table.
Why plain HTTP. With HTTPS ticked, the ESP8266 firmware opens TLS with a 1 KB receive buffer. That only works with a server that agrees to send small TLS records. Ours does not: on 2026-10-04 in.lakehousebox.com ignored that request (the max fragment length extension) and sent a 3.8 KB handshake. HTTPS from an ESP8266 was not tried on a real device; use port 80.
4. Check that it arrives
The firmware posts one JSON object per measuring interval, like this one (an SDS011 + BME280 kit):
{"esp8266id": "13982237", "software_version": "NRZ-2024-135", "sensordatavalues":[{"value_type":"SDS_P1","value":"14.73"},
{"value_type":"SDS_P2","value":"6.20"},{"value_type":"BME280_temperature","value":"17.84"},
{"value_type":"BME280_pressure","value":"98712.44"},{"value_type":"BME280_humidity","value":"63.21"},
{"value_type":"samples","value":"5118422"},{"value_type":"min_micro","value":"28"},{"value_type":"max_micro","value":"20091"},
{"value_type":"interval","value":"145000"},{"value_type":"signal","value":"-71"}]}
The run posted it the way the sensor does, with its headers:
$ curl -X POST http://in.lakehousebox.com/d/<token> -H 'Content-Type: application/json' \
-H 'X-Sensor: esp8266-13982237' --data-binary @sds-bme280.json
{"accepted":1,"rejected":0,"batch_id":"d-ce64…","sink_id":"8688db3f-…","bytes":519,"received_at":"2026-10-04T00:36:47.315Z","state":"pending"}
The firmware counts any answer from 200 to 208 as a success, so it is happy with this 202. If the same body comes again within 5 minutes (a retry), the answer is 200 with "duplicate":true and the post is stored once.
To see what the preset makes of a post without storing anything, save one body to a file and dry-run it:
$ lhbox sink test airrohr sds-bme280.json
Dry run on sink airrohr (application/json): 1 record, nothing stored.
mapping: sensor-community v1 -> 10 rows
sensor_id event_time sensor_type metric_name metric_unit metric_value
--------- ------------------------ ----------- --------------- ----------- ------------
13982237 2026-10-04T00:36:48.345Z SDS011 pm10 ug/m3 14.73
13982237 2026-10-04T00:36:48.345Z SDS011 pm2_5 ug/m3 6.2
13982237 2026-10-04T00:36:48.345Z BME280 temperature celsius 17.84
13982237 2026-10-04T00:36:48.345Z BME280 pressure hPa 987.1244
13982237 2026-10-04T00:36:48.345Z BME280 humidity percent 63.21
13982237 2026-10-04T00:36:48.345Z device loop_samples count 5118422.0
13982237 2026-10-04T00:36:48.345Z device loop_min_micros us 28.0
13982237 2026-10-04T00:36:48.345Z device loop_max_micros us 20091.0
13982237 2026-10-04T00:36:48.345Z device send_interval ms 145000.0
13982237 2026-10-04T00:36:48.345Z device wifi_signal dBm -71.0
The run posted eight bodies in all: the three kits, five more from the SDS011 + BME280 kit 30 seconds apart, and one repeat that was refused as a duplicate. After the roll:
$ lhbox sink get airrohr
received {"batches_total": 8, "bytes_total": 4370, "rows_total": 8, "rows_rejected_total": 0}
last_roll {"at": "2026-10-04T00:41:55.534Z", "batches": 8, "rows": 86, "rejected_rows": 0, ...}
What the rows look like
The rows are in long format: one row per reading. The columns:
event_time: when the post arrived. The firmware sends no time of its own.sensor_id: the chip id fromesp8266id, the number the firmware also sends asX-Sensor: esp8266-<id>.sensor_type: the hardware:SDS011,SPS30,PMS,HPM,NPM,IPS,PPD42NS,BME280,BMP280,BMP180,DHT22,HTU21D,SHT3X,SCD30,DS18B20,DNMS,GPS, ordevicefor the firmware's own figures.metric_name,metric_value,metric_unit: what was measured, its value and unit, as in the table below.
| metric_name | from the firmware's value_type | unit |
|---|---|---|
pm1, pm2_5, pm4, pm10 (and pm0_1, pm0_3, pm0_5, pm5) |
*_P0, *_P2, *_P4, *_P1, … (P1 is PM10, P2 is PM2.5) |
ug/m3 |
nc0_5, nc1, nc2_5, nc4, nc10, … |
SPS30_N05, NPM_N1, IPS_N25, … |
#/cm3 |
temperature, humidity |
BME280_temperature, temperature (DHT22), … |
celsius, percent |
pressure |
BME280_pressure, BMP280_pressure, BMP_pressure, converted from Pa |
hPa |
co2; noise_laeq, noise_la_min, noise_la_max |
SCD30_co2_ppm; DNMS_noise_* |
ppm; dBA |
wifi_signal, send_interval, loop_samples, … |
signal, interval, samples, min_micro, max_micro |
dBm, ms, … |
A numeric value the preset does not know is kept as a row with sensor_type = 'unknown', the firmware's own name and unit raw, so a reading from a new sensor is never lost. Because the format is long, adding a sensor never changes the table.
Query it
lhbox duckdb --catalog air opens a DuckDB shell with the catalog attached as air. The run's queries and their output:
SET TimeZone = 'Europe/Berlin'; -- your zone, so hours and days are yours
-- every sensor and what it measures
SELECT sensor_id, sensor_type, count(DISTINCT metric_name) AS metrics, count(*) AS readings
FROM air.sensors.readings GROUP BY ALL ORDER BY sensor_id, sensor_type;
┌───────────┬─────────────┬─────────┬──────────┐
│ sensor_id │ sensor_type │ metrics │ readings │
├───────────┼─────────────┼─────────┼──────────┤
│ 13982237 │ BME280 │ 3 │ 18 │
│ 13982237 │ SDS011 │ 2 │ 12 │
│ 13982237 │ device │ 5 │ 30 │
│ 3047120 │ DHT22 │ 2 │ 2 │
│ 3047120 │ SDS011 │ 2 │ 2 │
│ 3047120 │ device │ 5 │ 5 │
│ 8811904 │ SHT3X │ 2 │ 2 │
│ 8811904 │ SPS30 │ 10 │ 10 │
│ 8811904 │ device │ 5 │ 5 │
└───────────┴─────────────┴─────────┴──────────┘
-- the latest particulate and climate values of each sensor
SELECT sensor_id, metric_name, arg_max(metric_value, event_time) AS value, any_value(metric_unit) AS unit, max(event_time) AS at
FROM air.sensors.readings
WHERE metric_name IN ('pm2_5', 'pm10', 'temperature', 'humidity', 'pressure')
GROUP BY ALL ORDER BY sensor_id, metric_name;
┌───────────┬─────────────┬──────────┬─────────┬────────────────────────────┐
│ sensor_id │ metric_name │ value │ unit │ at │
├───────────┼─────────────┼──────────┼─────────┼────────────────────────────┤
│ 13982237 │ humidity │ 66.31 │ percent │ 2026-10-04 02:39:01.021+02 │
│ 13982237 │ pm10 │ 15.86 │ ug/m3 │ 2026-10-04 02:39:01.021+02 │
│ 13982237 │ pm2_5 │ 6.48 │ ug/m3 │ 2026-10-04 02:39:01.021+02 │
│ 13982237 │ pressure │ 987.2261 │ hPa │ 2026-10-04 02:39:01.021+02 │
│ 13982237 │ temperature │ 17.02 │ celsius │ 2026-10-04 02:39:01.021+02 │
│ 3047120 │ humidity │ 81.3 │ percent │ 2026-10-04 02:36:47.537+02 │
│ 3047120 │ pm10 │ 22.1 │ ug/m3 │ 2026-10-04 02:36:47.537+02 │
│ 3047120 │ pm2_5 │ 11.85 │ ug/m3 │ 2026-10-04 02:36:47.537+02 │
│ 3047120 │ temperature │ 12.4 │ celsius │ 2026-10-04 02:36:47.537+02 │
│ 8811904 │ humidity │ 48.9 │ percent │ 2026-10-04 02:36:47.788+02 │
│ 8811904 │ pm10 │ 5.61 │ ug/m3 │ 2026-10-04 02:36:47.788+02 │
│ 8811904 │ pm2_5 │ 5.02 │ ug/m3 │ 2026-10-04 02:36:47.788+02 │
│ 8811904 │ temperature │ 21.06 │ celsius │ 2026-10-04 02:36:47.788+02 │
└───────────┴─────────────┴──────────┴─────────┴────────────────────────────┘
-- back to one row per post (wide), e.g. for a spreadsheet
PIVOT (SELECT event_time, metric_name, metric_value FROM air.sensors.readings WHERE sensor_id = '13982237')
ON metric_name IN ('pm10', 'pm2_5', 'temperature', 'humidity', 'pressure') USING first(metric_value)
ORDER BY event_time;
┌────────────────────────────┬────────┬────────┬─────────────┬──────────┬──────────┐
│ event_time │ pm10 │ pm2_5 │ temperature │ humidity │ pressure │
├────────────────────────────┼────────┼────────┼─────────────┼──────────┼──────────┤
│ 2026-10-04 02:36:47.315+02 │ 14.73 │ 6.2 │ 17.84 │ 63.21 │ 987.1244 │
│ 2026-10-04 02:37:00.013+02 │ 16.02 │ 6.91 │ 17.71 │ 63.8 │ 987.141 │
│ 2026-10-04 02:37:30.257+02 │ 18.44 │ 7.73 │ 17.55 │ 64.42 │ 987.1692 │
│ 2026-10-04 02:38:00.496+02 │ 21.1 │ 8.95 │ 17.38 │ 65.1 │ 987.1935 │
│ 2026-10-04 02:38:30.718+02 │ 19.37 │ 8.12 │ 17.2 │ 65.77 │ 987.2103 │
│ 2026-10-04 02:39:01.021+02 │ 15.86 │ 6.48 │ 17.02 │ 66.31 │ 987.2261 │
└────────────────────────────┴────────┴────────┴─────────────┴──────────┴──────────┘
-- PM2.5 means per 5 minutes and sensor (use 1 HOUR or 1 DAY for longer series)
SELECT time_bucket(INTERVAL 5 MINUTE, event_time) AS bucket, sensor_id, round(avg(metric_value), 2) AS pm2_5, count(*) AS n
FROM air.sensors.readings WHERE metric_name = 'pm2_5' GROUP BY ALL ORDER BY bucket, sensor_id;
┌──────────────────────────┬───────────┬────────┬───────┐
│ bucket │ sensor_id │ pm2_5 │ n │
├──────────────────────────┼───────────┼────────┼───────┤
│ 2026-10-04 02:35:00+02 │ 13982237 │ 7.4 │ 6 │
│ 2026-10-04 02:35:00+02 │ 3047120 │ 11.85 │ 1 │
│ 2026-10-04 02:35:00+02 │ 8811904 │ 5.02 │ 1 │
└──────────────────────────┴───────────┴────────┴───────┘
If DuckDB is not installed on your machine, the service can run the same SQL for you, in UTC, up to 2,000 rows:
$ lhbox query "SELECT sensor_id, round(avg(metric_value), 2) AS pm2_5_mean, count(*) AS readings FROM air.sensors.readings WHERE metric_name = 'pm2_5' GROUP BY sensor_id ORDER BY sensor_id"
sensor_id pm2_5_mean readings
--------- ---------- --------
13982237 7.4 6
3047120 11.85 1
8811904 5.02 1
3 rows · 165 ms
The table is Apache Iceberg with Parquet files, so pandas, Polars, Spark and other tools read it too: see engines.
Limits and gotchas
- Plain HTTP on port 80, to
in.lakehousebox.com. As explained in step 3, the ESP8266's TLS settings do not fit our server. The token crosses the internet unencrypted, as with every plain-HTTP upload. Someone on the network path could post readings into this one sink, and nowhere else. If that happens,lhbox sink device-tokenreplaces the URL. - No time in the post.
event_timeis the arrival time, so a post delayed by the network is stamped late. With the firmware's default measuring interval of 145 s, that is the resolution you get. - Rate: 6 posts a minute per sink sustained, bursts of 30. One sensor at the default 145 s posts well under 1 a minute. Several sensors can share one sink, since
sensor_idtells them apart, while the sink's total stays under 6 a minute. - One custom API slot per sensor. Your uploads to sensor.community, Madavi and openSenseMap are separate checkboxes and keep working.
- The URL is 44 characters (
in.lakehousebox.complus/d/and 22 characters). The firmware's Server and Path fields take up to 99 characters each. - Commit delay. Rows appear at the sink's roll, every 5 minutes by default (
--roll-seconds, at least 60). - Volume. An SDS011 + BME280 kit makes 10 rows a post, about 6,000 rows a day at 145 s. The free plan's 5 GB of storage and 10 device sinks apply;
lhbox whoamishows your limits. - No history from sensor.community. The sink receives what the sensor posts from now on. Loading the archive (archive.sensor.community's CSV files) is not covered here.
Next
- Ingest: sinks, the device route and the mapping format, if you want your own columns.
- DuckDB: connecting, writing, persistent secrets.
- Public catalogs: to publish your sensor's history for anyone to read.