Logging Vivarium Temperature and Humidity with an SHT30 and Two Lambdas
A QT Py reads an SHT30 over I2C, posts JSON to a Krill DataPoint, and two tiny Python Lambdas fan that one snapshot out into a TempF DataPoint (with C→F conversion) and a Humidity DataPoint.
One Sensor, Two Numbers, Two Lambdas
The vivarium on pi-krill-05 needs two readings: temperature in Fahrenheit and relative humidity. The hardware is a single I2C sensor that gives me both at once, so the question becomes how to land them as two separate, charted, alertable DataPoints without writing any glue that has to know about Krill’s wire protocol.
The answer is the Lambda executor and its source/target wiring. One Lambda per output number. Each one is six lines of meaningful Python.
This post walks the whole tree top to bottom — the sensor, the QT Py reading it, the raw-JSON DataPoint that lands on the server, and the two Lambdas that split that JSON into clean numeric DataPoints I can graph and alert on.
The Hardware
- Adafruit SHT30 Sensirion temp/humidity breakout (#5064) — Sensirion’s SHT30-D in a STEMMA QT enclosure. ±2% RH, ±0.5 °C, 2.15–5.5 V, true I²C with onboard pull-ups. The wired/enclosed version Adafruit sells comes with a ~9-inch four-conductor cable already terminated, so the sensor itself can sit inside the vivarium and the electronics live outside.
- Adafruit QT Py RP2040 (#5405) — A thumbnail-sized RP2040 board with USB-C, a STEMMA QT port, and CircuitPython preinstalled. It plugs straight into the SHT30’s STEMMA connector — no soldering, no wiring diagram. The whole sensing rig is two parts and one cable.
- A Raspberry Pi running Krill Server — the QT Py shows up as a serial device on
/dev/serial/by-id/...and Krill polls it like any other serial device.
Why split the work between a QT Py and the Pi at all, instead of wiring the SHT30 directly to the Pi’s I²C header the way the CircuitPython-on-Pi guide does it? A few reasons that matter for a vivarium:
- The vivarium is on the other side of the room from the Pi. A 2 m USB cable is fine; a 2 m I²C cable is not.
- The QT Py is replaceable for $10 and survives condensation better than the Pi does.
- If the Pi reboots, the QT Py keeps sampling and the next read picks up.
The QT Py Sketch
The CircuitPython on the QT Py is intentionally dumb. It reads the sensor, prints one line of JSON to the USB serial console, and sleeps. No protocol, no framing — just JSON-per-line.
1
2
3
4
5
6
7
8
9
10
11
12
13
import time
import board
import busio
import adafruit_sht31d
i2c = busio.I2C(board.SCL, board.SDA)
sensor = adafruit_sht31d.SHT31D(i2c) # SHT30 is wire-compatible
while True:
print('{{"t_c":{:.2f},"rh":{:.2f},"addr":"0x44"}}'.format(
sensor.temperature, sensor.relative_humidity
))
time.sleep(2)
The point of emitting JSON instead of a raw 21.84,33.22 CSV line is that it survives growth. If I add a second sensor next year, I add a key. If I add an error case, I emit {"status":"no_i2c"} and downstream code already knows how to ignore it.
For more on the CircuitPython side of this — getting code.py onto the board, libraries, the editor workflow — see the Using Sensors with CircuitPython post.
The Krill Tree
Here is the actual node graph as it lives in the Vivarium Project on pi-krill-05:
graph TD
QTPY[QT Py RP2040<br/>USB-Serial] -->|JSON per line| SHT30
SHT30[DataPoint: SHT30<br/>type=JSON<br/>value={t_c, rh, addr}]
SHT30 --> EXEC[Executor: SHT30 Lambdas]
EXEC --> L1[Lambda: TempF-from-SHT30<br/>vivarium_sht30_to_tempf.py]
EXEC --> L2[Lambda: Humidity-from-SHT30<br/>vivarium_sht30_to_humidity.py]
L1 -->|target| TEMPF[DataPoint: TempF<br/>type=DOUBLE °F]
L2 -->|target| HUM[DataPoint: Humidity<br/>type=DOUBLE %]
Three DataPoints, one Executor, two Lambdas. Five user-visible nodes for one physical sensor.
The Source DataPoint: SHT30
This one is configured as a JSON DataPoint — its job is to hold whatever the QT Py wrote most recently. A representative snapshot value:
1
{"t_c":20.51,"rh":40.19,"addr":"0x44"}
The SHT30 DataPoint has manualEntry: false and maxAge: 3600000 (1 hour) — meaning Krill will mark it stale if no new reading lands within an hour, which is what we want for an “is the sensor still alive” check. We don’t graph this DataPoint directly; it’s a transport buffer.
The Executor and Its Two Lambdas
The Executor node groups the two Lambdas under one parent so they share lifecycle. Each Lambda has its executionSource set to SOURCE_VALUE_MODIFIED, which means: every time the SHT30 DataPoint’s snapshot changes, both Lambdas run. There’s no Cron Timer involved — the QT Py’s pace is the pipeline’s pace.
This is the bit that took me the longest to internalize when I first started using Krill: a Lambda’s sources list is what the runtime hands you as argv[1], and its targets list is what stdout goes to. That’s the whole API. You don’t import a Krill SDK from inside your script. You don’t authenticate anywhere. You read JSON in, print a value out, and the framework does the routing.
The Two Lambdas (in full)
These run on the Pi as /opt/krill/lambdas/vivarium_sht30_to_tempf.py and vivarium_sht30_to_humidity.py. They are the entire conversion logic.
vivarium_sht30_to_tempf.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env python3
"""Reads the SHT30 source-Node JSON from argv[1], pulls meta.snapshot.value
(itself a JSON string from the QT Py like {"t_c":..,"rh":..,"addr":".."}),
converts t_c to Fahrenheit, and prints the result. No-ops cleanly when the
inner payload has no t_c (e.g. {"status":"no_i2c"})."""
import sys
import json
def main() -> int:
try:
node = json.loads(sys.argv[1])
raw = node.get("meta", {}).get("snapshot", {}).get("value", "")
if not raw:
return 0
payload = json.loads(raw)
except (IndexError, ValueError) as e:
sys.stderr.write(f"bad input: {e}")
return 1
t_c = payload.get("t_c")
if t_c is None:
return 0
print(t_c * 9.0 / 5.0 + 32.0)
return 0
if __name__ == "__main__":
sys.exit(main())
vivarium_sht30_to_humidity.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env python3
"""Reads the SHT30 source-Node JSON from argv[1], pulls meta.snapshot.value
(itself a JSON string from the QT Py like {"t_c":..,"rh":..,"addr":".."}),
and prints the raw rh value. No-ops cleanly when the inner payload has no rh
(e.g. {"status":"no_i2c"}) so junk doesn't land in the Humidity DataPoint."""
import sys
import json
def main() -> int:
try:
node = json.loads(sys.argv[1])
raw = node.get("meta", {}).get("snapshot", {}).get("value", "")
if not raw:
return 0
payload = json.loads(raw)
except (IndexError, ValueError) as e:
sys.stderr.write(f"bad input: {e}")
return 1
rh = payload.get("rh")
if rh is None:
return 0
print(rh)
return 0
if __name__ == "__main__":
sys.exit(main())
Sources and Targets: the Mental Model
Every Lambda you write in Krill follows the same shape, and once you internalize it the rest of the platform stops feeling like magic.
- Sources are the nodes the Lambda reads. The Krill runtime serializes each source’s full Node JSON and hands it to your script as
sys.argv[1..n]— including its parent, type, current snapshot, and meta. You don’t have to make any HTTP calls to fetch values; they’re already in the argv your script wakes up holding. - Targets are the nodes the Lambda writes to. Whatever your script prints to
stdoutis captured, validated against the target DataPoint’sdataType(DOUBLE has to parse, DIGITAL has to be 0/1, etc.), and recorded as the new snapshot value. - Print nothing → write nothing. The Lambdas above use this on purpose — if the inner JSON is
{"status":"no_i2c"}and there’s not_ckey, the script returns early without printing. The TempF DataPoint’s snapshot is left untouched, so the chart shows a flat line during the gap rather than a fake32.0(a missingt_cblindly multiplied through9/5 + 32).
This is why two Lambdas, not one is the right factoring here. I could write a single Lambda that takes two targets and computes both numbers, but then I’d have to invent a way to map “first stdout line goes to TempF, second to Humidity,” and that ordering is nowhere in the Lambda contract. With one Lambda per number, the contract is the obvious one: one source in, one number out.
Why Convert in a Lambda Instead of a Calculation Executor?
Krill ships a Calculation executor that does formula-based math without any Python at all, and t_c * 9/5 + 32 is exactly the kind of thing Calculation was made for. So why use Lambda?
Because the source DataPoint is JSON, not DOUBLE. The Calculation executor expects to do arithmetic on numeric DataPoint values; it doesn’t know how to fish a key out of a JSON blob first. The “extract a field, then transform it” shape is what bumps this from Calculation into Lambda.
If the QT Py were emitting only the temperature on its own DataPoint, I’d absolutely use a Calculation node — that’s the right tool for that job. The reason there’s a JSON DataPoint in the middle is that I want one snapshot from the device per cycle, not two independent snapshots that might drift apart by half a sample interval.
What I Get on the Other Side
Both TempF and Humidity are configured as DOUBLE DataPoints with precision: 1 and units °F and %. That means:
- They show up cleanly in the project dashboard —
68.9 °Fand40.1 %instead of68.92and40.19. - They can be wired to High Threshold and Low Threshold triggers — I have a Low Threshold on Humidity that fires an SMTP alert if the misters fail and RH drops below 50%.
- They graph in the standard DataPoint chart, with smooth interpolation that the JSON DataPoint can’t get because
JSONisn’t on the numeric chart axis.
The SHT30 DataPoint is essentially private plumbing. The two DOUBLE DataPoints are what the rest of the swarm interacts with.
Adding a Third Output Later
Suppose I want a dew point DataPoint too. The recipe is:
- Create a new
DOUBLEDataPoint namedDewPointFunder the Vivarium project. - Create a third Lambda under the SHT30 Lambdas Executor with the SHT30 DataPoint as its source and the new DewPointF as its target.
- Drop a
vivarium_sht30_to_dewpointf.pyinto/opt/krill/lambdas/that readst_candrh, runs the Magnus formula, prints a single Fahrenheit number, and returns 0.
No changes to the QT Py. No changes to the existing Lambdas. No changes to the dashboard or alerts. The fan-out pattern composes — every additional derived value is one DataPoint, one Lambda, one short Python file, and the existing data flow keeps working unchanged.
Related
- Data Points — the unit of state in Krill, including the JSON / DOUBLE / DIGITAL data types this post leans on
- Lambda Executors — the full Lambda contract: sources, targets, stdin/stdout, sandboxing
- Using Sensors with CircuitPython — getting CircuitPython onto a QT Py / RP2040 board
- CircuitPython Sensors via Lambda — the alternative path: sensor wired straight to the Pi’s I²C header, no QT Py
- Calculation Executor — the lighter-weight alternative when your inputs are already numeric
- Serial Devices — how the QT Py shows up to the Pi
- High Threshold Trigger / Low Threshold Trigger — wire alerts onto the derived DataPoints
Last verified: 2026-04-28 on pi-krill-05.local