A small carrier PCB that joins a WEMOS/LOLIN D1 Mini (ESP8266) to a Sensirion SCD30 CO₂ / humidity / temperature sensor over I²C. Both modules socket in and lift out. 36 × 57 mm, two layers.
rev A · 2026-08-08
Fug is British English for the stale, stuffy, close air of a room that's been shut up too long with too many people in it.
That's precisely what this board detects. Rising CO₂ is the thing you feel as a headache and a fading attention span an hour before you think to open a window — outdoor air sits near 420 ppm, a closed meeting room reaches 1500 ppm easily, and somewhere past that you stop thinking clearly without ever noticing why.
So the sensor isn't measuring a chemical so much as a condition, and English already had a word for that condition.
Both modules deliberately overhang the board edge, joined by a narrow neck:
Roughly to scale — the board is 36 × 57 mm:
┌────────────────────────┐
│ │
│ SCD30 socket │ sensor end, 20 mm
│ + passives │ module overhangs upward
│ │
│ │
└────────┐ ┌────────┘
│ │ neck: 10 mm long, 8 mm wide
│ │ 5 traces, no copper pour
│ │
┌────────┘ └────────┐
│ │
│ │
│ D1 Mini socket │ ESP8266 end, 27 mm
│ │ USB overhangs downward
│ │
│ │
│ │
└────────────────────────┘
36 mm
Three things drive that shape:
- The SCD30 must not touch the host PCB. Sensirion's Assembly Guide requires an air gap under the module and forbids anything but the pins bearing load. Board underneath would only trap stagnant air against the down-facing sensing apertures.
- The neck is thermal isolation. It is the only conduction path from the ESP8266 to the sensor, and it carries five discrete traces — narrowed to 0.2 mm purely to restrict heat flow — and no ground pour. Sensirion ask for the distance between the sensor and heat sources to be "as large as possible"; the neck is that, in board form.
- The USB end faces outboard, so the cable never runs under the sensor's apertures.
Self-heating matters far more than it sounds: 3 °C of it puts temperature 7× outside
spec and humidity 3× outside, while barely touching CO₂. The neck exists to protect
T and RH. See DESIGN.md §D2a.
Grab fug-<tag>-gerbers.zip from the latest
release, or build it yourself:
./gen/fug.py # writes fab/fug-gerbers.zipThe archive holds the nine manufacturing layers, the drill file and the Gerber job file — nothing else, so a fab's parser has only what it needs. It isn't committed: it's a build product, published by CI when a version tag is pushed. Because the build is byte-reproducible, the release asset and your local build from the same commit are identical.
The major version is the board revision. rev A is v1.x.y; a rev B board
would be v2.0.0. Minor and patch releases change documentation, generators or
checks, never copper — so upgrading within a major means the boards in your hand
are still the boards these files describe. The definitive marking is on the board
itself: rev A · 2026-08-08, back silkscreen.
| Setting | Value |
|---|---|
| Layers | 2 |
| Size | 36 × 57 mm |
| Thickness | 1.6 mm |
| Copper | 1 oz (35 µm) |
| Finish | HASL is fine |
| Min track / hole | 0.2 mm / 0.4 mm |
The copper weight matters more than it looks: 2 oz would double the neck's thermal conduction, which is the one parameter the whole outline was shaped around.
WayWayWay on the back silk is not a typo. PCBWay stamp a product number on
every board; left unspecified they put it where they like, which on a board this
small lands it in the fug / rev / bboe block. Their free "Specify a location"
option works by substituting the real number for the literal string WayWayWay,
so it is a placeholder that never appears on the finished board. It sits at the
top of the sensor end, clear of everything. Keep it — and if you order elsewhere,
delete that one gtext line in gen/gen_pcb.py.
Before ordering, print fab/footprint-check-1to1.pdf and lay a real SCD30 on it.
The SCD30 footprint is home-made. It was derived from the datasheet and cross-checked
against Sensirion's own STEP to a hundredth of a millimetre — and for rev A it has
also been checked against a physical module on the 1:1 sheet, which is the only step
that catches a mistake shared by both sources.
The sheet is US Letter landscape, and the print dialog must say Scale: 100 %, never "Scale to Fit" — fitting shrinks the artwork a few percent and the check then reports a footprint error that isn't there. Measure the board outline first: it must read 36.00 × 57.00 mm. If that's right the scale is true and the rest means something; if it isn't, fix the printer before believing anything else on the page.
Four capacitors get soldered. Everything else is either a socket or deliberately left empty.
| Ref | Part | Note |
|---|---|---|
| C1 | 100 µF X5R 1210 | bulk at SCD30 VDD |
| C2 | 100 nF X7R 0805 | decoupling at SCD30 VDD |
| C3 | 10 µF X7R 0805 | bulk at D1 Mini 3V3 |
| C4 | 100 nF X7R 0805 | decoupling at D1 Mini 3V3 |
| J1 | 1×7 female header | SCD30 socket |
| J2, J3 | 1×8 female header | D1 Mini socket |
| — | 1×7 male pin strip | solders into the SCD30 itself |
| — | 3 × M2.5 hardware | Ø6 mm standoffs clear |
| R1, R2 | 10 kΩ 0805 | do not populate |
| R3 | 470 Ω 0805 | do not populate |
| TP1 | 1×4 header | do not populate |
R1/R2 must stay empty. The SCD30 has internal 45 kΩ pull-ups to 3.0 V and its
input maximum is also 3.0 V; adding 10 kΩ to 3.3 V drags the idle bus to ≈3.25 V,
over the sensor's absolute maximum. They're footprinted only in case a third I²C
device ever needs a stiffer bus. R3 bridges GPIO16→RST for deep sleep — fit it only
if you want that. All four carry a real dnp attribute and are excluded from the
pick-and-place file.
Verified against the SCD30 Interface Description v1.0 §1.1:
- Address 0x61, CRC-8 (poly 0x31, init 0xFF)
- Run the bus at 50 kHz. 100 kHz is the ceiling; 50 kHz is what Sensirion recommend.
Wire.setClockStretchLimit(200000)— microseconds, so 200 ms. Stretching is 30 ms normally (not the 12 ms repeated in most library docs) and up to 150 ms once a day during internal calibration. Current ESP8266 cores default to exactly 150 ms — equal to the worst case, with no margin. Older cores default to 230 µs and fail immediately.- No repeated start. Write the command, STOP, wait > 3 ms, then a fresh START.
In Arduino terms:
Wire.endTransmission()with the defaulttrue, neverfalse. - Boot-up is < 2 s. Poll data-ready (0x0202) rather than assuming.
- Set the temperature offset register. This matters more than the neck does — it is Sensirion's own prescribed remedy and it's what makes T/RH trustworthy.
main is protected: it takes no direct pushes, and every commit that lands must
already have passed CI on a branch.
git switch -c some-change
# ... edit, then ./gen/fug.py to rebuild and re-check ...
git commit -a
git push -u origin some-change # lint + build run here
git switch main
git merge --ff-only some-change # main's tip is the commit CI passed
git pushFast-forward only. Squash and rebase merges are disabled on the repository,
because both rewrite commits — and a rewritten commit is one CI never built. A
fast-forward puts the exact commit that passed onto main, which is what makes
"the required checks passed" mean anything about what is now on main.
The required checks are lint and build. build is the whole thing: the six
design checks, ERC, DRC, the fab-contents check, and a rebuild-and-diff proving
the committed outputs match their source.
Two consequences worth knowing:
- The green "Merge pull request" button creates a merge commit, not a
fast-forward. GitHub has no fast-forward button. If you open a PR, merge it
locally with
--ff-onlyas above; the PR closes itself when the commits land. pre-commitrefuses commits onmain(no-commit-to-branch), which is the same rule enforced one step earlier, on your own machine.
uv sync && uv run pre-commit install # once per checkoutEvery KiCad file except one is generated from scripts, so the geometry is derived rather than typed:
./gen/fug.pyThe exception is fug.kicad_pro, which holds KiCad's own project and UI state
alongside the DRC ruleset. Generating 16 kB of that would churn on every KiCad
version for no benefit, so it is committed as-is — and gen/checkrules.py asserts
the constraints DRC is actually measured against, since a hand-loosened
min_track_width would otherwise weaken the gate invisibly.
Paths are derived from the scripts' own location, so the checkout can live anywhere and be named anything.
Generating the board needs nothing but stock Python. The seven stock KiCad
symbols and six footprints it references are subset into vendor/kicad-libs/
(39 kB, from 10 MB of upstream libraries), so gen_fp.py, gen_sch.py and
gen_pcb.py run anywhere:
python3 gen/gen_fp.py && python3 gen/gen_sch.py && python3 gen/gen_pcb.pyThe full build needs KiCad 10 with kicad-cli on $PATH — for zone
filling, ERC, DRC and the fab exports, which are real computation rather than
data lookup. If KiCad isn't in the usual place, set KICAD_SHARE and
KICAD_PYTHON (the interpreter that can import pcbnew).
The build calls vendor/fetch.sh for you to pull Sensirion's SCD30 STEP model,
which isn't redistributed here. If that download fails the build continues and
says so — no manufacturing output depends on the model.
Footprint → schematic → board → zone fill → checks → fab/. Several checks run
beyond ERC and DRC, each because something got through:
| Check | Catches |
|---|---|
--schematic-parity |
board and schematic disagreeing — found R1/R2 swapped and no DNP flags anywhere |
checkzones.py |
a copper zone that was never filled — Gerbers with no pour, passing every other check |
checkrules.py |
a loosened DRC rule in fug.kicad_pro, the one file no script generates |
checkkeepout.py |
copper inside a keepout, which KiCad's own DRC does not check at all |
checksilk.py |
silk outside Edge.Cuts, which the fab clips and never prints; and an outline that isn't closed |
checklabels.py |
a label sitting nearer some other part than the one it names |
probe.py |
the SCD30 3D model drifting off its footprint |
checkfab.py |
an exported Gerber that no longer holds this board — wrong layer counts, wrong outline size |
stale-fab/ guard |
outputs describing a previous board |
The build fails loudly if any of them do, but never lets a failing check skip the fab
export — that's how fab/ once ended up 12 minutes stale while looking complete.
Every stage returns a real exit code, and ERC and DRC are gated on more than that:
this board carries warnings it is expected to carry, so gen/fug.py asserts their
counts. Any error fails the build, and so does a change in the warnings.
CC BY-SA 4.0 — see LICENSE, with scope and third-party
attribution in NOTICE.
Copy it, modify it, build it, sell it. Two conditions: credit the original, and license your changes under the same terms. That second one is the point — if you improve this board, the improvement stays available to everyone who might build it.
vendor/kicad-libs/ is part of the KiCad Libraries and carries its own
CC BY-SA 4.0 terms. Same license, so nothing special applies.
- Antenna clearance is 5.3 mm. Shortening the neck from 16 to 10 mm brought the D1 Mini's antenna closer to the sensor-end ground pour. Predicted detuning, not measured — the most likely disappointment on this board.
- The paste stencil covers the DNP resistors.
kicad-clihas no option to exclude them. Harmless: with nothing placed, paste reflows into separate bumps and the 0.7 mm pad gap is far too wide to bridge. - Nothing has been assembled. rev A is DRC-clean and its SCD30 footprint has been checked against a physical module at 1:1, but no board has been populated or powered. Thermal isolation is calculated (1.78–2.42 mW/K through the neck), not measured, and the air path across the 10 mm gap is unquantified.
DESIGN.md the reasoning — every decision, and the ones that were wrong first
gen/ fug.py is the CLI; every stage is also a subcommand
lib/ SCD30 symbol and footprint
vendor/ third-party files, fetched not committed — see vendor/README.md
fab/ generated outputs; wiped and rebuilt every run
.github/workflows/ CI: publishes the manufacturing package on a version tag
fug.kicad_pcb / _sch generated — edit the scripts, not these
Builds are byte-reproducible: the same commit always produces the same files,
down to UUIDs and embedded timestamps. So a rebuild that changes nothing leaves
git status clean, and when fab/ does change, the diff is the change.
Tag a commit that is already on main, so the release describes source that has
passed CI and cannot be rewritten out from under it:
git switch main && git pull
git tag -a v1.0.1 -m "what changed"
git push origin v1.0.1Annotated (-a), and three-part: the major number is the board revision, per
Getting one made above.
CI rebuilds from the tag, runs every check, verifies the rebuild matches the committed tree, and attaches the Gerber package, the 1:1 check sheet, the schematic, the BOM and the pick-and-place file to a GitHub release.
DESIGN.md is the interesting file. It records what was decided and what was got
wrong on the way — a mirrored footprint that would have parked the sensor on top of
the ESP8266, a test-point legend that put "3V3" nearest the ground pin, silk printed
off the edge of the board.