Skip to the content
PCBKitNew board
2 layers65 × 56 mmBibby pi hat, the printed circuit board in 3D

Controller for Brew-in-a-Bag Electric Kettle

Size
65 × 56 mm
Copper layers
2
Thickness
1.6 mm
Parts
21
Nets
48
Pads
108
Tracks, vias
135, 17
Pours
1
Routed
Yes
KiCad
KiCad 10.0 (file 20260206)
Created
22 Apr 2026
Updated
16 Sep 2026

Overview

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.

Main components

1

README

README.md

bibby — Brew In A Bag Temperature Ramp Controller

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:

  • Learn to use AI for coding purposes.
  • I recently purchased a Mac mini. I will only use a Mac for this project and it forces me to learn how to use it.
  • I've recently acquired a 3D printer. This project has one 3D printed part, so I should learn how to use it.

Table of contents

  1. System overview
  2. Software architecture
  3. Safety design
  4. Hardware
  5. Raspberry Pi bring-up and installation
  6. Building bibby
  7. Configuration
  8. Running
  9. Calibration methodology
  10. Data logging
  11. Repository layout
  12. License

1. System overview

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):

text
  +----------------------------------------------------------------+
  |                    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 elements
  • The SSR thread owns the relays. It runs at real-time priority (SCHED_FIFO) so a GUI stall can never delay the zero-cross response or the watchdogs.
  • The sampler thread owns the sensor and the control law. It publishes per-element duty commands into the shared state; it never touches the relays.
  • The UI thread is a pure view/controller: it renders the state and writes user intent (setpoint, mode, manual watts). Nothing in it is on the control or safety path.
  • Optionally, the web interface (§2.9) adds an accept thread, a framebuffer-capture thread and a short-lived thread per HTTP connection. They run at normal priority, hold no control state of their own, and reach the controller only by injecting pointer events into the UI — so everything above is unchanged whether it is running or not.

Why a duty cycle synchronized to zero crossing?

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.

Why watts, not duty?

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².


2. Software architecture

2.1 Shared state — 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:

FieldWriterPurpose
duty1, duty2samplerper-element duty commands in [0,1]
p_demand_wsamplercommanded power — also the delivered average power (§2.3)
temp_raw_c, temp_filt_c, temp_validsamplerlatest sensor sample and validity
setpoint_c, manual_mode, manual_power_w, grain_inUIuser intent
simulate_zcmain, at startupbench zero-cross simulation (mains.simulate_zc / --sim-zc); never changes while running
output1, output2, zc_count, watchdog_alarmSSR threadfired state, ZC counter, ZC watchdog
control_heartbeatsamplerbumped every loop pass; the SSR thread's staleness watchdog watches it
rtd_fault, rtd_unresponsive, fault_forced_manualsamplersensor-fault state
m_est_l, adaptive_scalesamplerbatch-size estimate (litres) and gain scale
runningmainglobal 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.

2.2 Sampler thread — src/sampler_thread.c

Paced by the MAX31865 DRDY edge (one conversion per mains cycle, so ~60 Hz):

  1. Wait for a DRDY falling edge (500 ms timeout so a dead sensor cannot stall the loop — the read also checks the DRDY level directly, so a missed edge still yields the conversion).
  2. Read the sensor (§2.5); fresh samples go through the temperature filter (§2.6) and into the shared state.
  3. Mode interlock: any RTD fault, unresponsive sensor, or invalid temperature forces manual mode at zero watts — automatic control can never command power on bad data.
  4. Control law (§2.4): manual watts pass straight through; in auto, the watts-based PID runs once per fresh sample.
  5. Power split (§2.4) turns the demand into duty1/duty2.
  6. Slow tick (0.5 s): publish the batch-size estimate (§2.7) and push a chart history point.
  7. CSV logging (§10): one row per fresh sample (or per 0.5 s during an outage, so faults stay on disk).

2.3 SSR thread — src/ssr_thread.c, src/sigma_delta.h

Runs at SCHED_FIFO (real-time) priority, pinned to the AC zero crossing:

  1. Zero-cross capture. Wait for a rising edge on GPIO20 (libgpiod v2 edge events) with a mains-derived timeout: half-period + 0.7 ms guard — 9.0 ms at 60 Hz, 10.7 ms at 50 Hz.
  2. Sigma-delta modulation. Each zero crossing, add each element's duty fraction to a per-channel accumulator; fire that SSR for the half-cycle when the accumulator reaches 1.0 and subtract 1.0. The accumulator is capped at 2.0 to bound catch-up after forced-off stretches. This is a first-order delta-sigma DAC clocked by the mains: the average fired fraction equals the commanded duty with the switching energy pushed to high frequency.
  3. Zero-cross watchdog. No edge within the timeout → both SSRs forced off, 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.
  4. Control-staleness watchdog. The sampler bumps 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.)

2.4 Control law — src/control.c, src/power_split.c

PID (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).

2.5 MAX31865 RTD sensor — src/max31865.c

  • PT100, 3-wire, 400 Ω nominal reference resistor, on /dev/spidev0.0 at 1 MHz, SPI_MODE_1.
  • Init sequence: set 3-wire + clear faults + mains filter notch → enable VBIAS (10 ms settle) → enable auto-conversion (20 ms) → discard the first 10 conversions using DRDY polling (they settle after VBIAS/auto enable).
  • Mains filter: config-register bit 0 selects the noise-rejection notch (set = 50 Hz, clear = 60 Hz). It is set from 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).
  • Fault handling: the RTD-LSB fault bit and Fault Status register are checked every read. A faulted conversion is rejected (the stale value is held so the filter is not poisoned) and the latch cleared so the status reflects the next conversion. max31865_fault_text() decodes the register for the UI fault band.

The MAX31865 datasheet is included at docs/MAX31865.pdf.

2.6 Temperature filter — src/temp_filter.c

Cascaded 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.

2.7 Batch-size estimator — src/mass_estimator.c

During 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.

2.8 UI — src/ui/

LVGL 9.3 rendering directly to the framebuffer:

  • Display: 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).
  • Layout: two large square toggles (Grain In, Manual Control) and the ±10/±1/±0.1 setpoint steppers on top; temperature chart (setpoint, filtered, raw, grain and mode-change markers) and power/PID-term chart below, each between a row of tappable legend chips above and a time scale below; a wide vertical power slider (manual watts in manual, live demand readout in auto); kettle graphic whose element glow follows the commanded duties and whose contents carry the batch-size estimate in litres and gallons, with the per-element duties and the adaptive gain scale under it; fault band (decoded RTD faults, watchdog, forced-manual notice).
  • Time scale: both charts are right-aligned to now and span 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).
  • No dead space. The two panes stretch from just under the toggles to the bottom edge. The fault band has no reserved strip: it is hidden nearly always, so it floats over the lower third of the power chart when it appears, which costs nothing — the loop is at zero watts by then.
  • Sized for fingers, not a mouse. Every target is a touch target: the two toggles are 180×160, the steppers 120×75, the legend chips 46 px tall, and the power slider is 104 px wide — it is the only continuous control on the screen, so it gets a thumb's worth of track. The one thing the panel deliberately cannot do is arm zero-cross simulation (§2.3).
  • LVGL is pinned to v9.3.0: v9.4 breaks the fbdev software-rotation path and hangs in lv_deinit on shutdown.
  • Memory (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.
  • Assertions: LVGL's default 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).
  • The kernel console shares /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.

2.9 Web interface — 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.

text
             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 frame2 fps5 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.


3. Safety design

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:

RequirementMechanism
Heaters off at startupSSR GPIO lines are requested with output value low before any thread runs
Heaters off on exit/crashthe 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 instanceflock single-instance guard (src/single_instance.h); the kernel drops the lock on any exit
Heaters off if mains-sense (ZC) is lostmains-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 wedgescontrol-staleness watchdog: sampler heartbeat stalled for ~2 s of ZCs → duties forced to zero
GUI stalls cannot delay safety logicthe SSR thread runs SCHED_FIFO; the UI is not on the control path
No power commanded on unreliable temperatureRTD fault / unresponsive sensor forces manual mode at zero watts; auto is unreachable until the sensor is healthy
Power commands always boundeddemand clamped to the configured element ratings (and the optional flux cap) before splitting; duties clamped to [0,1]
The integrator cannot run awayconditional-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 downan injected press with no follow-up sample is released after 2 s
Controller returns after a crashRestart=always in the systemd unit (§5.3); SSRs are safe during the gap per the kernel-release mechanism above
Remote control cannot bypass an interlockthe 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

4. Hardware

4.1 Major components

ItemNotes
Raspberry Pi 5GPIO chip is /dev/gpiochip4 on Pi 5
Touchscreen display720×1280 DSI panel (Goodix touch), mounted sideways; UI software-rotates 90°
PT100 RTD probe3-wire, immersed in the kettle
2 × zero-cross SSROne per heating element; switched by GPIO21 / GPIO26
2 × resistive heating elementMains-powered, BIAB kettle (this build: 5000 W and 5500 W)
Custom Pi HAT PCBSensor 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.

4.2 Raspberry Pi HAT PCB — 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.
  • SSOP-20 package; SPI to the Pi plus the DRDY data-ready line.

AC zero-cross detector (U2 — H11AA1 AC-input optocoupler):

  • The H11AA1 has an anti-parallel LED input, so it conducts on both mains half-cycles and its phototransistor output pulses around each zero crossing.
  • 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.
  • Solder jumpers 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.

4.3 Pin assignments (40-pin header)

PinGPIOSignalDirectionUsed by
1—3.3 VPowerMAX31865 VIN
6—GNDPowerMAX31865 GND
19GPIO10SPI0 MOSIOutMAX31865 SDI
21GPIO9SPI0 MISOInMAX31865 SDO
23GPIO11SPI0 SCLKOutMAX31865 CLK
24GPIO8SPI0 CE0OutMAX31865 CS
36GPIO16DRDYInMAX31865 DRDY (sampler thread)
37GPIO26SSR2OutSSR thread
38GPIO20Zero CrossInSSR thread
40GPIO21SSR1OutSSR thread

4.4 Electrical / wiring overview

text
   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--> Pi
  • The H11AA1 provides galvanic isolation between the mains line-sense and the Pi logic.
  • The SSRs are driven by 3.3 V GPIO on their input side; their output side switches mains into the elements.
  • The RTD is a low-voltage measurement isolated from the mains by the kettle and the sensor front-end.

WARNING: 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.

4.5 Enclosure

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.


5. Raspberry Pi bring-up and installation

Tested target: Raspberry Pi 5, 64-bit Raspberry Pi OS.

5.1 OS and interfaces

  1. Flash Raspberry Pi OS (64-bit) and boot the Pi. Install a beefy SD card (or other storage) for logging. Find a way to remote-login to the Pi.
  2. Enable SPI:
    text
    sudo raspi-config   # Interface Options -> SPI -> Enable
    Confirm /dev/spidev0.0 exists after reboot.
  3. The DSI touch display is auto-detected (display_auto_detect=1 in /boot/firmware/config.txt). The touch controller registers as a Goodix evdev device; bibby finds it automatically.
  4. The Pi 5 exposes GPIO via /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).

5.2 Build dependencies

text
sudo apt update
sudo apt install -y build-essential cmake git libgpiod-dev zlib1g-dev
  • libgpiod v2 is required (the code uses the v2 edge-event API). Confirm with gpiodetect / pkg-config --modversion libgpiod.
  • LVGL is fetched and built from source by CMake (FetchContent) — nothing to install. It renders straight to the framebuffer; no X, Wayland, or GL stack is needed.
  • The analysis tools (§9.4) additionally want pip3 install numpy scipy matplotlib pandas on whatever machine runs them.

5.3 Autostart at boot (systemd)

deploy/bibby.service is the reference unit. Besides starting bibby it handles three things the app cannot do for itself:

  • Unbinds the kernel console from the framebuffer (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:

text
sudo cp deploy/bibby.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now bibby

Check 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).


6. Building bibby

text
git clone <repo-url> bibby
cd bibby
cmake -S . -B build -DCMAKE_BUILD_TYPE=Release
cmake --build build -j

If 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

text
grep C_FLAGS build/CMakeFiles/bibby.dir/flags.make

and 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):
text
./build/tests

build/ 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.


7. Configuration

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:

KeyDefaultEffect
mains.frequency_hz60ZC watchdog timeout (half-period + 0.7 ms), staleness cutoff (2×Hz ZCs ≈ 2 s), MAX31865 notch (50/60 Hz)
mains.simulate_zcfalsebench 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.watts2500 / 2500element ratings; their sum is the maximum power demand
element1.area_cm2 / element2.area_cm2150 / 150wetted areas driving the equal-flux split
power.max_flux_w_cm20 (off)anti-scorch cap: total demand ≤ flux × total area
pid.kp / ki / kd0watts-based gains (§9.5); zero = auto commands no power
pid.i_band_c0 (off)integral-separation band, °C: the integrator only accumulates within it (§2.4); both gain sets
pid.i_clamp_w0 (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 / kd0alternate gain set while "Grain In" is on
grain.max_power_w0 (off)power cap while grain is in
feedforward.process_gain_c0 (off)identified K [°C/W]; holding feedforward (setpoint−ambient)/K watts
feedforward.ambient_c20feedforward reference temperature
sensor.ref_resistor_ohms400MAX31865 Rref, ice-point trimmed (§9.1)
sensor.temp_cal_gain / temp_cal_offset1.0 / 0.0two-point span trim, T_true = g·T + b (§9.2)
filter.order / filter.window2 / 40boxcar cascade stages and window length (§2.6)
logging.ratehighhigh = row per sample (60 Hz); low = row per low_period_s
logging.low_period_s2.0row period in low-rate mode
ui.chart_window_min5chart time window, minutes (1–30)
ui.rotation90UI rotation onto the panel: 0/90/180/270
ui.fb_device / ui.touch_deviceautoauto = /dev/fb0 / first multitouch evdev; or explicit paths
web.enablefalsestart the web interface (§8); ignored unless web.password is set
web.port8080TCP port
web.bind0.0.0.00.0.0.0 = whole LAN; 127.0.0.1 = the Pi only (ssh tunnel)
web.user / web.passwordbrewer / (empty)HTTP Basic credentials; empty password = server refuses to start. No # or ; (INI comment characters)
web.allow_controltruefalse serves a read-only mirror — the screen updates, taps are rejected
web.screen_fps5cap on mirror capture rate, 1–30 (only captures while a browser is watching)
adaptive.enablefalsescale kp/kd by (batch-size estimate / m_ref_l)
adaptive.m_ref_l0batch size the gains were tuned at, litres, as the estimator reads it (§9.5)
adaptive.scale_min / scale_max0.5 / 4.0clamp on the adaptive scale

The shipped bibby.ini documents each value's derivation inline; §9 gives the full procedures.


8. Running

text
./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 config

If the framebuffer cannot be opened the UI falls back to headless mode rather than exiting — the controller and safety logic keep running.

UI elements

  • Kettle card: setpoint in °C and °F above it, the measured temperature in °C and °F at the same size inside it (°F a shade darker, so °C still reads first), the batch-size estimate on the contents in litres and US gallons, animated elements whose glow tracks the commanded duty, and a per-element duty readout below (E1 x% E2 y% — these differ by design; see the equal-flux split, §2.4).
  • POWER slider: commands watts in manual mode; shows the loop's live demand in auto (disabled for input). Total watts read out under it. It is 104 px wide — deliberately, for a wet thumb.
  • Setpoint steppers: ±10 / ±1 / ±0.1 °C.
  • Toggles: two square pads — Grain In (gain set swap + logged event marker) and Manual Control (manual watts vs PID auto). Zero-cross simulation is not among them; it is mains.simulate_zc in the config (§2.3, §7).
  • Charts: temperature (setpoint / temp / raw + grain-event and mode-event markers — short dashes along the top edge at the samples where Grain In or Manual flipped) and power (demand W / ff / P / I / D), window set by ui.chart_window_min, y-autoscaled. Each carries a time scale under it reading backwards from now to the full window.
  • Chart legends: each trace has a chip under its chart — tap to drop that trace. A hidden trace is neither drawn nor counted in that chart's autoscale, so switching off the terms you are not tuning zooms the pane onto the rest. Chips are colored when on, greyed and struck through when off; all traces start visible on every launch.
  • Fault band: decoded RTD faults, watchdog alarm, and "Auto disabled → manual" when a fault forced the mode switch.
  • Status line: the adaptive gain scale, under the per-element duties at the foot of the kettle column. (The batch-size estimate that drives it is on the kettle contents.)

Web interface

Off by default. To turn it on, set a password in [web] (§7) and restart:

ini
[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 responsive

Then browse to http://<pi-address>:8080/ and enter the user and password. Startup logs the URL it is serving and whether control is enabled:

text
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 attached

Screen 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:

  • Use it on a trusted home network, with a password you use nowhere else.
  • Do not forward the port through your router. There is no TLS; anyone who can watch the traffic can read the password. For access from outside the house, set bind = 127.0.0.1 and reach it through an ssh tunnel: ssh -L 8080:localhost:8080 ramp , then browse to localhost:8080.
  • Set allow_control = false when you only want to watch a brew — the screen still updates live, but taps are rejected by the server.
  • Leave 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

text
bibby: ZERO-CROSS SIMULATION ON (--sim-zc) - the mains-loss watchdog cannot trip; bench use only

Turn 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.


9. Calibration methodology

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):

text
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.78
bibby.ini keySymbolWhat it corrects
sensor.ref_resistor_ohmsRrefone-point offset at the ice point (§9.1)
sensor.temp_cal_gaingspan/slope error across temperature (§9.2)
sensor.temp_cal_offsetbresidual offset after the span fit (§9.2)

The driver applies them in this order, so calibrate in this order too:

text
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)

9.1 Reference resistor 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

  1. 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).

  2. Submerge the probe in a well-stirred ice-water bath and let it settle.

  3. Read the measured temperature T_ice (the raw column in the CSV log, §10, before any span trim).

  4. 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:

    text
    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.

9.2 Span trim 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:

text
gain   = (T2_ref - T1_ref) / (T2_meas - T1_meas)
offset =  T1_ref - gain · T1_meas

Pick 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.

9.3 Process model

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.

Signal chain
text
  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)
Kettle thermal plant

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)$$

SymbolMeaningUnits
$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 variableW
$m$liquid masskg
$c_p$specific heat, 4186 for waterJ/(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).

RTD probe lag

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}$$

SymbolMeaningUnits
$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 elementJ/°C
$hA$film conductance from liquid to sheathW/°C
$\tau_{\text{probe}}$probe time constant — thin bare sheath at the low end, thick thermowell at the high ends

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.

Software filter

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.

Combined FOPDT approximation

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.

Gain conventions — continuous vs. INI

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:

text
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_s

identify_plant.py measures T_s from the log and folds it in automatically.

9.4 System identification

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:

PhaseDeterminesWhy
Heating rampmslope = P / (m·c); the loss term is small against 8 kW
Power on/off cornersLthe measured trace turns L seconds after the power does
Cooling decayk_lossat 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.

Test procedure
  1. Fill the kettle to the intended brew volume, with the lid and the recirculation pump exactly as you will brew. 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.
  2. Measure the room temperature with a separate thermometer near the kettle. This is the --ambient input; the script does not estimate it.
  3. Start bibby (the CSV logger runs from startup) and leave it a minute at 0 W so the log has a quiet lead-in.
  4. Heat. Manual Control on; set the power slider to about 80 % of maximum and leave it until the temperature reaches mash temperature (~65 °C). Do not approach boiling: evaporation changes k_loss. (--manual-w on the command line applies the power from startup.)
  5. Cool. Slider to 0 W. Leave the pump running and the lid as it was, and let the kettle cool until the temperature has dropped at least 5 °C; 30 minutes or more is better. The longer the decay, the tighter 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.
  6. Stop bibby and copy the log from ~/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).

Running the identification
text
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 30

The 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:

  • the power phases it found (heat/cool spans, mean watts, temperature range);
  • the fitted 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;
  • warnings when the log cannot support the fit: no cooling phase, a poorly determined k_loss, temperatures above 90 °C, or a large RMSE;
  • two corner diagnostics the lumped model does not contain (below): the dead time read directly off the power-on corner, and the mixing offset;
  • with --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;
  • the FOPDT equivalents 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.

ParameterPhysical meaningImplication
mBatch 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_lossHeat loss per °C above ambientHolding power = k_loss × (setpoint − ambient); needs the cooling phase
K = 1/k_lossSteady-state gainLarge K: heater dominates; steady state far above ambient
τ = m·c/k_lossThermal time constantLarger batch = larger τ; the loop can afford slower response
LProbe lag + filter delayLimits tuning aggression; keep the probe well-immersed
L/τRelative dead time< 0.1 easy; > 0.5 requires careful detuning

9.5 PID tuning

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:

RuleCharacteristicWhen to use
SIMC-PISkogestad's IMC: Kp = τ/(K(τc+L)), Ti = min(τ, 4(τc+L)); τc sets speed vs. robustnessFirst choice for BIAB; tune τc with --lambda
ZN-PIDAggressive, ~25 % overshootReaction-curve rule; meaningless when L ≪ τ
CC-PIDBalanced 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.

Choosing τc (--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:

text
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:

τckp (this kettle)P tapers belowTiCharacter
L ≈ 10 s11 700 W/°C0.9 °C76 sSIMC "tight": gain margin ≈ 3, phase margin ≈ 60°; every probe wobble becomes kilowatts
30 s (shipped)5 630 W/°C1.9 °C158 s2 °C step should settle within about 90 s; probe noise at hold ≈ 40 W rms of demand
120 s (script default)1 720 W/°C6 °C518 s2 °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:

  1. Copy the printed [pid] (and [feedforward], §9.6) blocks into bibby.ini and restart bibby.
  2. Set a setpoint 5–10 °C above current temperature, switch to Auto, and watch the charts.
  3. A good response rises at full power, tapers over the last degree or two and settles with minimal overshoot. If it oscillates, increase τc (--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.
  4. Inspect the logged 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].

9.6 Feedforward (optional, recommended)

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:

text
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).

  • It does not change loop stability. ff_w depends on the setpoint, not the measurement, so it sits outside the feedback loop; the closed-loop dynamics are unchanged (superposition).
  • It does not cause overshoot unless over-sized (K under-estimated). Keep K honest; a slight under-estimate of the holding power is the safe direction.
  • 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.


10. Data logging

src/csv_logger.c opens one timestamped file per run:

text
~/bibby/logs/YYYY/MM/DD/HH-MM-SS.csv

Directories 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:

text
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, watchdog
  • p_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.


11. Repository layout

text
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 filter

12. License

bibby uses three licenses, one per artifact type:

ArtifactLicenseFile
Software (src/, tests/, tools/, deploy/)Apache-2.0LICENSE
Hardware (pi_hat/)CERN-OHL-P-2.0pi_hat/LICENSE
Documentation (README.md, docs/)CC-BY-4.0creativecommons.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.

Parts

21
QtyValueFootprintReferences
443KResistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolderR3, R4, R5, R6
4MountingHole_2.7mm_M2.5MountingHole:MountingHole_2.7mm_M2.5MH1, MH2, MH3, MH4
30.1uCapacitor_SMD:C_0603_1608Metric_Pad1.08x0.95mm_HandSolderC1, C2, C3
2SolderJumper_3_OpenJumper:SolderJumper-3_P2.0mm_Open_TrianglePad1.0x1.5mm_NumberLabelsJP1, JP3
11MegResistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolderR7
120KResistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolderR2
1400 0.1%Resistor_SMD:R_0603_1608Metric_Pad0.98x0.95mm_HandSolderR1
1Conn_01x10TerminalBlock_Phoenix:TerminalBlock_Phoenix_MKDS-1,5-10-5.08_1x10_P5.08mm_HorizontalJ2
1GPIOConnector_PinSocket_2.54mm:PinSocket_2x20_P2.54mm_VerticalJ1
1H11AA1Package_DIP:DIP-6_W7.62mmU2
1MAX31865xAPPackage_SO:SSOP-20_5.3x7.2mm_P0.65mmU1
1SolderJumper_2_OpenJumper:SolderJumper-2_P1.3mm_Open_TrianglePad1.0x1.5mmJP2

Questions

How big is the Bibby pi hat PCB?

It measures 65 × 56 mm and has 2 copper layers, 1.6 mm thick.

What parts does the Bibby pi hat use?

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.

Where can I download the Bibby pi hat KiCad files?

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.

Can I use the Bibby pi hat design in my own project?

Its repository states no licence, so its author keeps all rights: ask austingrossman before reusing the design.

Similar boards

  • 2 layers100 × 74 mmQym solder, the printed circuit board in 3D

    Qym solder

    basson/qym-solder

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

    135 parts4GPL-3.0
  • 2 layers80.6 × 48.3 mmAC control, the printed circuit board in 3D

    AC control

    wvmarle/AC_control

    Arduino library for AC phase controller with peak detection.

    17 parts3MIT
  • 2 layers73.2 × 81.3 mmFurnace control, the printed circuit board in 3D

    Furnace control

    marcass/furnace_control

    Control a FUWI pellet boiler with arduino to improve smarts

    24 parts2GPL-3.0
  • 2 layers46 × 77 mmOn off module x1, the printed circuit board in 3D

    On off module x1

    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.

    40 parts1
  • 2 layers81.9 × 85.9 mmHeating controller, the printed circuit board in 3D

    Heating controller

    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.

    36 parts
  • 2 layers100 × 100 mmEncoder, the printed circuit board in 3D

    Encoder

    dexterlb/led_controller

    A simple device for controlling LEDs

    177 parts
  • 4 layers159 × 132.3 mmDSP TU, the printed circuit board in 3D

    DSP TU

    HaroldHallikainen/DSP_TU_PCB

    Printed circuit board for Teletype DSP Terminal Unit

    82 parts
  • 2 layers100 × 60 mmArduino nano reflow TRIAC v1 0, the printed circuit board in 3D

    Arduino nano reflow TRIAC v1 0

    hecko/nanoReflowController

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

    34 parts