
Qym solder
basson/qym-solder
QymSolder - Soldering station three in one - hakko t12, hot air gun, d909/898D or analog.

Controller for Brew-in-a-Bag Electric Kettle
Bibby pi hat is an open-source printed circuit board by austingrossman, from the austingrossman/bibby repository on GitHub. It is a 2-layer board of 65 × 56 mm with 21 parts on 48 nets, routed with 135 tracks and 17 vias.
It is built around the H11AA1. Its parts include 7 resistors, 3 capacitors, 2 ICs and 2 connectors.
bibby is a temperature and power controller for electric brew-in-a-bag (BIAB)
brewing, running on a Raspberry Pi 5. It reads kettle temperature from a PT100
RTD, drives two mains heating elements through solid-state relays (SSRs), and
presents a touch UI for setting and tracking the brew temperature. Power is
delivered as a duty cycle synchronized to the AC mains zero crossing, and the
whole system is built so that any fault, crash, or hang leaves the heaters
off.
This README is meant to let someone unfamiliar with the project understand it, reproduce the hardware, build the software, and calibrate it for their own kettle. It moves from the big picture down to the details: architecture, then hardware, then software, then build/bring-up, then calibration.
This project is for fun. I am doing it to improve my home brewing set up. I hope that someone sees inspiration in this project and does something similar. I am doing this with a couple specific goals in mind:
The controller is a single process with three threads sharing one in-memory state block of C11 atomics (the optional web interface adds a few more, none of them on the control path — §2.9):
+----------------------------------------------------------------+
| bibby (one process) |
| |
| main/UI thread sampler thread SSR thread |
| --------------- ------------------ ------------- |
| LVGL touch UI on 60 Hz, DRDY-paced: SCHED_FIFO, |
| /dev/fb0 (rotated) - MAX31865 read ZC-paced: |
| - kettle readout - temp filter - zero-cross |
| - charts, slider - PID (watts) capture |
| - fault band - power split - sigma-delta |
| | - CSV logging SSR firing |
| | - batch-size estimator - watchdogs |
| v | | |
| +------------------ BibbyState (C11 atomics) -------+ |
| duties, temps, modes, faults, history |
+----------------------------------------------------------------+
| SPI0 + DRDY (GPIO16) | GPIO20 ZC | GPIO21/26
v | v
+-------------+ +-------------+ +-----------+
| MAX31865 | | zero-cross | | SSR1 |
| + PT100 | | detector | | SSR2 |
+-------------+ +-------------+ +-----------+
^ ^ |
| RTD | AC mains | switched mains
kettle probe line sense to heating elementsSCHED_FIFO) so a GUI stall can never delay the zero-cross response or the
watchdogs.The heating elements are resistive loads switched by zero-cross SSRs. Switching
only at the AC zero crossing eliminates the inrush/EMI of phase-angle control.
Power is modulated by choosing which mains half-cycles to pass — a duty cycle
in [0, 1]. A sigma-delta modulator (§2.3) spreads the fired cycles out evenly
so the average power tracks the command with minimal low-frequency flicker.
The control law works in watts: the PID outputs a total power demand, and a
splitter turns watts into per-element duties using the configured element
ratings. This makes the tuned gains independent of which elements are fitted —
swap elements, update bibby.ini, and the loop still commands the same power
per degree of error. It also enables the equal-flux split and anti-scorch cap
(§2.4), which need to know real watts per cm².
src/state.{h,c}BibbyState is a single struct of C11 atomics shared by the three threads —
no locks on the hot paths. Key fields:
| Field | Writer | Purpose |
|---|---|---|
duty1, duty2 | sampler | per-element duty commands in [0,1] |
p_demand_w | sampler | commanded power — also the delivered average power (§2.3) |
temp_raw_c, temp_filt_c, temp_valid | sampler | latest sensor sample and validity |
setpoint_c, manual_mode, manual_power_w, grain_in | UI | user intent |
simulate_zc | main, at startup | bench zero-cross simulation (mains.simulate_zc / --sim-zc); never changes while running |
output1, output2, zc_count, watchdog_alarm | SSR thread | fired state, ZC counter, ZC watchdog |
control_heartbeat | sampler | bumped every loop pass; the SSR thread's staleness watchdog watches it |
rtd_fault, rtd_unresponsive, fault_forced_manual | sampler | sensor-fault state |
m_est_l, adaptive_scale | sampler | batch-size estimate (litres) and gain scale |
running | main | global shutdown flag |
A mutex-guarded ring buffer of HistPoints (one per 0.5 s) feeds the UI
charts; it is the only locked structure, and only the UI and the sampler's
0.5 s slow tick touch it.
src/sampler_thread.cPaced by the MAX31865 DRDY edge (one conversion per mains cycle, so ~60 Hz):
duty1/duty2.src/ssr_thread.c, src/sigma_delta.hRuns at SCHED_FIFO (real-time) priority, pinned to the AC zero crossing:
watchdog_alarm set. A lost mains-sense signal must not leave a relay
latched on. simulate_zc substitutes the timeout tick for the edge so the
modulator can be exercised with no mains connected. It is set once at
startup from mains.simulate_zc (or --sim-zc) and there is deliberately
no control for it on the panel: while it is on this watchdog can never
trip, which is a commissioning decision, not something a stray tap during a
brew should be able to do. Startup logs a warning line whenever it is on.control_heartbeat every
pass; if it stops advancing for ~2 s worth of zero crossings
(2 × mains_Hz), duties are forced to zero — a wedged control loop must not
leave the heaters running. (BIBBY_TEST_WEDGE=<sec> wedges the sampler once,
5 s in, to demonstrate exactly this on the bench.)src/control.c, src/power_split.cPID (watts). p = kp·e, integrator accumulates raw per-sample error
(ki is W/°C per sample), derivative is a raw per-sample delta. Output is
clamp(ff + p + i + d, 0, max_power) in watts. Anti-windup is conditional
integration: the integrator holds whenever the non-integral output is already
against a rail and the error would push further into it; a backstop clamps the
integral term to full output authority (max_power / ki).
Integral clamp (pid.i_clamp_w, watts; 0 = off). On top of that, the
integral term is bounded to ±i_clamp_w — the accumulator itself is held to
i_clamp_w / ki, every step, whether or not the integrator was allowed to
move. It is a statement of how much authority the integrator may ever have:
with the feedforward carrying the holding power, all the integrator has to
trim is model error, so it never needs kilowatts. The shipped value is 500 W,
about 5 % of the installed 10.5 kW — enough to absorb a wrong ambient or a
drifting k_loss, far too little to run away with the kettle. The tighter of
this and the max_power / ki backstop wins, and it applies to both gain sets.
Integral separation (pid.i_band_c, °C; 0 = off). The integrator only
accumulates while |error| ≤ i_band_c; outside the band it holds its value,
neither growing nor resetting. Anti-windup alone is not enough on this plant:
the kettle is an integrator, so a PI with an accurate feedforward must finish
a setpoint change with its integrator back near zero, which means every
degree-second of error it soaked up during the approach is paid back as
overshoot after the crossing. In the 2026-09-07 ramp test that accounted for
the whole 0.73 °C overshoot on a 10 °C step: the output was railed (and the
integrator held) only for the first 4 °C, then the integrator wound up for
250 s across the unsaturated approach and was still commanding 1.7 kW over
the holding power when the temperature first touched setpoint. With the
feedforward carrying the holding power the integrator has only model error to
trim, and there is nothing useful for it to learn while the temperature is
still degrees away, so the band confines it to the last half degree. The
band applies to both gain sets.
Holding feedforward. ff = (setpoint − ambient_c) / process_gain_c watts,
clamped to [0, max_power] — the steady-state power needed to hold the
setpoint, supplied directly so the integrator only trims model error (§9.6).
Zero gain disables it.
Grain-in gain set. The "Grain In" toggle swaps in an alternate gain set
(grain.kp/ki/kd) and optionally caps power (grain.max_power_w) — the plant
changes when grain is added, and scorch risk rises. Zero grain gains mean
"same as main gains disabled" (auto commands no power in grain mode until they
are set).
Adaptive gain scaling (config-gated, off by default). The batch-size
estimate in litres (§2.7) divided by adaptive.m_ref_l scales kp/kd
within [scale_min, scale_max] — a half-size batch gets half the gain without
retuning.
Equal-flux power split. Demand is split so both elements run at the same
surface power density (W/cm²): proportional to area, with saturation overflow
pushed to the other element and everything clamped to the element ratings.
With different elements (e.g. 5000 W/820 cm² + 5500 W/473 cm²) the duties are
deliberately unequal — same flux, different power. power.max_flux_w_cm2
optionally caps total demand at flux × total_area (anti-scorch; 0 disables).
src/max31865.c/dev/spidev0.0 at
1 MHz, SPI_MODE_1.mains.frequency_hz (§7) in the
first config word — before auto-conversion, as the datasheet requires.max31865_read() is non-blocking: it checks the DRDY level (GPIO16,
active-low); not ready → previous value with fresh=false. Otherwise it
burst-reads RTD + fault registers in one SPI transaction, converts via
Callendar–Van Dusen, and applies the per-unit Rref/gain/offset calibration
(§9.1–9.2).max31865_fault_text() decodes the register for the UI
fault band.The MAX31865 datasheet is included at docs/MAX31865.pdf.
src/temp_filter.cCascaded boxcar (moving-average) stages — order and window from bibby.ini
(default 2 × 40 samples ≈ a second-order CIC with triangular weighting, ~0.65 s
group delay at 60 Hz). The first sample primes every stage so the output starts
at the true temperature instead of ramping from zero.
src/mass_estimator.cDuring any stretch of roughly constant commanded power (taken as 0 W while the
ZC watchdog holds the SSRs off) where the temperature rises ≥ 1 °C, the kettle behaves as an integrator: dT/dt = P/(m·c). A
least-squares slope over the stretch gives the thermal mass P/slope in J/°C,
which is reported as the equivalent litres of water, m = (P/slope)/4186, so
the number on the status line (m 53.2 L) and in the log (m_est_l) is a
batch size a brewer can sanity-check against what they poured in. It reads a
little high because the kettle, elements and hoses count as water, and it
reads 1/s high when the elements put out a fraction s of their rated watts
(the shipped setup reads 53 L for 45 L poured; §9.4). It is always computed
and logged; feeding it back into the gains is the separate, config-gated
adaptive scaling of §2.4.
src/ui/LVGL 9.3 rendering directly to the framebuffer:
lv_linux_fbdev on /dev/fb0; the physical panel is portrait,
mounted sideways, so the UI renders 1280×720 and LVGL software-rotates 90°
into the 720×1280 framebuffer (ui.rotation). Touch comes from the first
evdev device advertising absolute multitouch (ui.touch_device = auto).ui.chart_window_min, so the scale is fixed at startup and drawn once —
six marks running -5m … -1m … now for the default 5-minute window, landing
exactly on the chart's vertical grid lines (sub-minute marks read -0:30).lv_deinit on shutdown.src/ui/lv_conf.h): LVGL's built-in allocator is a fixed pool
sized for microcontrollers — 64 KB by default, and this screen sits at ~36 KB
of it with peaks near 42 KB, so a large redraw could exhaust the remainder and
assert inside lv_draw_add_task ("Out of memory"). bibby therefore sets
LV_USE_STDLIB_MALLOC = LV_STDLIB_CLIB: LVGL allocates from the system heap
and there is no ceiling to tune.LV_ASSERT_HANDLER is while(1);, which would
wedge the UI thread at 100 % CPU — panel frozen, SIGTERM ignored, control
threads still driving the elements. bibby sets it to abort() instead: the
process dies, the kernel drops the GPIO requests, the SoC pull-downs hold the
SSR inputs low, and bibby.service restarts it (§5.3)./dev/fb0 and will draw its blinking cursor over
the UI; the systemd unit unbinds it (§5.3).bibby --ui-test shows a bring-up screen instead: border, corner labels, and a
crosshair that follows the finger — verifies rotation and touch mapping before
trusting the real UI.
src/web/Optional, off by default, and configured entirely from [web] in bibby.ini
(§7). When enabled it adds an HTTP server on the LAN with two screens: a live
mirror of the touchscreen that can be tapped from a phone or laptop, and a
browser for the CSV run logs. Operator's guide in §8; safety in §3.
browser bibby process
┌──────────────────────────┐ ┌────────────────────────────────────┐
│ Screen tab │ │ accept thread ─┬─ conn thread ─┐ │
│ <img> long-poll ─────┼───┼──> /api/screen.png │ │
│ pointer events ─────┼───┼──> /api/input │ │
│ │ │ v │
│ Logs tab │ │ capture thread pointer │
│ file list, charts ─────┼───┼──> /api/logs /api/log queue │
│ CSV download ─────┼───┼──> /api/log/download │ │
└──────────────────────────┘ │ │ │ │
│ mmap /dev/fb0 │ │
│ │ UI thread │
│ └──────────> LVGL indev│
└────────────────────────────────────┘Screen mirror. The capture thread mmaps /dev/fb0 read-only and
compares it against the previous frame; only a genuine pixel change bumps the
frame sequence. It captures at most web.screen_fps times a second, and only
while a browser is actually asking — an unwatched panel costs nothing.
Requests long-poll: the server holds /api/screen.png?seq=N until the
screen differs from the client's frame N (or 10 s pass, answering 204), so
a static screen uses no bandwidth and a touch appears within one frame.
Frames are encoded as PNG (zlib, "Up" filter) — about 50 KB for a full
1280×720 frame, and ?scale=2 halves each dimension for slow links.
Un-rotation. The framebuffer is the physical portrait panel, sideways;
the mirror applies ui.rotation so the browser shows what the operator at the
kettle sees. The forward map is the same one LVGL uses on pointer input, so
"display space" here is exactly LVGL's logical screen.
Pointer injection. Taps post to /api/input in display coordinates. The
server maps them back to panel coordinates and pushes them onto a small queue;
a second LVGL pointer indev, created on the UI thread, drains that queue
and reports the samples exactly as the evdev touchscreen does — so LVGL
applies the same rotation to a remote tap as to a finger, and it lands on the
pixel it looks like it lands on. LVGL is single-threaded and this queue is the
entire boundary: no web thread ever makes an lv_* call. A press with no
follow-up is auto-released after 2 s, so a browser that vanishes mid-drag
cannot leave a widget held down.
Log browser. /api/logs walks the CSV tree; /api/log streams one file
through a bounded-memory decimator (rows accumulate into buckets; when the
bucket array fills, adjacent buckets merge pairwise and the rows-per-bucket
doubles) and returns a whole-file average of at most ~1400 points per series.
A 75 MB, 459 k-row tuning log becomes 136 KB of JSON in about a second.
/api/log/download serves the raw CSV, including the run in progress.
Path safety. Every client-supplied path is realpath()d and required to
resolve inside ~/bibby/logs and to be a regular .csv file, so the server
cannot be talked into serving anything else.
Authentication. HTTP Basic against web.user / web.password, compared
length-independently so a wrong password cannot be narrowed down by timing.
The server refuses to start with an empty password. Failures are logged
and answered after a 300 ms delay.
Cost. Nothing at all when no browser is watching — the capture thread sleeps on a condition variable. With one browser on the Screen tab, measured on a Pi 5 at 1280×720 (percentages are of one core, of four; bibby idles at ~1.4 %):
| per frame | 2 fps | 5 fps | |
|---|---|---|---|
| full resolution | ~19 ms, ~48 KB | ~5 % | ~11 % |
scale=2 | ~8 ms, ~25 KB | ~3 % | ~6 % |
The cost is linear in web.screen_fps, and the dominant term is the
un-rotate/pixel conversion (~8 ms), not the PNG compression (~11 ms
including the filter) — rotating means walking the framebuffer down a
column, which defeats the cache. Two things keep it in hand: the conversion
precomputes a per-pixel and per-row byte step instead of transforming each
pixel's coordinates (worth ~10×), and the build is optimized (§6 — an
unoptimized build costs ~67 ms per frame instead of 19). The web threads run
at normal priority and are not on the control path; the SSR thread's
SCHED_FIFO and the staleness watchdog are unaffected either way.
The page is a single self-contained HTML file (src/web/www/index.html, no
external assets or CDN) compiled into the binary at build time, so an
installed bibby has no companion asset directory to lose.
This system switches mains voltage into multi-kilowatt heating elements, so the default state is always off. The design requirements and how they are met:
| Requirement | Mechanism |
|---|---|
| Heaters off at startup | SSR GPIO lines are requested with output value low before any thread runs |
| Heaters off on exit/crash | the kernel releases the GPIO lines when the process dies — for any reason — and the SoC pull-downs hold the SSR inputs low; SIGINT/SIGTERM additionally shut down in order |
| Only one instance | flock single-instance guard (src/single_instance.h); the kernel drops the lock on any exit |
| Heaters off if mains-sense (ZC) is lost | mains-derived zero-cross watchdog (~9 ms at 60 Hz) forces SSRs off, raises watchdog_alarm. The one thing that defeats it is bench mode (mains.simulate_zc, §2.3), which is why it lives in the config file and has no control on the panel |
| Heaters off if the control loop wedges | control-staleness watchdog: sampler heartbeat stalled for ~2 s of ZCs → duties forced to zero |
| GUI stalls cannot delay safety logic | the SSR thread runs SCHED_FIFO; the UI is not on the control path |
| No power commanded on unreliable temperature | RTD fault / unresponsive sensor forces manual mode at zero watts; auto is unreachable until the sensor is healthy |
| Power commands always bounded | demand clamped to the configured element ratings (and the optional flux cap) before splitting; duties clamped to [0,1] |
| The integrator cannot run away | conditional-integration anti-windup, plus a hard ± bound on the integral term (pid.i_clamp_w, 500 W as shipped) and the max_power / ki backstop (§2.4) |
| A vanished web client cannot hold a control down | an injected press with no follow-up sample is released after 2 s |
| Controller returns after a crash | Restart=always in the systemd unit (§5.3); SSRs are safe during the gap per the kernel-release mechanism above |
| Remote control cannot bypass an interlock | the web interface owns no control logic: it injects pointer events into the same LVGL widgets a finger drives (§2.9), so mode interlocks, clamps and watchdogs apply identically |
| Remote control is off unless deliberately enabled | [web] is disabled by default and the server refuses to start without a password; web.allow_control = false serves a read-only mirror |
| Item | Notes |
|---|---|
| Raspberry Pi 5 | GPIO chip is /dev/gpiochip4 on Pi 5 |
| Touchscreen display | 720×1280 DSI panel (Goodix touch), mounted sideways; UI software-rotates 90° |
| PT100 RTD probe | 3-wire, immersed in the kettle |
| 2 × zero-cross SSR | One per heating element; switched by GPIO21 / GPIO26 |
| 2 × resistive heating element | Mains-powered, BIAB kettle (this build: 5000 W and 5500 W) |
| Custom Pi HAT PCB | Sensor front-end + zero-cross detector (§4.2) |
TODO (fill in your build): exact display model, SSR part numbers and current rating, fusing/contactor, and mains connector. These are deployment-specific and not captured in the repo.
pi_hat/bibby_pi_hat/A KiCad 7+ project (.kicad_sch, .kicad_pcb, .kicad_pro). Open it in KiCad
to view/edit the schematic and board, or to regenerate gerbers
(pi_hat/bibby_pi_hat/gerbers/). The HAT carries two functional blocks:
RTD front-end (U1 — MAX31865):
R1 = 400 Ω 0.1 % — RTD reference resistor (Rref). Precision and tempco here
set the measurement accuracy; this is one of the two per-unit calibration
anchors (§9.1).C1, C3 = 0.1 µF — decoupling / RC filtering.AC zero-cross detector (U2 — H11AA1 AC-input optocoupler):
R3–R6 = 43 kΩ, R2 = 20 kΩ, R7 = 1 MΩ (! DNI !) — line current-limiting
and pull/bias network for the optocoupler. The pulse train feeds GPIO20.JP1/JP2/JP3 select options on the board.Connectors:
J1 — 2×20 stacking header to the Pi GPIO.J2 — Phoenix 1×10 5.08 mm terminal block (field wiring: RTD, mains sense,
SSR drive).The complete, authoritative bill of materials and net list is the KiCad schematic itself. The values above are transcribed from it for orientation.
| Pin | GPIO | Signal | Direction | Used by |
|---|---|---|---|---|
| 1 | — | 3.3 V | Power | MAX31865 VIN |
| 6 | — | GND | Power | MAX31865 GND |
| 19 | GPIO10 | SPI0 MOSI | Out | MAX31865 SDI |
| 21 | GPIO9 | SPI0 MISO | In | MAX31865 SDO |
| 23 | GPIO11 | SPI0 SCLK | Out | MAX31865 CLK |
| 24 | GPIO8 | SPI0 CE0 | Out | MAX31865 CS |
| 36 | GPIO16 | DRDY | In | MAX31865 DRDY (sampler thread) |
| 37 | GPIO26 | SSR2 | Out | SSR thread |
| 38 | GPIO20 | Zero Cross | In | SSR thread |
| 40 | GPIO21 | SSR1 | Out | SSR thread |
MAINS L ---+----------------------------+--------------------+
| | |
| [SSR1 load] [SSR2 load]
| | |
[ZC detector U2] [Element 1] [Element 2]
| (opto, isolated) | |
v [SSR1 line] [SSR2 line]
GPIO20 (ZC in) ^ ^
| |
Pi 3.3V/GPIO control side: GPIO21 (SSR1) GPIO26 (SSR2)
drive (3.3V logic into SSR input)
PT100 RTD --3 wires--> MAX31865 (U1) --SPI0 + DRDY--> PiWARNING: mains wiring, fusing, grounding/earth bonding, and enclosure safety are the integrator's responsibility. Use appropriately rated SSRs and heat-sinking, a proper earth connection to the kettle, and a contactor/fuse/breaker sized to the elements.
TODO (fill in your build): enclosure material and dimensions, panel cutout for the display, ventilation/heat-sinking for the SSRs, and how the Pi + HAT + SSRs are mounted. Add photos and mechanical drawings here.
Tested target: Raspberry Pi 5, 64-bit Raspberry Pi OS.
sudo raspi-config # Interface Options -> SPI -> Enable/dev/spidev0.0 exists after reboot.display_auto_detect=1 in
/boot/firmware/config.txt). The touch controller registers as a Goodix
evdev device; bibby finds it automatically./dev/gpiochip4; the code opens it directly. The
user running bibby needs the gpio, spi, video, and input groups (or
run via the systemd unit, §5.3).sudo apt update
sudo apt install -y build-essential cmake git libgpiod-dev zlib1g-devgpiodetect / pkg-config --modversion libgpiod.pip3 install numpy scipy matplotlib pandas on whatever machine runs them.deploy/bibby.service is the reference unit. Besides
starting bibby it handles three things the app cannot do for itself:
vtcon1) before start —
otherwise the console's blinking cursor (and any kernel message) draws over
the UI — and rebinds it on stop.LimitMEMLOCK=64M so mlockall(MCL_CURRENT) succeeds (the default 8 MB
is too small once LVGL is linked in).AmbientCapabilities=CAP_SYS_NICE so the SSR thread gets its SCHED_FIFO
real-time priority without running the whole process as root.Install:
sudo cp deploy/bibby.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now bibbyCheck status and logs with systemctl status bibby / journalctl -u bibby -f.
Adjust User= and the paths in the unit for your username/checkout. The unit
uses Restart=always: the controller comes back if it ever dies, and the SSRs
are safe during the gap (§3).
git clone <repo-url> bibby
cd bibby
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -jIf you omit CMAKE_BUILD_TYPE, CMake would normally configure with no -O
flag at all; CMakeLists.txt therefore defaults it to RelWithDebInfo
(-O2 -g) when it is unset. This matters more than it sounds: an unoptimized
build runs LVGL's renderer and the web mirror's pixel conversion roughly four
times slower (§2.9). Check an existing build tree with
grep C_FLAGS build/CMakeFiles/bibby.dir/flags.makeand re-run cmake -S . -B build -DCMAKE_BUILD_TYPE=RelWithDebInfo if it shows
no -O2 — a build directory keeps whatever type it was first configured with.
The first configure fetches LVGL (pinned to v9.3.0 — see §2.8) from GitHub. Two binaries result:
build/bibby — the controller (links lvgl, gpiod, z, m, pthread,
rt; z is only used by the web screen mirror, §2.9)build/tests — unit tests for the pure modules (no hardware needed):./build/testsbuild/ and logs/ are gitignored. There is no install step yet; run from the
build tree (the systemd unit does the same). The config file is found relative
to the executable (§7), so a copied-out binary works if bibby.ini sits next
to it.
Runtime parameters that depend on the install (mains region, heating elements,
control gains, calibration) live in bibby.ini, not in the source.
There is no reason to edit a constant in code and rebuild to adapt a new brew
setup.
Lookup order: -c <path> argument → $BIBBY_CONFIG environment variable →
bibby.ini next to the executable → bibby.ini in the current directory.
A missing file or unknown key falls back to a built-in safe default (60 Hz,
zero gains), so the system still comes up. Comments (# or ;, inline
allowed) and blank lines are ignored.
All keys, with their defaults:
| Key | Default | Effect |
|---|---|---|
mains.frequency_hz | 60 | ZC watchdog timeout (half-period + 0.7 ms), staleness cutoff (2×Hz ZCs ≈ 2 s), MAX31865 notch (50/60 Hz) |
mains.simulate_zc | false | bench only: treat ZC-watchdog timeouts as zero crossings, so the whole loop runs with no AC connected (§2.3). The mains-loss watchdog cannot trip while it is on, so it is a config switch with no panel control; --sim-zc forces it on for one run |
element1.watts / element2.watts | 2500 / 2500 | element ratings; their sum is the maximum power demand |
element1.area_cm2 / element2.area_cm2 | 150 / 150 | wetted areas driving the equal-flux split |
power.max_flux_w_cm2 | 0 (off) | anti-scorch cap: total demand ≤ flux × total area |
pid.kp / ki / kd | 0 | watts-based gains (§9.5); zero = auto commands no power |
pid.i_band_c | 0 (off) | integral-separation band, °C: the integrator only accumulates within it (§2.4); both gain sets |
pid.i_clamp_w | 0 (off) | ± bound on the integral term, watts (§2.4); the tighter of this and the max_power / ki backstop applies; both gain sets |
grain.kp / ki / kd | 0 | alternate gain set while "Grain In" is on |
grain.max_power_w | 0 (off) | power cap while grain is in |
feedforward.process_gain_c | 0 (off) | identified K [°C/W]; holding feedforward (setpoint−ambient)/K watts |
feedforward.ambient_c | 20 | feedforward reference temperature |
sensor.ref_resistor_ohms | 400 | MAX31865 Rref, ice-point trimmed (§9.1) |
sensor.temp_cal_gain / temp_cal_offset | 1.0 / 0.0 | two-point span trim, T_true = g·T + b (§9.2) |
filter.order / filter.window | 2 / 40 | boxcar cascade stages and window length (§2.6) |
logging.rate | high | high = row per sample (60 Hz); low = row per low_period_s |
logging.low_period_s | 2.0 | row period in low-rate mode |
ui.chart_window_min | 5 | chart time window, minutes (1–30) |
ui.rotation | 90 | UI rotation onto the panel: 0/90/180/270 |
ui.fb_device / ui.touch_device | auto | auto = /dev/fb0 / first multitouch evdev; or explicit paths |
web.enable | false | start the web interface (§8); ignored unless web.password is set |
web.port | 8080 | TCP port |
web.bind | 0.0.0.0 | 0.0.0.0 = whole LAN; 127.0.0.1 = the Pi only (ssh tunnel) |
web.user / web.password | brewer / (empty) | HTTP Basic credentials; empty password = server refuses to start. No # or ; (INI comment characters) |
web.allow_control | true | false serves a read-only mirror — the screen updates, taps are rejected |
web.screen_fps | 5 | cap on mirror capture rate, 1–30 (only captures while a browser is watching) |
adaptive.enable | false | scale kp/kd by (batch-size estimate / m_ref_l) |
adaptive.m_ref_l | 0 | batch size the gains were tuned at, litres, as the estimator reads it (§9.5) |
adaptive.scale_min / scale_max | 0.5 / 4.0 | clamp on the adaptive scale |
The shipped bibby.ini documents each value's derivation inline; §9 gives the
full procedures.
./build/bibby # the normal touch UI
./build/bibby --ui-test # display/touch bring-up screen (§2.8)
./build/bibby --headless # no display: prints a status line every 2 s
./build/bibby --sim-zc # start with simulated zero crossings (bench)
./build/bibby --manual-w 8000 # start in manual at 8000 W (plant-ID tests)
./build/bibby -c /path/to.ini # explicit configIf the framebuffer cannot be opened the UI falls back to headless mode rather than exiting — the controller and safety logic keep running.
E1 x% E2 y% — these differ by design; see the
equal-flux split, §2.4).mains.simulate_zc in the config (§2.3, §7).ui.chart_window_min, y-autoscaled. Each carries a time scale under it
reading backwards from now to the full window.Off by default. To turn it on, set a password in [web] (§7) and restart:
[web]
enable = true
port = 8080
bind = 0.0.0.0 ; 127.0.0.1 to reach it only through an ssh tunnel
user = brewer
password = pick-something-long
allow_control = true ; false for a read-only mirror
screen_fps = 5 ; halve it if you want the CPU back; 2 is still responsiveThen browse to http://<pi-address>:8080/ and enter the user and password.
Startup logs the URL it is serving and whether control is enabled:
web: mirroring /dev/fb0, panel 720x1280 32 bpp, rotation 90 -> 1280x720
web: http://0.0.0.0:8080/ as user "brewer" (remote control ENABLED)
web: remote pointer attachedScreen tab — a live mirror of the panel, upright the way you see it at the kettle. It is the real framebuffer, not a re-drawing, so it shows exactly what the touchscreen shows. Click or drag anywhere on it and the tap is injected into the UI as a pointer event: setpoint steppers, the power slider, and the toggles all behave as they do under a finger. Quality can be dropped to half resolution for a weak connection. The mirror pauses while the Logs tab is open or the browser tab is hidden.
Logs tab — every run under ~/bibby/logs newest first (the run in progress
is marked "● live"). Pick one to plot it: temperature (filtered / raw /
setpoint) and power (demand, ff/P/I/D, and per-element duty on a
right-hand axis). Legend chips toggle traces, and hovering reads out every
visible series at that instant. Download CSV fetches the raw file — the
same one tools/identify_plant.py and tools/plot_logs.py expect — so a brew
can be pulled off the Pi from a laptop without ssh. Long logs are averaged
server-side to about 1400 points for plotting; the download is always the full,
unmodified file.
Before you enable it, read this. The mirror puts the kettle controls on your network. Everything the panel can do, a browser with the password can do, including raising the setpoint or commanding power on a multi-kilowatt element. Every interlock still applies — remote taps go through the same widgets, so a sensor fault still forces manual at 0 W, and the watchdogs are untouched — but the authorization is only that password, over plain HTTP:
bind = 127.0.0.1 and reach it through an ssh tunnel:
ssh -L 8080:localhost:8080 ramp , then browse to localhost:8080.allow_control = false when you only want to watch a brew — the screen
still updates live, but taps are rejected by the server.enable = false if you do not want any of this; there is no default
password and the server will not start without one.Bench testing without mains: set simulate_zc = true in [mains], or
start with --sim-zc to avoid editing the file. The SSR thread then clocks the
sigma-delta from its timeout tick instead of the missing zero-cross edge; the
full UI, control law, and logging run with no AC connected. Startup prints
bibby: ZERO-CROSS SIMULATION ON (--sim-zc) - the mains-loss watchdog cannot trip; bench use onlyTurn it off before the controller is wired to a kettle. With it on, losing the mains-sense signal looks exactly like normal operation to the SSR thread, so the ZC watchdog — one of the interlocks in §3 — is inert. There is no toggle for it on the panel for that reason: it should change when the unit is on a bench, not when someone is brewing on it.
Three of the measurement constants are per-unit — they depend on the
specific RTD probe and the board's reference resistor, so they must be
re-derived for any other board or probe. All three live in the [sensor]
section of bibby.ini; none require a code change or rebuild. Their
identity defaults (Rref = 400 Ω, gain = 1.0, offset = 0.0) give a working but
uncalibrated sensor.
tools/calibrate_sensor.py does the §9.1/§9.2 arithmetic for you (standard
library only):
python3 tools/calibrate_sensor.py rref --ice-reading 1.4 --rref-current 400
python3 tools/calibrate_sensor.py span --p1 0.0 0.0 --p2 25.0 23.78bibby.ini key | Symbol | What it corrects |
|---|---|---|
sensor.ref_resistor_ohms | Rref | one-point offset at the ice point (§9.1) |
sensor.temp_cal_gain | g | span/slope error across temperature (§9.2) |
sensor.temp_cal_offset | b | residual offset after the span fit (§9.2) |
The driver applies them in this order, so calibrate in this order too:
R_rtd = (raw / 32768) * Rref # ref_resistor_ohms (§9.1)
T_cvd = CVD(R_rtd) # Callendar–Van Dusen, fixed
T_true = g * T_cvd + b # gain, offset (§9.2)Rref (ice-point one-point trim)Rref scales the entire resistance reading, so trimming it nulls a constant
offset. Trim it first, at a single known low temperature (the ice point is the
cheapest 0 °C reference available).
Procedure
Set sensor.temp_cal_gain = 1.0, sensor.temp_cal_offset = 0.0 (so the
span trim does not mask the reading), and sensor.ref_resistor_ohms to the
board's nominal reference (400 Ω here — the value of R1 on the HAT, §4.2).
Submerge the probe in a well-stirred ice-water bath and let it settle.
Read the measured temperature T_ice (the raw column in the CSV log,
§10, before any span trim).
The PT100 resistance the probe should present at T_ice is, from
Callendar–Van Dusen with R0 = 100 Ω, A = 3.9083e-3:
R_pt100(T) = 100·(1 + A·T + B·T²), which near 0 °C is ≈ 100·(1 + A·T).
Because the reading scales linearly with Rref, the trimmed value is:
Rref = Rref_nominal · 100.0 / R_pt100(T_ice)Worked example (this unit): read +1.4 °C in the ice bath.
R_pt100(1.4) = 100·(1 + 3.9083e-3·1.4) ≈ 100.547 Ω, so
Rref = 400 · 100.0 / 100.547 = 397.82 Ω → sensor.ref_resistor_ohms = 397.82.
gain/offset (two-point line fit)With Rref set, a probe can still read low or high in proportion to
temperature (a probe α / self-heating mismatch). That is a slope error, so it
is corrected in the temperature domain by a straight line through two
reference points (T1_ref, T1_meas) and (T2_ref, T2_meas), where T_meas is
the reading after the §9.1 trim:
gain = (T2_ref - T1_ref) / (T2_meas - T1_meas)
offset = T1_ref - gain · T1_measPick the two points as far apart as practical and bracketing the brew range for the best fit. Good choices: the ice point (0 °C) and the local boiling point (altitude-corrected, below). A stirred ambient bath against a trusted reference thermometer also works for the upper point.
Boiling-point reference. Water boils below 100 °C at altitude; approximate
the local boiling point as T_boil ≈ 100 − 3.4·(h/1000m) (°C, h =
elevation), or read it from a steam table for your barometric pressure, then
use that as the upper T_ref.
Worked example (this unit): ice point (0.00, 0.00) and a fan-mixed
ambient point (25.00, 23.78):
gain = (25.00 − 0.00)/(23.78 − 0.00) = 1.0513,
offset = 0.00 − 1.0513·0.00 = 0.0. After a small refinement the shipped
values are sensor.temp_cal_gain = 1.0528, sensor.temp_cal_offset = -0.032.
PROVISIONAL: both points above are ≤ 25 °C. Re-fit using a boiling-point upper reference before trusting brew-range temperatures — at 5230 ft the local boiling point is 94.7 °C, and the §9.1-trimmed RTD should read ~90.0 °C there. Re-derive both keys for your own probe and altitude.
The model of the signal chain from power command to measured temperature has three cascaded stages: the kettle thermal plant, the RTD probe lag, and the software filter.
P [W] ──►[ G_plant ]──► T_kettle ──►[ G_probe ]──►[ G_filt ]──► T_meas ──► PID
K/(τs+1) 1/(τ_pr s+1) boxcar cascade
plant heat loss: T_kettle relaxes toward ambient at rate k_loss·(T − T_amb)Energy balance of a well-stirred (recirculated) kettle, linearised about an operating point: the stored energy rises with the electrical power poured in and falls with the heat leaking to the room.
$$m,c_p,\frac{dT}{dt} ;=; P ;-; k_{\text{loss}},\bigl(T - T_{\text{amb}}\bigr)$$
| Symbol | Meaning | Units |
|---|---|---|
| $T$ | kettle liquid temperature — the controlled variable | °C |
| $T_{\text{amb}}$ | ambient temperature the kettle relaxes toward unpowered | °C |
| $P$ | total electrical power delivered by the elements — the manipulated variable | W |
| $m$ | liquid mass | kg |
| $c_p$ | specific heat, 4186 for water | J/(kg·°C) |
| $k_{\text{loss}}$ | total heat-loss coefficient (conduction through walls and lid, radiation, evaporation) | W/°C |
Work in deviation variables so the constant ambient term drops out. Let $\theta \equiv T - T_{\text{amb}}$; with $T_{\text{amb}}$ constant $\dot\theta = \dot T$, so
$$m,c_p,\dot\theta + k_{\text{loss}},\theta ;=; P$$
Laplace transform, starting from the operating point so $\theta(0) = 0$:
$$m,c_p,s,\Theta(s) + k_{\text{loss}},\Theta(s) ;=; P(s) \quad\Longrightarrow\quad \bigl(m,c_p,s + k_{\text{loss}}\bigr),\Theta(s) ;=; P(s)$$
Solve for the transfer function and divide numerator and denominator by $k_{\text{loss}}$ to reach standard first-order form:
$$G_{\text{plant}}(s) ;=; \frac{\Theta(s)}{P(s)} ;=; \frac{1}{m,c_p,s + k_{\text{loss}}} ;=; \frac{1/k_{\text{loss}}}{\dfrac{m,c_p}{k_{\text{loss}}},s + 1} ;=; \frac{K}{\tau s + 1}$$
$$K = \frac{1}{k_{\text{loss}}}\ \left[\text{°C/W}\right] \qquad \tau = \frac{m,c_p}{k_{\text{loss}}}\ \left[\text{s}\right]$$
where $K$ is the steady-state gain (°C of rise per watt held) and $\tau$ the dominant time constant.
For a 30 L batch in a reasonably insulated kettle $\tau$ is typically 20–40 minutes. The initial ramp rate at power $P$ is $P/(m,c_p)$ — easy to check directly against the heating phase of the identification test, and the basis of the online $m,c$ estimator (§2.7).
The PT100 element sits inside a sheath with its own thermal mass, so it reports a lagged version of the liquid temperature. Treating the sheath as a lumped mass exchanging heat with the liquid through a film conductance gives the same first-order form as the kettle:
$$m_{\text{pr}},c_{\text{pr}},\frac{dT_{\text{pr}}}{dt} ;=; h A,\bigl(T - T_{\text{pr}}\bigr) \quad\Longrightarrow\quad \tau_{\text{probe}},\dot T_{\text{pr}} + T_{\text{pr}} ;=; T$$
$$G_{\text{probe}}(s) ;=; \frac{T_{\text{pr}}(s)}{T(s)} ;=; \frac{1}{\tau_{\text{probe}},s + 1}, \qquad \tau_{\text{probe}} = \frac{m_{\text{pr}},c_{\text{pr}}}{hA} ;\approx; 5\text{–}30\ \text{s}$$
| Symbol | Meaning | Units |
|---|---|---|
| $T_{\text{pr}}$ | temperature the probe actually reports | °C |
| $T$ | true liquid temperature (the plant output above) | °C |
| $m_{\text{pr}} c_{\text{pr}}$ | thermal mass of the sheath and element | J/°C |
| $hA$ | film conductance from liquid to sheath | W/°C |
| $\tau_{\text{probe}}$ | probe time constant — thin bare sheath at the low end, thick thermowell at the high end | s |
Its DC gain is 1: the probe eventually reaches the liquid temperature, it just gets there late. That is why $\tau_{\text{probe}}$ shows up as dead time in the FOPDT fit rather than as a gain error.
The default filter is $n = 2$ cascaded boxcars of $N = 40$ samples each, run at the sensor rate $f_s \approx 60\ \text{Hz}$ (one conversion per mains cycle). A single $N$-sample boxcar is
$$G_{\text{box}}(z) ;=; \frac{1}{N}\sum_{k=0}^{N-1} z^{-k}, \qquad G_{\text{filt}}(z) ;=; \bigl[G_{\text{box}}(z)\bigr]^{,n}$$
Its taps are uniform, so the group delay is the mean tap index — $(N-1)/2$ samples — and $n$ identical stages in series simply add:
$$L_{\text{filt}} ;=; \frac{n,(N-1)}{2,f_s} ;=; \frac{2 \cdot 39}{2 \cdot 60} ;\approx; 0.65\ \text{s}$$
with each stage contributing $(N-1)/(2 f_s) \approx 0.33\ \text{s}$. Both $n$
and $N$ are filter.order / filter.window in the INI; changing them changes
the dead time below, so re-identify the plant afterward.
Cascading the three stages gives a third-order model. The two small lags — probe and filter — are far faster than the kettle, so the standard reduction (Skogestad half-rule, in the limit where the small time constants are much smaller than the dominant one) folds them into a pure delay:
$$G(s) ;=; \underbrace{\frac{K}{\tau s + 1}}{\text{kettle}} \cdot \underbrace{\frac{1}{\tau{\text{probe}} s + 1}}{\text{probe}} \cdot \underbrace{G{\text{filt}}(s)}_{\text{filter}} ;;\approx;; \frac{K,e^{-Ls}}{\tau s + 1}$$
using $\dfrac{1}{\tau_{\text{probe}} s + 1} \approx e^{-\tau_{\text{probe}} s}$ for $\tau_{\text{probe}} \ll \tau$, so the delays add:
$$K \approx \frac{1}{k_{\text{loss}}}\ \left[\text{°C/W}\right] \qquad \tau \approx \frac{m,c_p}{k_{\text{loss}}}\ \left[\text{s}\right] \qquad L \approx \tau_{\text{probe}} + L_{\text{filt}}\ \left[\text{s}\right]$$
The ratio $L/\tau$ characterises controllability: $L/\tau < 0.3$ is straightforward; $L/\tau > 1.0$ is delay-dominated. A BIAB kettle sits well below 0.3 — with $L \approx 10\text{–}30\ \text{s}$ against a $\tau$ of tens of minutes, the challenge is the large time constant, not the dead time.
The PID accumulates raw error once per sample (integral += e, not e·dt)
and uses raw per-sample deltas for the derivative. The INI gains relate to
continuous-time gains by:
kp_ini = Kp_c [W/°C, unchanged]
ki_ini = Ki_c · T_s [T_s = sample period ≈ 1/60 s]
kd_ini = Kd_c / T_sidentify_plant.py measures T_s from the log and folds it in automatically.
m (the batch size, litres), k_loss, and L come from a heat-then-cool test: run the elements
at a fixed high power in manual mode until the kettle reaches mash
temperature, then turn them off and let it cool. tools/identify_plant.py
drives the lumped model of §9.3 with the logged power demand p_demand_w
(which the modulator delivers, §2.3; rows where the ZC watchdog was up count
as 0 W), fits the three parameters to the measured temperature by least
squares, and prints ready-to-paste [pid]/[feedforward] blocks.
Each phase pins down something the other cannot:
| Phase | Determines | Why |
|---|---|---|
| Heating ramp | m | slope = P / (m·c); the loss term is small against 8 kW |
| Power on/off corners | L | the measured trace turns L seconds after the power does |
| Cooling decay | k_loss | at 0 W the kettle relaxes toward ambient at rate k_loss / m·c |
A constant-power step alone cannot separate K from τ unless it runs to
steady state, which for a BIAB kettle at any useful power sits above boiling.
The cooling phase removes that problem: K = 1/k_loss and τ = m·c/k_loss
follow directly.
k_loss depends on the lid,
insulation, and pump heat, so the test setup must match the brew setup.
Good mixing is required for the lumped model to hold.--ambient input; the script does not estimate it.k_loss.
(--manual-w on the command line applies the power from startup.)k_loss.
A well-insulated kettle cools very slowly (a cooling time constant of
hours is normal), so give it an hour or more — the holding power near mash
temperature is pinned down early, but separating k_loss from the ambient
value takes a longer decay.~/bibby/logs/YYYY/MM/DD/HH-MM-SS.csv.The power trace does not have to be clean — the script uses whatever
p_demand_w was, sample by sample — but the plant must not
change mid-test (no adding water, no lid on/off).
pip3 install numpy scipy matplotlib pandas # once
python3 tools/identify_plant.py logs/YYYY/MM/DD/HH-MM-SS.csv \
--ambient 21.5 --volume-l 45 -o plant_fit.png
# τc (--lambda) sets how aggressive the SIMC gains are (§9.5); the shipped
# bibby.ini uses 30 s, the script's default is max(3L, 120 s):
python3 tools/identify_plant.py logs/... --ambient 21.5 --lambda 30The whole log is used; there is no window to choose. --ambient is the room
temperature you measured, in °C; --volume-l is the water you put in, used
only for the element-watts cross-check below. The script prints:
m in litres (compare against what you poured in), k_loss, and L,
each with a standard error, the fit RMSE overall and per phase, and a
cross-check fit with the ambient left free — a gap of more than a couple of
°C from your --ambient means the room reading or the setup is off;k_loss, temperatures above 90 °C, or a large RMSE;--volume-l, the ratio of the fitted m to the litres you put in.
A ratio well above 1 means the elements put out fewer watts than
element1_watts/element2_watts claim — mains below the rating voltage,
element resistance tolerance, SSR drop. The gains and feedforward are still
correct: the controller, the log, and the fit all work in the same nominal
watts, so the factor cancels in the loop. Only the displayed watts, the
on-screen m, and the flux cap are mislabeled. If you correct the INI
ratings by a factor s (measure V and R, P = V²/R), scale kp and ki
by s and process_gain_c by 1/s, or rerun the test — changing the
ratings alone makes the loop 1/s more aggressive;K, τ, L, tuned gains per rule, and the INI blocks.Corners. A real kettle does two things at the power transitions that a
lumped mass cannot. First, the probe usually sits in the recirculation flow
downstream of the elements and reads a few tenths of a degree above the bulk
while they run — visible as a fast drop in the first minute after power-off.
The script reports this mixing offset; scaled to holding power it is
hundredths of a degree and can be ignored for control. Second, because that
transient dominates the corners, the least-squares dead time is unreliable
(it went to zero on the first real run). The script therefore also reads L
straight off the power-on corner — the time for the measured slope to reach
half its steady ramp value — and uses the larger of the two for tuning.
The plot shows the measured temperature against the model (heating phases
shaded), the commanded power, and the residuals. Spikes in the residual at the
power corners are the mixing offset and probe lag above. A residual that
drifts through the cooling phase means k_loss or the ambient value is wrong.
| Parameter | Physical meaning | Implication |
|---|---|---|
m | Batch size, litres of water (thermal mass m·c = 4186·m J/°C) | Sanity check vs. what you poured; sets the ramp rate P/(m·c); adaptive.m_ref_l |
k_loss | Heat loss per °C above ambient | Holding power = k_loss × (setpoint − ambient); needs the cooling phase |
K = 1/k_loss | Steady-state gain | Large K: heater dominates; steady state far above ambient |
τ = m·c/k_loss | Thermal time constant | Larger batch = larger τ; the loop can afford slower response |
L | Probe lag + filter delay | Limits tuning aggression; keep the probe well-immersed |
L/τ | Relative dead time | < 0.1 easy; > 0.5 requires careful detuning |
Auto mode commands zero power until pid.kp/ki/kd are set. The script
prints SIMC-PI by default; --rule zn|cc|all adds the classical rules:
| Rule | Characteristic | When to use |
|---|---|---|
| SIMC-PI | Skogestad's IMC: Kp = τ/(K(τc+L)), Ti = min(τ, 4(τc+L)); τc sets speed vs. robustness | First choice for BIAB; tune τc with --lambda |
| ZN-PID | Aggressive, ~25 % overshoot | Reaction-curve rule; meaningless when L ≪ τ |
| CC-PID | Balanced at moderate L/τ | Same caveat as ZN |
The cap on the integral time is what makes SIMC usable here: an insulated
kettle has a τ of hours, and plain IMC-PI (Ti = τ) would leave the
integrator uselessly slow, while ZN/CC divide by L/τ and print absurd gains.
Keep kd = 0: at 60 Hz the raw per-sample derivative mostly amplifies sensor
noise.
--lambda)τc is the closed-loop time constant you ask for: after a small setpoint step
the error should decay roughly as exp(−t/τc). It is the one knob in SIMC,
and on this plant the rule collapses to something you can read directly:
kp = m·c / (τc + L) [W/°C]
Ti = 4 (τc + L) [s]
P saturates when error > ramp_rate × (τc + L) (ramp_rate = P_max / m·c)So τc sets how far from setpoint the loop leaves full power and starts to taper, and how fast the integrator can move. Smaller τc = stiffer:
| τc | kp (this kettle) | P tapers below | Ti | Character |
|---|---|---|---|---|
| L ≈ 10 s | 11 700 W/°C | 0.9 °C | 76 s | SIMC "tight": gain margin ≈ 3, phase margin ≈ 60°; every probe wobble becomes kilowatts |
| 30 s (shipped) | 5 630 W/°C | 1.9 °C | 158 s | 2 °C step should settle within about 90 s; probe noise at hold ≈ 40 W rms of demand |
| 120 s (script default) | 1 720 W/°C | 6 °C | 518 s | 2 °C step took 4 min to first touch setpoint in the 2026-09-07 ramp test |
The 2026-09-07 ramp test at τc = 120 s showed why smooth is not the same as
safe: the response was gentle and overshot (0.26 °C on a 2 °C step, 0.73 °C
on 10 °C), because the long unsaturated approach let the integrator wind up
(§2.4). Integral separation (pid.i_band_c) removes that mechanism, and once
it is gone a smaller τc is pure gain. Robustness is what bounds it from below:
SIMC's τc = L is already a reasonable margin against model error, and the
things not in the model (the corner transients of §9.4, filter delay, a
grain bed changing the mixing) all argue for staying a factor of 2–3 above
it. The shipped 30 s ≈ 3L is that compromise. Batch size changes m·c and
therefore the right kp in proportion; that is what adaptive.m_ref_l is
for. Grain in makes the plant slower (more mass, worse mixing), which errs
on the safe side for a fixed gain set but still wants its own [grain]
gains.
The shipped bibby.ini carries SIMC-PI gains and feedforward identified from
the 2026-09-07 heat/cool test (45 L of water, 5000+5500 W nominal elements, no
grain). Validate on your own setup before trusting a batch:
[pid] (and [feedforward], §9.6) blocks into
bibby.ini and restart bibby.--lambda) and re-run the script; if it creeps up over minutes, or
overshoots after a long approach, τc is too large or pid.i_band_c is off.pid_ff_w/pid_p_w/pid_i_w/pid_d_w split: the
feedforward should carry the holding power, the integral only trim, the
derivative not chatter.Anti-windup itself needs no configuration: conditional integration plus the
max_power/ki backstop (§2.4) bound the integrator automatically. Two keys do:
pid.i_band_c — integral separation. Keep it at about 0.5 °C (the shipped
value) so the integrator stays out of the approach. The band should be a
little larger than the steady-state ripple and any offset the feedforward
leaves, and smaller than the error at which P leaves full power
(ramp_rate × (τc + L) above), so the integrator wakes up only once the P
term is already tapering.pid.i_clamp_w — how much power the integrator may ever add or remove,
500 W as shipped. Size it from the model error you expect the integrator to
cover, not from the elements: the feedforward supplies the holding power, so
this only has to absorb a wrong ambient_c, a drifting k_loss, or a lid
left off. Read pid_i_w from a good run and leave a few times its peak; if
the log shows it sitting pinned at ±i_clamp_w while a real offset
persists, the feedforward is wrong — fix [feedforward] rather than raising
the clamp.Grain-in gains. Adding grain changes the plant (more mass, worse mixing,
scorch risk at the bag). Re-run the heat/cool test with grain in (or a sacrificial
equivalent) to derive grain.kp/ki/kd, and consider grain.max_power_w to cap
flux at the bag.
Adaptive scaling. If batch size varies brew to brew, set
adaptive.m_ref_l to the batch the gains were tuned at as the estimator
reads it (the script prints it under [adaptive]; it is the fitted m,
not the litres poured) and enable adaptive.enable: kp/kd then scale with
the measured batch size within [scale_min, scale_max].
A kettle spends most of a brew holding a temperature against heat loss. Left to the PID alone, the integrator must wind up to supply that holding power — slow to converge and a source of overshoot. The holding feedforward supplies it directly:
ff_w = clamp( (setpoint − ambient_c) / K , 0, max_power ) [watts]
demand = clamp( ff_w + P + I + D , 0, max_power )K (°C/W) and ambient_c live in [feedforward]; the same heat/cool test
that tunes the PID sizes the feedforward, and the script prints the block.
process_gain_c = 0 disables it (the safe default).
ff_w depends on the setpoint, not
the measurement, so it sits outside the feedback loop; the closed-loop
dynamics are unchanged (superposition).k_loss needs the cooling phase. A heat-only log cannot separate K
from τ; the script warns and tells you to leave process_gain_c = 0.
This build ships with feedforward disabled for exactly that reason.ambient_c is the room temperature you passed as --ambient; the script
copies it into the block. If the kettle habitually rests above room
temperature (pump heat), the script's free-ambient cross-check will say so —
use that value instead. Errors just become a small bias the integrator
removes.Feedforward is only active in auto mode, and an RTD fault still forces manual, so it never drives the relays on bad data.
src/csv_logger.c opens one timestamped file per run:
~/bibby/logs/YYYY/MM/DD/HH-MM-SS.csvDirectories are auto-created. In the default high-rate mode one row is written
per fresh sensor sample (~60 Hz), right after the control law runs, and
each row is flushed so a crash mid-brew keeps the data on disk. During a sensor
outage a row is still written every 0.5 s so the fault interval is on disk.
logging.rate = low drops to one row per logging.low_period_s for long
unattended runs. Columns:
wall_time, t_monotonic_s, temp_raw_c, temp_filt_c, setpoint_c,
p_demand_w, duty1, duty2, flux1_w_cm2, flux2_w_cm2,
pid_ff_w, pid_p_w, pid_i_w, pid_d_w, pid_integral, pid_deriv, pid_error_c,
m_est_l, manual, grain_in, rtd_fault, watchdogp_demand_w is the commanded power after every clamp (grain cap, flux cap).
The sigma-delta modulator delivers it on average (§2.3), so it is also the
delivered power — except while watchdog is 1, when the SSRs are held off.
Logs written before 2026-09-13 carry an extra p_delivered_w column after
it; the tools look columns up by name, so both kinds open.flux1/2_w_cm2 are the per-element surface power densities from the split.pid_*_w is the feedforward / P / I / D breakdown in watts (§9.5);
pid_error_c the residual error.wall_time is ISO-8601 local with milliseconds; t_monotonic_s is
CLOCK_MONOTONIC. Recover dt from either.grain_in marks grain addition; manual the control mode; rtd_fault the
raw MAX31865 fault byte; watchdog the ZC watchdog state.tools/plot_logs.py is a small Tk browser for the log tree — pick a file,
tick columns, zoom. It reads whatever columns the header declares, so both old
and new logs open fine.
bibby/
├── CLAUDE.md project notes / coding conventions
├── CMakeLists.txt build: bibby + tests, fetches LVGL
├── cmake/
│ └── embed_asset.cmake turns the web page into a C byte array at build time
├── README.md this file
├── TODO.md known work not yet done (currently: HTTPS, §8)
├── bibby.ini user configuration (mains, elements, gains, cal)
├── deploy/
│ └── bibby.service systemd unit (console unbind, memlock, RT prio)
├── docs/
│ ├── MAX31865.pdf sensor datasheet
│ └── REBUILD.md the plan the single-process rebuild followed
├── src/
│ ├── main.c startup, GPIO requests, thread spawn, shutdown
│ ├── state.{h,c} shared atomics + chart history ring
│ ├── config.{h,c} bibby.ini loader
│ ├── control.{h,c} watts PID + feedforward
│ ├── power_split.{h,c} equal-flux watts→duty split + flux cap
│ ├── sigma_delta.h per-ZC sigma-delta modulator
│ ├── ssr_thread.{h,c} ZC capture, SSR firing, watchdogs (SCHED_FIFO)
│ ├── sampler_thread.{h,c} sensor pacing, filter, control, logging
│ ├── max31865.{h,c} MAX31865 RTD driver
│ ├── temp_filter.{h,c} boxcar cascade filterbibby uses three licenses, one per artifact type:
| Artifact | License | File |
|---|---|---|
Software (src/, tests/, tools/, deploy/) | Apache-2.0 | LICENSE |
Hardware (pi_hat/) | CERN-OHL-P-2.0 | pi_hat/LICENSE |
Documentation (README.md, docs/) | CC-BY-4.0 | creativecommons.org/licenses/by/4.0 |
Apache-2.0 (software): permissive, includes an explicit patent grant so
techniques in the control code cannot be used against you or downstream users.
No per-file headers are required; the top-level LICENSE and NOTICE files
satisfy the license requirements.
CERN-OHL-P-2.0 (hardware): the permissive variant of the CERN Open
Hardware Licence — the hardware analog of Apache/MIT. Covers the KiCad
schematics, PCB layout, and gerbers in pi_hat/.
CC-BY-4.0 (documentation): attribution-only. You may reproduce, adapt, and redistribute the documentation for any purpose as long as you credit the original author.
| Qty | Value | Footprint | References |
|---|---|---|---|
| 4 | 43K | Resistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolder | R3, R4, R5, R6 |
| 4 | MountingHole_2.7mm_M2.5 | MountingHole:MountingHole_2.7mm_M2.5 | MH1, MH2, MH3, MH4 |
| 3 | 0.1u | Capacitor_SMD:C_0603_1608Metric_Pad1.08x0.95mm_HandSolder | C1, C2, C3 |
| 2 | SolderJumper_3_Open | Jumper:SolderJumper-3_P2.0mm_Open_TrianglePad1.0x1.5mm_NumberLabels | JP1, JP3 |
| 1 | 1Meg | Resistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolder | R7 |
| 1 | 20K | Resistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolder | R2 |
| 1 | 400 0.1% | Resistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolder | R1 |
| 1 | Conn_01x10 | TerminalBlock_Phoenix:TerminalBlock_Phoenix_MKDS-1,5-10-5.08_1x10_P5.08mm_Horizontal | J2 |
| 1 | GPIO | Connector_PinSocket_2.54mm:PinSocket_2x20_P2.54mm_Vertical | J1 |
| 1 | H11AA1 | Package_DIP:DIP-6_W7.62mm | U2 |
| 1 | MAX31865xAP | Package_SO:SSOP-20_5.3x7.2mm_P0.65mm | U1 |
| 1 | SolderJumper_2_Open | Jumper:SolderJumper-2_P1.3mm_Open_TrianglePad1.0x1.5mm | JP2 |
It measures 65 × 56 mm and has 2 copper layers, 1.6 mm thick.
21 parts, built around the H11AA1. The full list of its parts is on this page, with links to each part's symbol and footprint where PCBKit's library has them.
From the austingrossman/bibby repository on GitHub, at the commit this page was made from: the board is pi_hat/bibby_pi_hat/bibby_pi_hat.kicad_pcb and its schematic pi_hat/bibby_pi_hat/bibby_pi_hat.kicad_sch. PCBKit opens the board in the browser, with nothing to install.
Its repository states no licence, so its author keeps all rights: ask austingrossman before reusing the design.

basson/qym-solder
QymSolder - Soldering station three in one - hakko t12, hot air gun, d909/898D or analog.

wvmarle/AC_control
Arduino library for AC phase controller with peak detection.

marcass/furnace_control
Control a FUWI pellet boiler with arduino to improve smarts

AlanCarvalhoEE/hap
Home Automation Platform with custom hardware and firmware (ESP8266), a Raspberry Pi–based server, and an Android client. Features modular IoT devices, TCP/IP and MQTT messaging, and a scalable system architecture.

BwanaFr/heating-controller
The heater can be controlled using MQTT using a Wi-Fi connection or using the integrated OLED display. It is designed to be mounted on a DIN rail in an electrical cabinet.



hecko/nanoReflowController
(c) 2017 David Sanz Kirbis (c) 2014 Karl Pitrich in part based on a project (c) 2013 Ed Simmons