Skip to content

Repository files navigation

fug

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


The name

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.


What it looks like

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.


Getting one made

Grab fug-<tag>-gerbers.zip from the latest release, or build it yourself:

./gen/fug.py              # writes fab/fug-gerbers.zip

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


Assembly

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.


Firmware

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 default true, never false.
  • 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.

Working on it

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 push

Fast-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-only as above; the PR closes itself when the commits land.
  • pre-commit refuses commits on main (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 checkout

Building the files

Every KiCad file except one is generated from scripts, so the geometry is derived rather than typed:

./gen/fug.py

The 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.py

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


License

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.

Known limitations

  • 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-cli has 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.

Layout

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.

Cutting a release

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

Annotated (-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.

About

Carrier PCB joining a WEMOS D1 Mini (ESP8266) to a Sensirion SCD30 CO2/RH/T sensor. Script-generated KiCad.

Topics

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages