Firmware for the senseBox:home v2: an environmental measurement station based on the senseBox MCU S2 (ESP32-S2).
There is one firmware for all stations. Which sensors are connected, how often they measure and where the data goes are set in a configuration stored on the box, not compiled into the firmware. Switching a sensor (e.g. HDC1080 → AM2315C) or a data target means changing the config, not flashing a different firmware.
- Config generator: sensebox.github.io/sensebox-home-v2. Creates the config from a template (sensors, LoRaWAN keys, channels) as ready-to-paste commands for the Arduino IDE serial monitor and generates the matching TTN payload formatter.
- Firmware files: Releases.
latestis rebuilt frommainon every push; versioned releases (v…) are the ones for stations in the field.sensebox-home-v2_<version>_mcu_s2.binis the OTA image for the firmware update in service mode.
The smallest setup: one HDC1080 on the MCU S2, no WiFi, no LoRaWAN. The box measures and shows the values on the serial console, and writes them to the SD card.
You need:
- senseBox MCU S2 with USB cable
- HDC1080 temperature/humidity sensor on a QWIIC/I2C port
- microSD card (at least 4 GB, FAT32) in the MCU S2. It is part of the hardware: without it the box still measures, but reports an
sderror and the LED blinks purple. - PlatformIO (CLI or the VS Code extension)
1. Build and flash the development firmware. The dev build logs every single measurement; the normal build only logs the averages that get uploaded.
pio run -e mcu_s2_dev -t upload
pio device monitorIf the upload doesn't find the board: hold the BOOT button, press and release RESET, release BOOT, and upload again.
2. Set the config. Paste this as one line into the serial monitor and press Enter:
config-set {"schemaVersion":1,"station":{"name":"my-test-box"},"sensors":[{"slot":0,"type":"hdc1080","intervalSec":60}]}
Answer: ok: config stored, reboot to apply. Then:
reboot
3. Watch the values. After the restart the log shows a measurement every 60 seconds:
[ 61] D data slot 0 air_temperature raw 23.41 °C q=0x040
[ 61] D data slot 0 relative_humidity raw 56.20 % q=0x040
q=0x040 is the quality flag "time not synced" (see Quality flags). Without LoRaWAN the box has no time source, so timestamps are uptime seconds.
4. Check the status:
status
The sensor 0 hdc1080 ready ... line shows the sensor state, the number of readings and errors. Unplug the sensor to see it go to error with a retry countdown, and recover by itself when you plug it back in.
Next step: LoRaWAN upload, see Adding LoRaWAN.
Open the serial monitor at 115200 baud (pio device monitor). Commands are one line each, terminated with Enter. help lists them.
| Command | What it does |
|---|---|
help |
List the commands. |
status |
Firmware version, normal/safe/service mode, uptime, reset reason, time sync, memory, the health of every component, every sensor (state, readings, errors, retry countdown) and LoRaWAN (joined, AppKey set/missing, uplinks, downlinks, spreading factor, RSSI/SNR, remaining airtime budget). |
config |
Print the stored config as JSON. Secrets (the AppKey) are never included. If it differs from the running config, a note says a reboot is pending. |
config-set <json> |
Replace the whole config with a JSON document on one line (see Configuration). It is validated first; if anything is wrong you get error: ... and nothing is stored. |
config-set name <name> |
Set the station name (max. 31 characters, spaces allowed). |
config-set uplinks devEui <hex> |
Set the LoRaWAN DevEUI: 16 hex digits, MSB first, exactly as shown in the TTN console. Upper/lower case and separators (70 B3 D5 …, 70:B3:…) are accepted. Needs a lorawan-cayenne or lorawan-compact uplink in the config. |
config-set uplinks joinEui <hex> |
Set the LoRaWAN JoinEUI (also accepted: appEui). Same format as the DevEUI. |
appkey <hex> |
Store the LoRaWAN AppKey: 32 hex digits, MSB first, as in the TTN console. It is stored separately from the config and never printed; status only shows set or missing. |
service-password <password> |
Set the WiFi password of service mode (8–63 characters; default 123sensebox). Stored like the AppKey and never printed by status or config. |
log |
Print the end of the log (about the last 40 lines) from the current log file on the SD card. The complete log is on the card (see SD card). In service mode, or if the card is unavailable, only the newest 16 lines kept in RAM. |
service |
Restart into service mode, like holding BOOT for 3 s. Not available while a fresh firmware update is being checked. |
reboot |
Restart the box into normal operation. Also ends safe mode and service mode. |
Notes:
config-setandappkeytake effect afterreboot. Severalconfig-setcommands before one reboot add up: each edit starts from the stored config.- Field names are case-insensitive (
devEUI=devEui). - Lines can be up to 2047 characters (a complete config is about 1000). If a pasted line arrives incomplete, the console says so and doesn't run it.
- The console also works in service mode, e.g. to change settings or to
reboot. There,statusprints the status saved before service mode.
[ uptime s] L tag message
[ 600] I uplink queued 15 bytes, time sync requested
L is the level: E error, W warning, I info, D debug. Debug lines only appear in the dev build (mcu_s2_dev).
| LED | Meaning |
|---|---|
| 🟢 - green | running normal operation |
| ⚪ - blink white | starting... (connecting, setting up sensors) |
| 🟡 - blink yellow | couldn't connect |
| 🔴 - blink red | couldn't start or read one or more sensors |
| 🟣 - blink purple | couldn't read/write the SD card |
| 🔴 - red | critical error, normal operation is halted: the stored config was rejected, or safe mode |
| 🔵 - blue | service mode |
If the box crashes (panic, watchdog, brownout) more than 5 times in a row without running normally for 10 minutes, it starts in safe mode (steady red). Sensors and LoRaWAN stay off and only the console runs, so a broken config can't keep the box in a reboot loop. Service mode can be started from safe mode with the BOOT button.
Service mode opens a WiFi access point with a web page for checking and setting up the box from a phone or laptop, without a USB cable.
The MCU S2 has no PSRAM, and WiFi needs a large part of the RAM. So service mode replaces normal operation: while it runs, nothing is measured and nothing is sent over LoRaWAN. Before switching, the box saves its current status, and the web page shows that saved status.
Start:
- Hold the BOOT button for 3 seconds while the box is running. The LED turns blue.
- Release the button. The box saves its status and restarts into service mode.
Or type service on the serial console.
Press BOOT only after the box has started. Held during power-on or reset, it puts the chip into flashing mode instead.
Connect:
- Join the WiFi network
senseBox-…(the serial number, or the last 6 digits of the MAC address). The password is123sensebox, unless a different one was set withservice-passwordon the serial console. The serial console also shows the network name and password when service mode starts (*** service mode: WiFi "…", password "…" ***). - The web page usually opens by itself; otherwise open
http://192.168.4.1/.
The web page has, from top to bottom:
- Service mode: the time until service mode closes, the Restart into normal mode button, and a warning while the default WiFi password is in use.
- Status (expandable): the status saved before service mode (overview, sensors, last measured values, LoRaWAN, components). It's marked saved, not live.
- Logs (expandable):
- Most recent logs: the last lines logged before service mode, with a Download button.
- Log files on the SD card: list the newest 40 and download them. The card is read only when you press Show log files, and the newest file is marked.
- Measurements (expandable): the measurement files (CSV) on the SD card, listed and downloaded the same way (Show measurement files).
- Settings (expandable):
- station name, DevEUI, JoinEUI and AppKey (the AppKey can only be written, never read)
- the service WiFi password (write-only)
- the complete config as JSON, to edit
- Firmware update, see Firmware update (OTA).
Changes are stored immediately and take effect after the restart.
Leave: any restart ends service mode:
- the restart button on the web page
rebooton the serial console- unplugging the power
- automatically after
service.apTimeoutMinminutes (default 15) without page requests and without a connected device - automatically
service.idleExitSecseconds (default 60) after the last device left the network. Started with the button, this only counts once a device has joined.
Pressing BOOT again does nothing in service mode.
The web page uses a small REST API, which tools can use too:
| Request | What it does |
|---|---|
GET /api/status |
service-mode info (network, clients, time until timeout, memory) and the saved status |
GET /api/log |
the log lines saved before service mode (before) |
GET /api/config |
stored config (no secrets), whether the AppKey is set, whether a restart is pending |
POST /api/config |
store a complete config (JSON body) |
POST /api/config/field |
{"field": "name" | "uplinks.devEui" | "uplinks.joinEui", "value": "…"} |
POST /api/appkey |
{"appKey": "<32 hex digits>"} |
POST /api/service-password |
{"password": "..."} (8–63 characters; write-only, applies after the restart) |
POST /api/reboot |
restart into normal operation |
POST /api/ota |
firmware update: the .bin file as multipart/form-data, e.g. curl -F firmware=@firmware.bin http://192.168.4.1/api/ota |
GET /api/sdlogs[?before=B000042-0001.log] |
log files on the SD card, newest first, 40 per page (name, size, last write mtime in UTC or 0 if unknown), card size and free space; more says whether older files are left, the next page is ?before=<oldest name shown> |
GET /api/sdlogs/file?name=B000042-0001.log |
download one log file |
GET /api/sddata[?before=B000042-0001.csv] |
measurement files (CSV) on the SD card, same answer as /api/sdlogs |
GET /api/sddata/file?name=B000042-0001.csv |
download one measurement file |
The SD card is not in use in service mode. Only during one of the /api/sdlogs or /api/sddata requests is it mounted, and it is unmounted right after: it needs about 5–9 KB of RAM, which WiFi needs otherwise. With less than 16 KB free the request is refused (not enough memory).
All answers are JSON; errors come as {"ok": false, "error": "…"}.
The firmware can be updated over WiFi in service mode, without a USB cable. Settings, keys and the data on the SD card are kept.
- Build the firmware:
pio run -e mcu_s2. The file is.pio/build/mcu_s2/firmware.bin. - Open service mode, go to Firmware update, choose the file and press Install.
The box writes the new firmware into its second firmware slot while it uploads. The old firmware stays untouched in the other slot.
Checked before switching:
- Is it firmware for this board? It must be an ESP32-S2 image.
- Is it this firmware? Every senseBox:home v2 build contains a marker,
@@SBH2-IMAGE@@ fw=<version> hw=<revisions>;. A Blockly sketch or another program is refused. - Does it fit the hardware revision? The board's revision comes from its device identity; every revision is accepted while none is provisioned. The build flag
SBH_HW_COMPATsets the list, default*= any. - Is the image complete and undamaged? Checked via its checksum and hash.
If any check fails, the update is refused with a message and nothing changes.
After the upload the box restarts into the new firmware, which first has to prove itself. It is kept once it has run normally for 2 minutes. The box goes back to the previous firmware by itself if, before that:
- it crashes or hangs (watchdog),
- it rejects the configuration (e.g. a failed migration), or
- it is restarted in any way.
During these 2 minutes status shows NEW FIRMWARE: self-test running, and neither the BOOT button nor service opens service mode, because the restart would roll the update back. After a rollback, status and the web page say that an earlier update was rolled back.
The config is a JSON document stored in the box's flash memory. It survives firmware updates. When the format changes in a future version, an old config is migrated automatically (schemaVersion).
Set it with config-set <json> (one line) or field by field (see Serial console), and read it back with config. Ready-made configs for common setups are in config/examples/.
All sections are optional; missing fields use the defaults below. Unknown fields are rejected, so typos don't go unnoticed.
{
"schemaVersion": 1,
"station": { "name": "Olpe-017", "timezone": "Europe/Berlin" },
"sensors": [
{ "slot": 0, "type": "am2315c", "intervalSec": 60,
"calibration": { "air_temperature": { "gain": 1.0, "offset": -0.3 } } },
{ "slot": 1, "type": "sps30", "intervalSec": 60, "fanCleaningIntervalDays": 4 }
],
"uplinks": [
{ "type": "lorawan-compact", "devEui": "70B3D57ED0001234", "joinEui": "0000000000000000",
"intervalSec": 600, "timeSyncIntervalSec": 259200,
"channels": [
{ "slot": 0, "phenomenon": "air_temperature" },
{ "slot": 0, "phenomenon": "relative_humidity" },
{ "slot": 1, "phenomenon": "pm2_5" },
{ "slot": 1, "phenomenon": "pm10" },
{ "slot": 1, "phenomenon": "pm1" },
{ "slot": 1, "phenomenon": "pm4" }
] }
],
"storage": { "lowWatermarkPct": 10, "highWatermarkPct": 20, "minRetentionDays": 30 },
"service": { "apTimeoutMin": 15, "idleExitSec": 60 },
"deployment": { "lat": 51.03, "lon": 7.85, "heightAboveGround": 2.5 }
}| Field | Required | Description |
|---|---|---|
schemaVersion |
yes | Version of the config format. Currently 1. |
station |
no | Station details, see below. |
sensors |
no | List of connected sensors (max. 8). Empty = the box measures nothing. |
uplinks |
no | List of data targets (max. 2). Empty = nothing is sent; the box only measures. |
storage |
no | SD card settings. |
service |
no | Service mode settings. |
deployment |
no | Free JSON object for installation metadata (location, mounting height, shield, …), max. 511 characters. Stored and exported, not interpreted by the firmware. |
| Field | Default | Description |
|---|---|---|
name |
"" |
Station name, max. 31 characters. |
timezone |
"Europe/Berlin" |
Only for display. The firmware works in UTC internally. |
| Field | Default | Description |
|---|---|---|
slot |
required | Number of this sensor, 0–7, unique. Uplink channels refer to sensors by slot. |
type |
required | Driver: hdc1080, am2315c or sps30 (see table below). An unknown type shows up as an ERROR sensor in status. |
enabled |
true |
false keeps the sensor in the config but doesn't start it. |
intervalSec |
60 |
Measurement interval in seconds, 1–86400. Once the clock is synced, measurements run on the UTC clock (60 → every full minute) if the interval divides a day. |
calibration |
none | Per phenomenon: { "<phenomenon>": { "gain": 1.0, "offset": 0.0 } }. Produces an additional corrected value raw × gain + offset; the raw value is kept. If present, averages and uploads use the corrected value. Max. 4 entries. |
| other numbers | Driver-specific parameters, e.g. fanCleaningIntervalDays for the SPS30, and quality check overrides like rh.stuckCount. Max. 8 per sensor. |
Sensor types:
type |
Sensor | I2C address | Phenomena | Parameters |
|---|---|---|---|---|
hdc1080 |
TI HDC1080 (senseBox temperature/humidity board) | 0x40 |
air_temperature, relative_humidity |
none |
am2315c |
Adafruit AM2315C (encased AHT20 probe) | 0x38 |
air_temperature, relative_humidity |
none |
sps30 |
Sensirion SPS30 particulate matter sensor | 0x69 |
pm1, pm2_5, pm4, pm10 |
fanCleaningIntervalDays (default 4): automatic fan cleaning interval |
All sensors use the QWIIC/I2C bus (SDA 39, SCL 40) at 100 kHz. The SPS30 needs 30 s to warm up after starting; values from that time are not used.
Phenomena (used in calibration and in uplink channels):
| Name | Unit |
|---|---|
air_temperature |
°C |
relative_humidity |
% |
pm1, pm2_5, pm4, pm10 |
µg/m³ |
Two types send averages over LoRaWAN (TTN). They differ only in the frame format:
| Type | Port | Format | TTN payload formatter | Olpe frame |
|---|---|---|---|---|
lorawan-cayenne |
1 | Cayenne LPP: channel + type + value per value | built-in CayenneLPP | 23 bytes, no timestamp |
lorawan-compact |
2 | fixed layout: timestamp + values, nothing else (see Compact frames) | custom, tools/ttn-uplink-formatter.js | 15 bytes incl. timestamp |
| Field | Default | Description |
|---|---|---|
type |
required | lorawan-cayenne, lorawan-compact (or none). |
devEui |
"" |
DevEUI from TTN: 16 hex digits, MSB first. Empty = LoRaWAN stays off. |
joinEui |
"" |
JoinEUI / AppEUI from TTN, same format. |
intervalSec |
600 |
Averaging window and send interval in seconds, 60–86400. Each window's average of every channel is sent once. Once the clock is synced, windows end on the UTC clock (600 → XX:00, XX:10, …) if the interval divides a day. |
timeSyncIntervalSec |
259200 (72 h) |
How often the box asks the network for the time (LoRaWAN DeviceTimeReq), 3600–2592000. It always asks after joining. |
channels |
none | Which values are sent, max. 16. See below. |
The AppKey is not part of the config: it's a secret and is set with appkey <hex>.
channels[]:
| Field | Description |
|---|---|
slot |
Sensor slot the value comes from; must be a configured sensor. |
phenomenon |
Which value of that sensor, e.g. pm2_5. |
ch |
lorawan-cayenne only: Cayenne channel number, 0–255, unique. Must match the openSenseMap decodeOptions. |
lpp |
lorawan-cayenne only: Cayenne type: temperature (0.1 °C), relative_humidity (0.5 %), analog_in (0.01, range ±327.67, used for PM), luminosity, barometric_pressure, digital_in. |
For lorawan-compact the order of channels is the frame layout; ch and lpp are rejected there.
How sending works:
- Windows end on full intervals of the UTC clock once the clock is synced, e.g. XX:10:00. The window is closed 5 s later, so the measurement taken exactly at XX:10:00 is part of it. Before the first time sync, windows run from the start of the box.
- Each box sends with its own fixed delay of 0–120 s after the window end (derived from the DevEUI, at most a quarter of the interval), so boxes of one deployment don't collide at the gateway. With
lorawan-compactthe frame still carries XX:10:00. - Values outside the encoding's range are clamped and flagged; e.g. with Cayenne, PM above 327.67 µg/m³ is sent as 327.67.
- A sensor without valid values in a window: Cayenne leaves its channels out of the frame (the openSenseMap then stores
0); compact sends a "missing" marker and the formatter leaves the field out. - Missed windows are not sent later (e.g. while not joined): the openSenseMap would timestamp them with the arrival time.
- TTN's fair-use limit of 30 s airtime per day is tracked. When the budget is used up (slow spreading factors, short intervals), windows are skipped and logged.
The frame has no channel or type bytes: the TTN formatter knows the layout. For the Olpe config that is 15 bytes instead of 23 (Cayenne without timestamp), and the measurement time is included.
| Bytes | Content | Encoding (big endian) | "Missing" marker |
|---|---|---|---|
| 0–3 | window end (e.g. XX:10:00 UTC) | Unix seconds, uint32; 0 = box has no time yet |
– |
then, per entry of channels, in config order: |
|||
| 2 | air_temperature |
int16, 0.01 °C (±327.67) |
0x8000 |
| 1 | relative_humidity |
uint8, 0.5 % |
0xFF |
| 2 | pm1, pm2_5, pm4, pm10 |
uint16, 0.1 µg/m³ (0–6553.4) |
0xFFFF |
Example (Olpe config): window end 2025-10-10 12:10:00 UTC, 22.86 °C, 60 %, PM2.5 12.3, PM10 15.0, PM1 8.1, PM4 13.2:
68 E8 F7 98 | 08 EE | 78 | 00 7B | 00 96 | 00 51 | 00 84
time temp rh pm2_5 pm10 pm1 pm4
Setting it up in TTN:
-
In the TTN console, open the application (or a single device) → Payload formatters → Uplink.
-
Formatter type Custom Javascript formatter, paste the content of tools/ttn-uplink-formatter.js, save.
-
Check that the
LAYOUTlist at the top of the formatter has the same phenomena in the same order aschannelsin the box config. For the Olpe config it already does. -
Test it under "Test" in the formatter page: FPort
2, byte payload68E8F79808EE78007B009600510084. The result must be:{ "unixtime": 1760098200, "measured_at": "2025-10-10T12:10:00.000Z", "temperature_1": 22.86, "relative_humidity_2": 60, "analog_in_3": 12.3, "analog_in_4": 15, "analog_in_5": 8.1, "analog_in_6": 13.2 }
measured_at is the time the averages belong to; TTN's received_at is a few seconds to two minutes later. Frames sent before the box's first time sync have no measured_at. A sensor without values in a window has no field.
The field names (temperature_1, analog_in_3, …) are chosen to match TTN's CayenneLPP formatter, so the openSenseMap cayenne-lpp profile keeps working with the same decodeOptions as a Cayenne box (it uses the arrival time, not measured_at). On the openSenseMap box, the TTN port must be empty or 2.
| Field | Default | Description |
|---|---|---|
lowWatermarkPct |
10 |
The card is never filled beyond 100 − this (default: 90 %). Below this much free space the oldest files are deleted, whatever their age… |
highWatermarkPct |
20 |
…until this much is free again (%). Must be above lowWatermarkPct. |
minRetentionDays |
30 |
Measurement files younger than this are not deleted for the cleanup from 90 % down to 80 %. It never lets the card go above 90 %. Applies only to files whose age is known (written after the first time sync). |
| Field | Default | Description |
|---|---|---|
apTimeoutMin |
15 |
Service mode ends (restart into normal operation) after this many minutes without page requests and without a connected device, 1–240. |
idleExitSec |
60 |
Service mode ends this many seconds after the last device left the network (counted once a device has joined), 0–3600 (0 = off). |
Every value carries a quality bitmask (q=0x... in the log). 0x000 means no remarks.
| Bit | Hex | Meaning | Excluded from averages |
|---|---|---|---|
| 0 | 0x001 |
outside the sensor's physical range | yes |
| 1 | 0x002 |
outside the plausible environmental range | yes |
| 2 | 0x004 |
spike: changed faster than plausible since the last value | yes |
| 3 | 0x008 |
stuck: identical value many times in a row | yes |
| 4 | 0x010 |
sensor still warming up | yes |
| 5 | 0x020 |
value obtained after a bus retry | no |
| 6 | 0x040 |
time not synced: timestamp is seconds since boot, not UTC | no |
| 7 | 0x080 |
average built from fewer samples than expected | no |
| 8 | 0x100 |
value clamped to fit the transmission format | no |
Bits 1–3 come from checks with default limits per phenomenon:
| Phenomenon | Plausible range | Spike limit | Stuck after | Saturation (never "stuck") |
|---|---|---|---|---|
air_temperature |
−40 … 60 °C | 5 °C/min | 30 identical values | ≤ −40, ≥ 60 |
relative_humidity |
0 … 100 % | 20 %/min | 30 identical values | ≤ 0.5, ≥ 98 (rain, fog) |
pm1, pm2_5, pm4, pm10 |
0 … 1000 µg/m³ | none | 30 identical values | ≤ 0.5 (clean air) |
Each sensor can override them with parameters named <prefix>.<field>. Prefixes: temp, rh, pm1, pm2_5, pm4, pm10. Fields: min, max, maxDelta (per minute, 0 = no spike check), stuckCount (0 = no stuck check), saturatedBelow, saturatedAbove.
{ "slot": 0, "type": "hdc1080", "rh.saturatedAbove": 95, "temp.stuckCount": 60 }The microSD card is a fixed part of the hardware (at least 4 GB). The box keeps its measurement history and its log on it. It is used in normal operation only, not in service or safe mode.
A missing or failing card is treated as a hardware fault: health shows an sd error and the LED blinks purple. Measuring and sending continue meanwhile, so nothing else stops because of the card.
Files: named by boot number and part, because after a power cut the box has no time until the first LoRaWAN time sync.
| File | Content |
|---|---|
/data/B000042-0001.csv |
measurements of boot 42 (part 1): every raw value, every corrected value and every 10-min mean |
/logs/B000042-0001.log |
log of boot 42, including the lines from before the card was mounted |
A new part starts after 4 MB (measurements) or 1 MB (log). A new boot never continues or overwrites an older file. The boot number is counted in the box's flash and printed in the first line of every file.
Measurement CSV:
# senseBox:home v2 firmware 0.1.0-dev, station "Olpe-017", boot 42
utc,uptime_s,slot,phenomenon,kind,value,unit,quality
,61,0,air_temperature,raw,23.41,°C,0x040
# time synced: uptime_s 245 of boot 42 = 2026-10-14T09:30:45Z
2026-10-14T09:31:01Z,,0,air_temperature,raw,23.40,°C,0x000
2026-10-14T09:40:00Z,,1,pm2_5,aggregated,4.12,µg/m³,0x000
- Before the first time sync,
utcis empty anduptime_sholds the seconds since boot; the# time syncedline converts them: UTC = sync time − (245 − uptime_s). kindisraw,corrected(calibration) oraggregated(10-min mean of the uplink window).qualityuses the quality flags.
Log lines: 2026-10-14T09:31:01Z [ 261] I uplink queued 15 bytes, with - instead of the date before the first time sync.
Buffering: lines are collected in RAM and written every 15 seconds (sooner when the buffer fills up), so a crash or power cut loses at most the last 15 seconds.
When the card is missing, fails or is removed:
- The box keeps measuring and sending.
- It unmounts the card, switches its power off and tries again after 2 s, then 30 s, 60 s, … up to every 10 minutes (a missing card at boot: first retry after 30 s).
- Measurements arriving meanwhile, also before the card was mounted the first time, are kept in RAM while they fit (about 30 lines), then counted as dropped.
- A reseated card is found by the same periodic check.
- A card smaller than 4 GB is used, but
statusand the log warn about it. statuson the console shows the card, the free space and the counters. Thesdline under health shows the last error.
Janitor (overflow protection):
- Logs: at most 10 % of the card, max. 100 MB. Above that the oldest log file is deleted.
- Never more than 90 % full (
lowWatermarkPct): above that, the oldest files are deleted, whatever their age. Old logs go first, then the oldest measurement files. - Cleanup down to 80 % (
highWatermarkPct): deleting continues until 20 % is free again, so it doesn't happen file by file. Measurement files younger thanminRetentionDaysare kept for this part. - Never deleted: the files currently being written, and files that don't follow the naming scheme (your own files are safe).
- Over 90 % anyway: this only happens if the files currently being written fill the card. Health then shows
card over 90 % fulland the LED blinks purple.
The check runs after mounting and every 10 minutes. Each deleted file is logged.
Card format: FAT32 (SDHC). 4 GB is plenty: about 0.6 MB of measurements per day for the Olpe setup, so the card holds years of history before the janitor starts deleting.
- In the TTN console, create an end device with these settings:
- Frequency plan: EU868
- LoRaWAN version: 1.0.3
- Activation: OTAA
- Set the TTN uplink payload formatter (application or device) to CayenneLPP for
lorawan-cayenne. Forlorawan-compactuse the custom formatter instead, see Compact frames. - Put the TTN keys into the box via the console:
config-set {"schemaVersion":1,"station":{"name":"my-test-box"},"sensors":[{"slot":0,"type":"hdc1080","intervalSec":60}],"uplinks":[{"type":"lorawan-cayenne","intervalSec":600,"channels":[{"ch":1,"slot":0,"phenomenon":"air_temperature","lpp":"temperature"},{"ch":2,"slot":0,"phenomenon":"relative_humidity","lpp":"relative_humidity"}]}]} config-set uplinks devEui <DevEUI from TTN> config-set uplinks joinEui <JoinEUI from TTN> appkey <AppKey from TTN> reboot - Check that it works:
- The log shows
joining,join request 1, SF7and thenjoined. If a request gets no answer (no answer to join request), the box tries again: SF7 to SF12, three requests each, with longer pauses at higher SF because of the 0.1 % duty cycle on the join channels (about 1 min after a SF7 request, about 25 min after a SF12 request). The LED blinks white during all of this and only switches to yellow when all 18 requests went unanswered (join failed on SF7-SF12, after about 2.5 h); the box then keeps trying at SF12. - After 10 minutes,
queued 7 bytes. - TTN shows
temperature_1andrelative_humidity_2.
- The log shows
To forward the data to the openSenseMap:
- In TTN, add a custom webhook: JSON format, base URL
https://ttn.opensensemap.org/v3, only "Uplink message" enabled. - On the openSenseMap box, set the TTN application ID and device ID, the decoding profile Cayenne LPP, and
decodeOptionsthat map each channel to a sensor title. For example:
[
{ "decoder": "temperature", "channel": 1, "sensor_title": "Temperatur" },
{ "decoder": "relative_humidity", "channel": 2, "sensor_title": "rel. Luftfeuchte" }
]decodeOptions is an openSenseMap setting, not part of the box config. It must use the same channel numbers as the box's channels.
PlatformIO environments:
| Env | Purpose |
|---|---|
mcu_s2 |
Release firmware. |
mcu_s2_dev |
Debug logging; uses config/olpe-template.json when no config is stored. |
native |
Unit tests on the PC: pio test -e native |
hwtest_wifi_ap |
Hardware test: minimal WiFi access point (hwtest/wifi_ap/main.cpp). |
Every device build ends with scripts/check_image.py, which fails the build if the OTA image marker or the rollback hook is missing from the image.
Firmware version: scripts/version.py sets SBH_FW_VERSION. Local builds report 0.1.0-dev unless you set it (SBH_FW_VERSION=0.2.0 pio run -e mcu_s2). CI builds use git describe: tag v0.2.0 → 0.2.0, later commits → 0.2.0-3-gabc1234, before the first tag → 0.1.0-dev-gabc1234.
CI (.github/workflows/ci.yml) runs on every pull request and push to main:
pio test -e native, the JS tests for the TTN formatters and the config generator templates (node --test 'tools/test/*.test.mjs') and a build of all device envs.- On
mainit also replaces thelatestpre-release and deploys the config generator (site/) to GitHub Pages.scripts/package_firmware.pynames the files. - To release, push a tag:
git tag v0.2.0 && git push origin v0.2.0.
Code layout and the rules for contributing are in implementation-docs/CLAUDE.md.