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 shown
  • For queries on your machine, DuckDB 1.5.5 or newer (the run used 1.5.6). lhbox query works 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 from esp8266id, the number the firmware also sends as X-Sensor: esp8266-<id>.
  • sensor_type: the hardware: SDS011, SPS30, PMS, HPM, NPM, IPS, PPD42NS, BME280, BMP280, BMP180, DHT22, HTU21D, SHT3X, SCD30, DS18B20, DNMS, GPS, or device for 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-token replaces the URL.
  • No time in the post. event_time is 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_id tells 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.com plus /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 whoami shows 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.
Followed this guide and something did not work, or your setup differs? Write to hello@lakehousebox.com; we fix the guide, not just the reply.