Skip to the content
PCBKitNew board
2 layers65 × 76 mmRaspi HAT, the printed circuit board in 3D

Open source firmware for a automation of vacuum systems controlled using Raspberry pi

Size
65 × 76 mm
Copper layers
2
Thickness
1.6 mm
Parts
36
Nets
57
Pads
143
Tracks, vias
0, 0
Pours
1
Routed
Not yet
KiCad
KiCad 10.0 (file 20260206)
Created
28 Jul 2026
Updated
8 Oct 2026

Overview

Raspi HAT is an open-source printed circuit board by 4rynv, from the 4rynv/VacDepoctrl repository on GitHub. It is a 2-layer board of 65 × 76 mm with 36 parts on 57 nets.

It is built around the LM358, CAT24C256LI and BC847B, with the 1N4148W among its other parts. Its parts include 13 resistors, 9 connectors, 3 ICs, 2 diodes and 2 transistors.

Main components

4

README

README.md

VacDepoctrl — Vacuum Deposition Controller

A Raspberry Pi-based vacuum process controller. The current process is DC magnetron sputter deposition — chamber pump-down, argon gas flow, plasma ignition, and sputtering via a live Tkinter GUI with scrolling sensor graphs and a hardware-interlocked state machine. A thermal evaporation mode is planned as a second process on the same rig control stack.

VacDepoctrl GUI


Table of Contents


Hardware Overview

ComponentModelInterfaceNotes
MicrocontrollerRaspberry Pi 5 (BCM GPIO)—Host for all control logic
Pirani gaugeACE Instruments DHPG-015Analog 0–10VDivided to 0–3.3V before ADC (ratio 0.33)
ADCADS1115I2C bus 1 @ 0x48Pirani on A0 (divided), MFC feedback on A1 (divided), turbo RPM tach on A2
MFCMKS 1179AAnalog 0–5V setpoint0–700 sccm Argon
DACMCP4725I2C bus 1 @ 0x60Level-shifted to the MFC's full 0–5V setpoint range — see below
DAC bufferLM358P op-ampAnalogBuffers + level-shifts the DAC output up to 0–5V; DAC alone cannot drive the MFC setpoint input
Turbo enableOpto-isolator (4N35)GPIO 17 (BCM, phys 11)Marginal — needs BC547 output buffer, see below
MFC valve closeEmergency shutGPIO 27 (BCM, phys 13)Pulls MFC valve closed on demand
Turbo inlet valveRelay via BC547GPIO 22 (BCM, phys 15)HIGH = valve OPEN, LOW = valve CLOSED (relay NC contact)
Energy meterPZEM-004T-100AModbus-RTU over CP2102 USB-TTL, /dev/ttyUSB0AC voltage/current/power on the variac output; plasma-ignition sensing. Optional at startup — see below

Raspberry Pi pin connections

Full 40-pin header and device wiring diagram

FunctionBCM GPIOPhysical header pinConnection
I2C1 SDAGPIO23ADS1115 SDA + MCP4725 SDA
I2C1 SCLGPIO35ADS1115 SCL + MCP4725 SCL
Signal ground—6 (or another GND)ADC/DAC signal ground
Turbo enableGPIO1711Opto-isolator input circuit
MFC emergency closeGPIO2713Existing MFC valve-close interface
Turbo inlet valveGPIO2215BC547 relay-driver input

GPIO23/24 (physical pins 16/18) are no longer the I2C connection.

I2C: Hardware Bus 1 on GPIO 2/3

The Pi 5 uses hardware I2C1 at 100 kHz. Configure /boot/firmware/config.txt:

ini
dtparam=i2c_arm=on
dtparam=i2c_arm_baudrate=100000
SignalBCMPhysical pin
SDAGPIO 23
SCLGPIO 35
GNDGround6

Connect both ADS1115 and MCP4725 to this bus; keep pull-ups at 3.3 V. Power down before moving SDA/SCL from the previous GPIO23/24 wiring. The code uses ExtendedI2C(I2C_BUS_NUMBER) with I2C_BUS_NUMBER = 1. The older software-bus setup was a workaround for reported damaged GPIO2/3 on a previous setup; this configuration assumes functioning GPIO2/3. Reboot after changing boot configuration; the operator performs device checks and the first controller launch with equipment in a safe condition.

Voltage Divider (Pirani Output)

The DHPG-015 outputs 0–10V. A 3-resistor voltage divider scales this to 0–3.3V for the ADS1115 (divider ratio 0.33). All pressure thresholds in config.py are expressed in post-divider (ADC-side) volts. The full manufacturer calibration table lives in hardware/datasheets/Di-Hi-Pr-Pirani output voltage.pdf and is transcribed as _PIRANI_CAL in main.py.

DAC Output Buffer + Level Shifter (LM358P)

The MCP4725 cannot source enough current to drive the MFC setpoint input directly — above ~1.25V the output voltage collapses under load even though it reads correctly when unloaded. An LM358P stage buffers the DAC output and shifts it up to the MFC's full 0–5V setpoint range before it reaches the MFC (ARGON_DAC_VREF = 5.0 in config.py reflects this — commanded flow covers the full 0–MFC_FULL_SCALE range, not a fraction of it):

text
DAC OUT ──────────────┬──── LM358 IN+ (pin 3)
                       │
                  (feedback) IN- (pin 2) ──── OUT (pin 1) ──── MFC setpoint
LM358 VCC (pin 8) ── Pi 5V
LM358 GND (pin 4) ── GND

Power the op-amp from the Pi's 5V rail, not 3.3V. The diagram above shows the original current-buffer wiring; the gain-setting feedback network that shifts the output up to 0–5V isn't captured in this ASCII diagram.

Turbo Inlet Valve (GPIO 22 → BC547 → Relay → Solenoid)

The turbo inlet solenoid valve (~50 mA @ 25–26 V) is switched by a relay, driven by a BC547:

text
GPIO22 ──470Ω── base   BC547   collector ── relay coil ── +5V
                          emitter ── GND         ▲
                                          1N4007 across coil
                                          (band toward +5V)

Valve circuit ── relay COM + NC contacts (isolated from the Pi)
  • Valve is wired through the relay's NC (normally closed) contact: relay released = valve circuit shorted = valve CLOSED; relay energized = valve OPEN.
  • Logic (in _poll(), turbo_valve_step()): valve held CLOSED at all times; latched OPEN during VENTING once turbo pump RPM (tach on ADC A2, see Turbo Pump RPM) sustains TURBO_VALVE_CONFIRM_SAMPLES consecutive reads at or below TURBO_VALVE_OPEN_RPM_MAX (35000 RPM); re-closed automatically on leaving VENTING. The debounce prevents a single corrupted I2C read from opening the valve; the latch prevents relay chatter once open. Driven by rotor speed directly rather than inferring it from chamber pressure — the earlier pressure-based gate (TURBO_VALVE_OPEN_MBAR) has been replaced.
  • Fail-safe: if the Pi crashes or loses power, the relay releases and the valve fails closed — the safe direction for the turbo inlet.
  • GUI shows live actuation state (T-VALVE : CLOSED / OPEN (venting)).

Turbo Pump RPM (ADC A2)

  • Tach/speed output, 0–3.3V linear = 0–90000 RPM (TURBO_RPM_VOLTAGE_FULL_SCALE, TURBO_RPM_FULL_SCALE), read by TurboRPMController (turbo_rpm.py) every poll tick regardless of state.
  • Displayed in its own Turbo Pump panel (T-RPM, T-RPM V, a scrolling RPM graph, plus OPTO and T-VALVE state — both turbo-side actuators, moved here from the Pirani panel) — below the Plasma panel, beside the Pirani/MFC panels.
  • During VENTING, once RPM drops to/below VENTING_PUMP_OFF_PROMPT_RPM (2000), the Pirani panel's status message turns orange and prompts the operator to turn off the primary/roughing pump — an early, informational cue, not a gate. The actual gate on completing the vent is separate — see Vent Completion Confirmation below.
  • Feeds the turbo inlet valve interlock above and the cross-sensor checks below.

Vent Completion Confirmation

Reaching atmospheric pressure during VENTING does not by itself complete the vent. Earlier, sm.update() transitioned VENTING → IDLE purely from Pirani voltage — but the turbo inlet valve closes the instant the state leaves VENTING (turbo_valve_step()), so that auto-transition could reclose the valve before the operator had any real chance to react to the "turn off the primary pump" cue above, let alone actually do it.

VENTING → IDLE is now owned exclusively by _poll() (the same ownership pattern as ARGON_FLUSH → PLASMA_IGNITING), gated by vent_complete_ready(): both atmospheric pressure and an explicit operator click are required.

  • Once Pirani reaches VENTING_COMPLETE_VOLTAGE during VENTING, _state["vent_complete_pending"] latches True — the Pirani panel's status message turns red ("Atmosphere reached — turn OFF the primary/roughing pump, then click Confirm Pump Off") and a Confirm Pump Off button appears in Controls (hidden otherwise, same show/hide pattern as Clear Fault).
  • Clicking it sets _state["vent_complete_confirmed"]; _poll() picks this up on its next tick and, if pressure is still at/above atmosphere, transitions to IDLE — closing the turbo inlet valve as part of the normal VENTING-exit path.
  • Both flags reset on entering IDLE or VENTING (handle_hardware_interlocks()), so a stale confirmation from a previous cycle can never auto-complete the next one.

Energy Meter (PZEM-004T-100A, plasma ignition sensing)

Reads AC voltage/current/power/energy/frequency/power-factor on the sputtering supply's variac output and uses the current-draw step when the discharge strikes to detect plasma ignition. Talks Modbus-RTU over a CP2102 USB-TTL adapter plugged into the Pi's USB port (pzem_meter.py) — a completely separate transport from the ADS1115/MCP4725 I2C bus above.

[!warning] Mains AC hazard The PZEM's voltage-sense terminals and current-transformer (CT) clamp are on live mains-derived AC, not the Pi's low-voltage side. Work de-energized (variac and upstream breaker off), verify with a meter before touching any terminal, don't work alone, and don't exceed the CT's 100A rating. The PZEM's internal isolation protects the TTL/USB side from the AC side once wired correctly — it does not protect you while wiring it.

Wiring

AC side (mains, de-energized while wiring):

  • Connect the PZEM's L / N voltage-sense terminals directly across the variac's OUTPUT (not its input) — the two wires feeding whatever the variac drives (typically an HV/isolation transformer feeding the sputtering cathode supply).
  • Clamp the split-core CT around the same variac output's live (L) conductor — do not clamp both L and N together (their fields cancel and it reads ~0A), and do not clamp the input side.
  • Wiring the CT and voltage sense to the same side (output) is required for the power reading to mean anything — the PZEM computes P = V × I × PF locally, so V and I must reflect the same node.
  • This placement means voltage/current/power all read what's actually delivered to the sputtering supply, independent of the variac's own tap position — exactly the load signature that changes when the discharge strikes (impedance drop → current step).

TTL side (isolated, low-voltage, safe to wire live):

PZEM TTL pinCP2102 pinNotes
5V5VPZEM-004T v3.0's TTL interface requires 5V — verify the CP2102's voltage-select jumper (if it has one) is set to 5V, not 3.3V
GNDGNDCommon ground
TXDRXDCross — PZEM transmits into the adapter's receive pin
RXDTXDCross — PZEM receives from the adapter's transmit pin

Plug the CP2102 into any Pi USB port. It enumerates as /dev/ttyUSB0 (check with ls /dev/ttyUSB*; use a /dev/serial/by-id/... symlink instead if more than one USB-serial device is ever attached). The Pi user needs the dialout group to access it without root:

bash
sudo usermod -a -G dialout $USER
# log out and back in for the group change to take effect
Protocol

Modbus-RTU, 9600 baud 8N1, factory-default slave address 0xF8. pzem_meter.py hand-rolls the request framing and CRC16 (matching how mfc_control.py hand-rolls the MCP4725 I2C write) rather than pulling in a general Modbus library — reads input registers 0x0000–0x0009 (voltage, current, power, energy, frequency, power factor, alarm) every poll tick.

Optional hardware, unlike the ADS1115/MCP4725 checked in _startup_self_test(): this sensor may not be wired up yet. PZEMController.read() never raises — if the serial port can't be opened or the meter doesn't respond, it returns ready=False and a zeroed reading. main.py's two PZEM cross-sensor checks (below) only evaluate if e["ready"], so nothing they do can fire until the meter is actually installed and responding — deploying this code cannot regress a rig that doesn't have the PZEM wired yet.

Plasma Detection

plasma_detect_step() applies the same debounced ON/OFF hysteresis pattern as the turbo enable opto (TURBOOPTO_ON/OFF_THRESHOLD) to the PZEM's current reading: PZEM_PLASMA_CURRENT_ON_A (default 1.0A, unvalidated placeholder) held for PZEM_PLASMA_CONFIRM_SAMPLES (3) consecutive ticks latches plasma_detected True; it clears immediately below PZEM_PLASMA_CURRENT_OFF_A (0.6A). Like every other cross-sensor threshold in this repo, these must be bench-measured against the real supply's no-load vs. struck-plasma current before they mean anything.

plasma_detected drives the Plasma GUI panel (replacing the old placeholder) and the PLASMA_IGNITING → SPUTTER_READY confirmation, which now has three paths:

PathTriggerDefault
ManualOperator clicks Confirm PlasmaAlways available
Hybrid cueConfirm Plasma button highlights green once plasma_detected is True during PLASMA_IGNITING — operator still clicksAlways on
Auto-confirmOperator arms the Auto-Confirm toggle in the Plasma panel; while armed, a confirmed detection advances the state machine the same way a manual click would, via the same _complete_plasma_confirmation() pathOff by default

Auto-confirm is an explicit operator opt-in, not the default, because PZEM_PLASMA_CURRENT_ON_A is unvalidated — the same reasoning as every other "unvalidated until confirmed on the bench" threshold in config.py. PLASMA_IGNITING's own PLASMA_IGNITION_TIMEOUT abort-to-READY path is unchanged and still the fallback if neither manual nor auto-confirm fires.

Bring-up notes / troubleshooting
  • Board-specific terminal layout, not the generic datasheet. On Aryan's board, the AC-side 4-terminal block is L / N (voltage sense, across variac output) and two CT-lead terminals — but the position of these on the physical block didn't match the generic PZEM-004T datasheet. The L line initially went to the board's 10A direct-pass-through terminal position instead of the 100A/CT-clamp position; moving it to the correct terminal (found via the board's own silkscreen, not the datasheet) fixed it. Always read your specific board's silkscreen, not a generic online diagram.
  • The PZEM's measurement MCU is powered from the AC L/N line itself, not the TTL 5V header. Symptom if you wire TTL first and haven't energized L/N yet: CP2102 TX blinks (host is sending fine), PZEM never replies — looks like a dead unit but is just unpowered. Resolves once L/N is actually connected to live AC.
  • A struck-but-undetected plasma likely means the CT isn't sensing the load, not a threshold-tuning problem. Hit 2026-07-12: operator visually confirmed plasma and clicked Confirm Plasma, PLASMA_IGNITING → SPUTTER_READY succeeded, then pzem_plasma_absent_check() force-shut-down 5s later with the PZEM reporting 0.00 A the entire time. A true zero (not "some current below 1.0A") points at the sensor path, not calibration — most likely the CT clamp isn't actually installed on the load line yet (this repo's CT bring-up was still pending a bench test on the real variac-output load as of the prior session), or is clamped over both L and N together (fields cancel to ~0A), rather than L alone.
  • If real current draw is small relative to the CT's 100A rating, wind multiple turns of the live conductor through the clamp before closing it. A CT senses ampere-turns, so N turns gives N × I_actual on the reading — useful because a 100A-rated core sensing a sub-1A sputtering load sits at the very bottom of its response curve, where accuracy/noise floor issues are common. Divide the reported current/power by N in software to recover the true value, or (simpler, since only the comparison matters) scale PZEM_PLASMA_CURRENT_ON_A/_OFF_A by N instead. Sanity-check the real current independently first (a separate clamp meter, or the sputtering supply's own front-panel readout) before deciding how many turns to use.

Cross-Sensor Consistency Checks

Every threshold elsewhere in this doc compares one sensor to one fixed value. These instead compare two live readings to each other — catching a case where each reading is individually "in range" but the two together don't make physical sense. All feed the global safety gate below: a sustained flag forces a full hardware cutoff, not just a status message. Grace periods are placeholder defaults (favor fewer false positives over noise from normal settling time) — tune once measured against real spin-up/response/settling behavior on the bench. Because a false positive forces a real shutdown, treat these as unvalidated until confirmed on the bench.

CheckComparesFlags whenConfig
turbo_spinup_check()Turbo enable opto (GPIO 17) vs turbo RPM (ADC A2)Opto commanding the turbo on for TURBO_SPINUP_GRACE_SECONDS (60s) while RPM stays below TURBO_RPM_STALL_THRESHOLD (10000) — turbo/RPM-sensor/wiring fault, or the turbo never spun up at allconfig.py
turbo_rpm_drop_check()Same opto/RPM pair, but tracks a confirmed-healthy peakRPM has exceeded TURBO_RPM_STALL_THRESHOLD at least once (confirmed healthy spin), then drops back below it for TURBO_RPM_DROP_GRACE_SECONDS (10s) while opto is still on — a running turbo failing/degrading, distinct from "never spun up"config.py
mfc_flow_check()Commanded flow (DAC/flow_target) vs measured flow (A1 feedback)Commanded ≥ MFC_FLOW_MIN_COMMANDED_SCCM (20) but measured stays below MFC_FLOW_RESPONSE_FRACTION (0.2×) of commanded for MFC_FLOW_GRACE_SECONDS (5s) — MFC not responding, dead unit, or empty gas supplyconfig.py
dac_saturation_flow_check()DAC output voltage vs measured flowDAC pegged within DAC_SATURATION_MARGIN_V (0.1V) of ARGON_DAC_VREF while measured flow stays below DAC_SATURATION_FLOW_FRACTION (0.5×) of MFC_FULL_SCALE, for DAC_SATURATION_GRACE_SECONDS (10s) — the PID has given up on proportional correction and is commanding max output with nothing coming back: stuck valve, blocked line, or empty supply. A stronger signal than mfc_flow_check aloneconfig.py
pressure_convergence_check()Current Pirani voltage vs the active PID target (flush/sputter)|current - target| exceeds PRESSURE_CONVERGENCE_TOLERANCE_V (0.3V) for PRESSURE_CONVERGENCE_GRACE_SECONDS (90s) despite the control loop running — leak, stuck valve, or MFC fault. Raised from 20s after a bench run showed a legitimate large setpoint step (e.g. PLASMA_IGNITING → SPUTTER_READY) needs longer than that to converge given the deliberately gentle PID tuningconfig.py
pzem_plasma_absent_check()Process state (SPUTTER_READY/SPUTTERING only) vs PZEM plasma_detectedIn SPUTTER_READY or SPUTTERING — both assume plasma is already lit — with no PZEM-confirmed current draw for PZEM_PLASMA_ABSENT_GRACE_SECONDS (5s): an arc dropout or the plasma extinguishing mid-run. PLASMA_IGNITING is deliberately excluded (PLASMA_IGNITION_TIMEOUT already owns "never struck"). Only evaluated once the PZEM is ready — see Energy Meter section aboveconfig.py
pzem_power_unexpected_check()Process state (everywhere except PLASMA_IGNITING/SPUTTER_READY/SPUTTERING) vs PZEM plasma_detectedPlasma-level current draw while nowhere near attempting ignition (IDLE/PUMP_DOWN/READY/ARGON_FLUSH/VENTING) for PZEM_POWER_UNEXPECTED_GRACE_SECONDS (5s) — stuck relay, live supply, or wiring fault. Only evaluated once the PZEM is readyconfig.py

All are pure functions (main.py, next to turbo_valve_step()) — no I/O, no wall-clock access internally (the current timestamp is passed in), so they're tested directly like the calibration/valve logic.

Global Safety Gate

Everything above this point in the doc is a local mechanism: a specific check for a specific failure, conditioned on which state you're in. The global gate is different — evaluated every poll tick regardless of current state, and any single condition tripping it forces the exact same full cutoff: turbo valve closed (direct GPIO write), MFC flow/valve/opto off, state forced to IDLE (sm.emergency_stop()), and the trip latches (see below).

ConditionDetectsConfig
Sustained cross-sensor flagAny of the checks above, once their own grace period elapsessee table above
sensor_range_check()Any ADC-derived voltage (Pirani, MFC feedback, turbo tach) outside SANE_VOLTAGE_MIN/SANE_VOLTAGE_MAX for SENSOR_RANGE_CONFIRM_SAMPLES (5) consecutive ticks — disconnected, shorted, or miswired sensor, not a legitimate reading at any pressure/flow/speed. Suppressed for STARTUP_GRACE_SECONDS (5s) after the poll loop starts — real analog settling transients (MFC feedback observed at -0.15 to -0.23V for a few seconds after power-on) tripped this otherwiseconfig.py
Consecutive poll-loop exceptionsCONSECUTIVE_EXCEPTION_LIMIT (5) ticks in a row raise — occasional I2C/EMI glitches are tolerated (as before), but this many in a row is a real fault, not noise. Also suppressed during STARTUP_GRACE_SECONDSconfig.py
Heartbeat / watchdogThe GUI thread (_refresh()) checks _state["last_tick"]; if the poll loop hasn't updated it within HEARTBEAT_TIMEOUT_SECONDS (2.0s), the poll thread is presumed stalled/hung. Checked from the GUI thread specifically — a hung poll thread can't detect its own hangconfig.py

All of this routes through _force_safe_shutdown(reason) (main.py) — safe to call every tick for as long as a condition persists (GPIO/state-machine calls are idempotent; _set_error() de-dupes the repeated log line rather than spamming it). handle_hardware_interlocks()'s own mfc/pirani cutoffs are already exception-safe (SputterStateMachine catches callback errors internally), so the only extra guarding needed is around the direct turbo-valve GPIO write, in case the poll thread hung mid-I2C-transaction.

The trip latches — a bench run found why this matters. emergency_stop() alone forces IDLE, but IDLE's own auto-resume-to-PUMP_DOWN logic (_poll()) will immediately re-enter PUMP_DOWN if pressure is already low (e.g. an external roughing pump already has the chamber down) — silently undoing the shutdown within one tick, with the process continuing as if nothing happened. _force_safe_shutdown() now sets _state["safety_tripped"], which blocks that auto-resume and shows a red banner + "Clear Fault" button in the Process State panel, plus a one-time modal alert. The IDLE branch checks this flag before its auto-transition. Clearing does not reset the underlying check's own "since" timer — if the root cause is still present, it re-trips immediately on the next tick. That's intentional: acknowledging a fault without fixing it must not let the process quietly continue.

Startup Self-Test

Runs once before the poll thread starts (_startup_self_test(), main.py): re-validates config invariants at runtime (config_sanity_failures() — same checks as TestConfigSanity, but live, so a bad future edit to config.py is caught before the app runs with a broken safety threshold; this is exactly the class of bug that shipped once, VENTING_COMPLETE_VOLTAGE at ~5 mbar instead of near-atmosphere), and confirms the ADS1115 and MCP4725 DAC actually ACK on the I2C bus. Any failure prints to console and exits — no GUI exists yet at this point in startup.

Persistent Event Log

Every operator-visible error/warning and every state transition is appended to sputter_ctrl.log (next to main.py, EVENT_LOG_FILENAME in config) via _log_event() — the GUI status bar only shows the latest message and auto-expires it after ERROR_DISPLAY_SECONDS, so this is the only place an incident can be reconstructed after the fact. _set_error() is the single choke point every error message goes through (writes _state["error"] and logs, de-duping repeats); handle_hardware_interlocks() logs every transition unconditionally, regardless of which state it's entering.

Do not drive relay coils or solenoids from a 4N35 directly — see the hard-won rule below.

Opto/Load Rule of Thumb (learned the expensive way)

A 4N35's usable output current is roughly CTR (~100%) × LED current (~10 mA max from a GPIO) ≈ 10 mA. Asked for more, it goes linear and drops the supply across itself (looks like a mystery series resistor). Loads measured on this rig:

LoadDraw4N35 alone?
Turbo enable input~15–30 mANo — needs BC547 buffer (pending)
Relay coil (5V)~60 mANo — driven by BC547 directly, opto not needed (relay contacts provide the isolation)
Inlet solenoid~50 mA @ 26VNo — relay contacts switch it

The turbo enable opto (GPIO 17) currently only works with excess LED drive; the proper fix is a BC547 buffer on its output (opto pin 5 → enable+, pin 4 → BC547 base, 10 kΩ base–emitter, BC547 C/E across the enable terminals). Flyback diodes go in parallel across coils (reverse-biased), never in series.


Software Architecture

text
main.py
├── Hardware init (GPIO, ExtendedI2C bus 1, ADS1115)
├── Shared state dict (thread-safe, _lock)
├── Polling thread (_poll) — runs every POLLING_INTERVAL (0.2s)
│   ├── Reads Pirani + MFC + turbo RPM (A2) + PZEM (variac current) sensors
│   ├── Drives MFC pressure PID loop
│   ├── Drives turbo inlet valve latch (GPIO 22), gated on turbo RPM (not pressure)
│   ├── Debounced plasma_detect_step() from PZEM current -> plasma_detected
│   ├── Auto-confirms PLASMA_IGNITING -> SPUTTER_READY if the operator has
│   │   armed Auto-Confirm and plasma_detected is True
│   ├── Calls sm.update() for auto-transitions
│   ├── Converts Pirani voltage <-> mbar via log-linear calibration table
│   ├── End-of-tick safety cutoff re-assert (E-STOP race guard)
│   └── Updates shared state dict
├── Tkinter GUI (main thread)
│   ├── _refresh() — runs every GUI_REFRESH_INTERVAL (200ms) via root.after()
│   ├── Scrolling graphs (ScrollingGraph)
│   ├── Live pressure readout in mbar (adc_voltage_to_mbar)
│   ├── Manual set-point entry fields (sputter target mbar, argon PSI)
│   ├── Error display with ERROR_DISPLAY_SECONDS auto-expiry
│   └── Button callbacks → shared _try_*() operator commands
├── Web dashboard server (daemon threads — web_ui.py, see Web Dashboard)
│   ├── GET /events — SSE stream of _state snapshots to each browser
│   └── POST /command — dispatches the SAME _try_*() operator commands
└── Hardware interlock callback
    └── handle_hardware_interlocks() — fires on every state transition

The polling thread and GUI thread never share objects directly — all data passes through _state dict protected by _lock. On window close, _stop_event is set and the polling thread is joined (2 s timeout) before hardware shutdown and GPIO.cleanup().

E-STOP race guard: the poll loop reads the state once per tick; if an E-STOP/vent transition lands mid-tick, the interlock callback's cutoff could be overwritten by in-flight valve_release()/set_flow() calls. The poll loop therefore re-asserts the full safety cutoff at the end of any tick in which the state machine moved to IDLE/VENTING — the cutoff is always the last hardware write of the tick.

Error handling: errors are no longer cleared by the polling loop each tick. They persist in the status bar for ERROR_DISPLAY_SECONDS (10 s) and then auto-expire, so both operator-guard messages and the plasma-timeout message are actually readable.


File Structure

text
VacDepoctrl/
├── main.py            — Entry point: GUI, polling thread, hardware init, cal table
├── config.py          — All constants, thresholds, and pin assignments
├── state_machine.py   — SputterStateMachine class (future: per-process state machines)
├── drivers/           — one class per hardware device
│   ├── mfc_control.py — MFCController class (DAC, valve, PID loop)
│   ├── pirani.py      — PiraniController class (ADC, turbo enable opto)
│   ├── turbo_rpm.py   — TurboRPMController class (ADC A2 tach → RPM)
│   └── pzem_meter.py  — PZEMController class (PZEM-004T-100A, Modbus-RTU over USB-TTL)
├── ui/
│   ├── graph.py       — ScrollingGraph class (pure Tkinter, no matplotlib; single/dual/multi-series)
│   ├── web_ui.py      — browser dashboard server (stdlib HTTP + SSE), parallel to the Tkinter GUI
│   └── web/
│       └── index.html — the dashboard page (self-contained: inline CSS/JS, no CDN)
├── sim/
│   └── sim_hardware.py — ChamberSim + fake hardware modules for SPUTTER_SIM=1 (Simulation Mode)
├── hardware/
│   ├── Raspi_HAT/     — KiCad project for the custom Raspberry Pi HAT (schematic/PCB/BOM)
│   └── datasheets/    — Pirani gauge output-voltage table PDF
├── docs/              — GUI screenshot and other README assets
├── assets/            — desktop/app icon source (app-icon*.png, AppIcon.icns)
├── scripts/
│   ├── setup.sh       — venv + dependency installer, also installs the desktop launcher
│   ├── VacDepoctrl.desktop       — desktop launcher icon (installed by setup.sh; see Installation)

State Machine

States and Transitions

text
                    pressure rises
         ┌─────────────────────────────┐
         ▼                             │
       IDLE ──(pressure drops)──► PUMP_DOWN ──(vacuum reached + opto)──► READY
         ▲                             │                                     │
         │                    pressure rises                        [Start Argon Flush]
         │                             │                                     ▼
         └─────────────────────────────┘                            ARGON_FLUSH
                                                                         │
                                                              (0.09 mbar reached)
                                                                         ▼
                                                               PLASMA_IGNITING
                                                              │              │
                                                   [Confirm Plasma]    (2 min timeout)
                                                              │              │
                                                              ▼              ▼
                                                        SPUTTER_READY     READY
                                                              │
                                                       [Start Sputter]
                                                              ▼
                                                          SPUTTERING
                                                              │
                                                       [Stop Sputter]
                                                              ▼

Automatic Transitions

FromToConditionWhere
IDLEPUMP_DOWNPirani voltage ≤ 2.71V (10 mbar)_poll()
PUMP_DOWNIDLEPirani voltage ≥ 2.71V (pump failure/leak)_poll() + sm.update()
PUMP_DOWNREADYVoltage ≤ 0.3V AND opto enabledsm.update()
READYPUMP_DOWNPirani voltage ≥ 2.71V (pressure degraded)sm.update()
ARGON_FLUSHPLASMA_IGNITINGVoltage ≥ 1.287V (0.09 mbar)_poll() only — sole owner: arms the ignition timeout and fires _ignite_plasma()
PLASMA_IGNITINGREADY120s timeout, no plasma confirmation_poll()
PLASMA_IGNITINGSPUTTER_READYPZEM confirms plasma-level current draw, only if the operator has armed Auto-Confirm_poll() — see Energy Meter
VENTINGIDLEPirani voltage ≥ 3.0V (near-atmosphere) AND operator has clicked Confirm Pump Off_poll() only — see Vent Completion Confirmation

PLASMA_IGNITING → SPUTTER_READY is operator-driven by default (Confirm Plasma button, highlighted once the PZEM confirms detection) with an explicit opt-in Auto-Confirm toggle for the sensor-driven path — see the Energy Meter section for why auto-confirm defaults off. VENTING → IDLE similarly requires an explicit operator click regardless of pressure — there is no auto-confirm option for this one, since it's gating a real physical action (turning off the primary pump), not just a detection threshold.

Manual Transitions (Operator Buttons)

ButtonTransitionGuard
Start Argon FlushREADY → ARGON_FLUSHArgon inlet ≥ 15 psi
Confirm PlasmaPLASMA_IGNITING → SPUTTER_READYNone
Start SputterSPUTTER_READY → SPUTTERINGArgon inlet ≥ 15 psi
Stop SputterSPUTTERING → VENTINGNone
VentPUMP_DOWN, READY, ARGON_FLUSH, PLASMA_IGNITING, SPUTTER_READY, SPUTTERING → VENTINGNone — PUMP_DOWN added 2026-07-12 (previously only IDLE→PUMP_DOWN→IDLE was reachable from PUMP_DOWN; vent is now available mid-pump-down too)
Confirm Pump OffVENTING → IDLEOnly appears once atmosphere is reached; see Vent Completion Confirmation
E-STOPAny → IDLENone — immediate. Routes through _force_safe_shutdown(): closes the turbo valve by direct GPIO write (no dependency on the poll thread being alive) and latches — Clear Fault required to resume, like a real EMO reset

Hardware Interlock Callback

handle_hardware_interlocks() fires synchronously on every state transition:

  • → IDLE or VENTING: mfc.set_flow(0), mfc.valve_close(), pirani.set_opto(False), and resets plasma_ignition_start / plasma_ignition_triggered so the next flush cycle starts clean (a stale flag previously could leave PLASMA_IGNITING with no armed timeout)
  • → PUMP_DOWN: pirani.set_opto(False) — resets opto for next pump-down cycle

During VENTING the poll loop additionally holds the MFC valve closed every tick (venting is done with the turbo inlet valve, not through the MFC).


Pressure Reference

All voltages are post-divider (ADC input, ×0.33 from raw gauge output). Conversion between Pirani voltage and mbar uses log-linear interpolation against the full manufacturer calibration table (_PIRANI_CAL in main.py, source PDF in hardware/datasheets/): linear in voltage, logarithmic in pressure, matching the gauge's thermal-conductivity response. Both directions are implemented (mbar_to_adc_voltage, adc_voltage_to_mbar) and round-trip to machine precision at all 51 table points.

Pressure (mbar)Raw gauge (V)ADC input (V)Significance
99910.003.30Atmosphere (ADC saturated)
~213—3.0VENTING_COMPLETE_VOLTAGE — VENTING → IDLE (safety margin below the 3.30V atmosphere point so the transition reliably fires)
108.202.706IDLE_PRESSURE_MAX_VOLTAGE — pump-down gate
0.093.901.287ARGON_FLUSH_TARGET_VOLTAGE — plasma ignition pressure
~0.1834.241.4Turbo enable opto fires (PUMP_DOWN, transition-based; raised from 1.3V 2026-07-12)
~0.00570.910.3PUMP_DOWN_COMPLETE_VOLTAGE — PUMP_DOWN → READY gate (raised from 0.2V 2026-07-12)
0.0071.100.3630.007 mbar — default sputtering pressure

The GUI displays live pressure in mbar (bold, under the voltage readout), computed via adc_voltage_to_mbar(); reads ATM (>999 mbar) at ADC saturation.

Sputter Target Pressure (operator-settable)

An input field sets the SPUTTER_READY / SPUTTERING target pressure directly in mbar (default 0.007). Entered values are converted to an ADC voltage target via the calibration table before being passed to pressure_control_step().

Validated at entry: must be > 0 and ≤ SPUTTER_TARGET_MAX_MBAR (0.05 mbar). Values above the cap are rejected with a warning dialog rather than silently accepted — this also catches an Argon PSI value (tens) fat-fingered into this field, since any such value is far above the cap. Similarly, the Argon PSI field rejects anything below ARGON_PRESSURE_MIN_PSI (15 psi) with a warning, catching a Sputter P mbar value entered in the wrong field.

Turbo Enable Opto Logic (GPIO 17)

Transition-based, not level-based:

  • Goes HIGH the first time Pirani voltage drops below 1.4V during PUMP_DOWN (raised from 1.3V 2026-07-12)
  • Once set, stays HIGH until any state transition resets it via the interlock callback
  • This prevents opto chatter on noisy ADC readings near the threshold
  • Separate from TURBOOPTO_ON_THRESHOLD/TURBOOPTO_OFF_THRESHOLD in config.py (ADC-count based, ~1.31V/~1.87V) — those drive pirani.py's auto_opto=True hysteresis path used in READY/ARGON_FLUSH, a different code path from this PUMP_DOWN transition-based one. The two are not currently kept in sync; only the PUMP_DOWN threshold above was changed this session.

Installation

1. Clone the Repository

bash
ssh av@<pi-ip>
git clone https://github.com/<your-username>/VacDepoctrl.git ~/VacDepoctrl
cd ~/VacDepoctrl

2. Enable Hardware I2C

Wire physical pins 3/5 as described above. Enable these settings under [all] in /boot/firmware/config.txt, removing any old i2c-gpio overlay:

ini
dtparam=i2c_arm=on
dtparam=i2c_arm_baudrate=100000

The operator should reboot when safe, then verify /dev/i2c-1 exists. Hardware verification should find ADS1115 at 0x48 and MCP4725 at 0x60. Do not launch the controller until the wiring and equipment are ready: main.py configures outputs immediately and can automatically enter PUMP_DOWN.

3. Run the Setup Script

bash
cd ~/VacDepoctrl
chmod +x scripts/setup.sh
./scripts/setup.sh

This installs adafruit-blinka, adafruit-circuitpython-ads1x15, adafruit-extended-bus, rpi-lgpio (providing the RPi.GPIO API), and pyserial into a venv at ~/VacDepoctrl/venv/, adds a source_vacdep alias to ~/.bashrc, and places a VacDepoctrl icon on the desktop (VacDepoctrl.desktop) — double-click it to launch main.py directly, no terminal needed. First launch may need a one-time "Allow Launching" confirmation (right-click the icon → Allow Launching) depending on the desktop environment's trust settings for new .desktop files.

bash
source ~/.bashrc
source_vacdep

4. (Optional) Energy Meter Serial Permissions

If the PZEM-004T-100A / CP2102 is wired up (see Energy Meter), add the Pi user to dialout so main.py can open /dev/ttyUSB0 without root:

bash
sudo usermod -a -G dialout $USER
# log out and back in for the group change to take effect

This step can be skipped entirely — the meter is optional hardware and main.py starts normally without it (see pzem_meter.py).


Running

Must be run on the Pi with a display connected (HDMI or VNC), not over a plain SSH session.

bash
source_vacdep
cd ~/VacDepoctrl
python main.py

Note: use python, not python3, inside the activated venv on this setup.

To run over SSH with display forwarding:

bash
ssh -X av@<pi-ip>
source_vacdep && cd ~/VacDepoctrl && python main.py

This renders the GUI on your own machine instead of the Pi's local display — see Remote GUI Access below for one-time setup and why you'd want to.

Bench-test scripts (run these with main.py stopped — they fight over pins):

bash
python tests/adc_test.py          # live ADC readings, both channels
python tests/dac_test.py          # DAC voltage staircase + register readback
python tests/opto_test.py         # GPIO 17 toggle every 5 s
python tests/turbo_valve_test.py  # GPIO 22 relay/valve toggle every 5 s

Remote GUI Access

Why: Raspberry Pi OS Bookworm's default desktop uses a Wayland compositor (labwc). On older/weaker boards that compositor can alone consume most of the CPU just keeping the desktop alive — independent of main.py — which shows up as system-wide lag, including the mouse cursor itself stuttering. Tkinter also doesn't speak Wayland natively; on this desktop it runs through an Xwayland compatibility shim that gets composited by labwc anyway, adding a second layer of overhead on top.

SSH X11 forwarding (ssh -X) sidesteps all of that: main.py still runs on the Pi (it has to — that's where the GPIO/I2C/PZEM-over-USB hardware physically is), but the X11 protocol (drawing commands, not rendered frames) is tunneled to an X server running on your own machine, which does the actual rendering. None of the Pi's local compositor is involved. This is optional — if you're on the Pi's physical HDMI display or VNC and it isn't laggy for you, there's nothing to set up.

The Pi side needs no configuration — sshd already has X11Forwarding yes and xauth installed. Every OS below needs its own X server to receive the forwarded connection, since none of them speak X11 out of the box.

macOS

bash
./scripts/setup_remote_gui_macos.sh

Or manually:

bash
brew install --cask xquartz
open -a XQuartz

Then from a new terminal (so it picks up XQuartz's environment):

bash
ssh -X av@<pi-ip>
source_vacdep && cd ~/VacDepoctrl && python main.py

If the window doesn't appear, try -Y (trusted forwarding, fewer restrictions) instead of -X. Still nothing? As a last resort:

bash
defaults write org.macosforge.xquartz.X11 nolisten_tcp -bool false

then quit and relaunch XQuartz. This allows raw TCP connections to the X server, not just the local Unix socket ssh -X normally uses — a real (if small, loopback-only-by-default) security tradeoff, so it's not applied automatically by the script above; only turn it on if the standard path above doesn't work.

Desktop launcher (optional): once XQuartz is set up, ./scripts/build_mac_launchers.sh builds two double-clickable .app icons on the Desktop — the Mac equivalent of VacDepoctrl.desktop on the Pi:

  • VacDep Remote.app — picks a rig from a small known-rigs list baked into the app (edit RIGS in scripts/build_mac_launchers.sh and rebuild to add more — one entry auto-connects with no prompt, several show a choose-from-list dialog), then runs the ssh -X command above for you. Connects via the Pi's mDNS hostname (av.local) rather than a raw IP — DHCP has moved this rig's address before, but the mDNS name survives that. The SSH password itself is left to ssh's own normal interactive Terminal prompt (secure, no extra tooling) rather than something like sshpass.
  • VacDep Simulation.app — runs Simulation Mode locally (see below); no Pi or XQuartz-to-Pi connection needed, just XQuartz itself.

Both open Terminal.app rather than running silently, since main.py's startup self-test prints failures to the console before any GUI window exists — same reasoning as Terminal=true in the Pi's .desktop file. First launch of either needs a one-time right-click → Open to bypass Gatekeeper's unsigned-app warning.

[!note] Live network discovery was tried and doesn't work here mDNS service browsing (dns-sd -B _ssh._tcp) was the first approach tried for "find the Pi automatically" — it doesn't find this rig, since Avahi on the Pi isn't advertising _ssh._tcp (only plain hostname resolution works, which is what av.local above relies on). A maintained list is simply more reliable than scanning here.

Windows

Two options — pick one:

  • MobaXterm (recommended — simplest, one tool): install it, open a new SSH session to the Pi. X11 forwarding is enabled by default; its bundled X server starts automatically.
  • VcXsrv + PuTTY: install VcXsrv, run XLaunch with "Disable access control" checked and leave it running, then in PuTTY set Connection → SSH → X11 → Enable X11 forwarding (X display location localhost:0) before connecting.

Either way, once connected: source_vacdep && cd ~/VacDepoctrl && python main.py.

Linux

Usually nothing to install. A native X11 desktop session supports ssh -X out of the box; a Wayland desktop on Linux ships Xwayland by default too (unlike macOS/Windows, which have no X server at all). Just:

bash
ssh -X av@<pi-ip>
source_vacdep && cd ~/VacDepoctrl && python main.py

Web Dashboard

Any browser on the LAN gets the full live interface — readouts, graphs, and controls — at:

text
http://av.local:8080          (or http://<pi-ip>:8080)

No app, no XQuartz, no SSH, no per-OS setup; works from a phone. Served by ui/web_ui.py (stdlib HTTP + Server-Sent Events — nothing to install) with the page in ui/web/index.html (self-contained, no CDN: renders on a LAN with no internet).

Parallel by design, never a replacement. The web dashboard is a second view of the same running main.py — the local Tkinter GUI always runs regardless (WEB_UI_ENABLED only controls the browser side). A network problem can therefore only ever cost the browser page; the control loop and the local GUI on the rig don't know or care whether anyone is connected. The page makes staleness unmissable: if the stream stops for >3 s it greys out behind a DISCONNECTED banner rather than silently showing frozen numbers.

Commands are the same code path as the local buttons. Every browser action dispatches the exact _try_*() function the corresponding Tkinter button calls — same argon-pressure guards, same state-machine locks, same confirmation gates (Confirm Plasma, Confirm Pump Off, Clear Fault). Every web command is appended to sputter_ctrl.log with the client's IP, so the audit trail records who did what from where.

[!warning] No authentication Anyone on the LAN who can reach port 8080 can operate the rig — the same trust model as the Pi's own VNC/SSH access. Don't expose the Pi to a network you don't control; set WEB_UI_ENABLED = False in config.py to not open the port at all.

Troubleshooting: browsers with HTTPS-Only mode (Firefox especially) rewrite a bare <ip>:8080 to https:// and fail with SSL_ERROR_RX_RECORD_TOO_LONG — the server is plain HTTP, so the URL must start with an explicit http://. The Tkinter header shows the full URL in a selectable field with a Copy button for exactly this reason.

[!warning] Deploy this feature as a set, not piecemeal WEB_UI_ENABLED lives in config.py, but the code it depends on (ui/web_ui.py, ui/web/index.html) are separate files. A partial scp that ships a config.py with WEB_UI_ENABLED = True but not the other two crashes main.py on startup with ModuleNotFoundError: No module named 'web_ui' — every launch path fails identically (desktop icon, SSH -X, plain SSH), since the crash happens at import time before Tkinter ever gets a chance to open a window. Hit and fixed 2026-07-12. Always deploy config.py together with the whole ui/ folder, or set WEB_UI_ENABLED = False on the Pi if you're deliberately deploying without the dashboard for now.


Simulation Mode

Runs the full GUI and control loop — state machine, PID loop, cross-sensor checks, plasma detection — on any machine with no Pi, no sensors, and no GPIO/I2C/serial hardware attached. Useful for developing/demoing this project, or just poking at the UI without risking anything.

bash
SPUTTER_SIM=1 python main.py
# or
python main.py --sim

sim/sim_hardware.py installs fake RPi.GPIO / adafruit_ads1x15 / adafruit_extended_bus / serial modules into sys.modules before main.py's own hardware imports run (the same technique the hardware-free test suite uses) — but unlike the test suite's static, manually-set values, these are backed by a live physics model (ChamberSim) that reacts to whatever the real control loop actually commands: turbo RPM ramps when the enable opto goes high, pressure falls while pumping and rises when the turbo valve opens or the MFC commands flow, MFC measured flow chases commanded flow with a lag, etc. The window shows an unmissable orange "SIMULATION MODE" banner so it's never confused with a real run.

Plasma strikes are manual, not automatic. An early version auto-triggered the simulated variac current from a flow+low-pressure heuristic, but that fired during ARGON_FLUSH itself — before any real ignition attempt, since the PID legitimately drives flow/pressure into the same range just converging on the flush target. _ignite_plasma() is still a stub on real hardware too, so nothing should auto-decide this in the simulation either. Instead, Simulation Mode adds a "Plasma Strike (SIM)" toggle button to the Controls panel — click it whenever you want to simulate the plasma actually igniting; it stays exactly as set until toggled again.

On macOS, ./scripts/build_mac_launchers.sh builds a double-clickable VacDep Simulation.app — see Remote GUI Access above.


Configuration

All tunable parameters are in config.py. Key constants:

python
# I2C
I2C_BUS_NUMBER               = 1       # hardware I2C1 on GPIO2/3
ADS1115_I2C_ADDRESS          = 0x48
ARGON_DAC_I2C_ADDRESS        = 0x60

# GPIO (BCM)
GPIO_PIRANI_PIN              = 17      # turbo enable opto
GPIO_MFC_VALVE_CLOSE_PIN     = 27      # emergency valve close
GPIO_TURBO_VALVE_PIN         = 22      # turbo inlet valve relay (was GPIO 4 — dead pad)

# Pressure thresholds (post-divider volts unless noted)
IDLE_PRESSURE_MAX_VOLTAGE    = 2.71    # 10 mbar  — pump-down gate
PUMP_DOWN_COMPLETE_VOLTAGE   = 0.3     # PUMP_DOWN → READY gate
ARGON_FLUSH_TARGET_VOLTAGE   = 1.287   # 0.09 mbar — plasma ignition pressure
VENTING_COMPLETE_VOLTAGE     = 3.0     # VENTING → IDLE threshold (near-atmosphere; see Pressure Reference)
SPUTTER_TARGET_MAX_MBAR      = 0.05    # cap on the operator-settable Sputter P field
ARGON_PRESSURE_MIN_PSI       = 15.0    # floor on the operator-settable Argon PSI field

# Turbo pump RPM (tach on ADC A2) and inlet valve interlock
ADC_CHANNEL_TURBO_RPM        = 2
TURBO_RPM_VOLTAGE_FULL_SCALE = 3.3     # volts at A2 for full-scale RPM
TURBO_RPM_FULL_SCALE         = 90000.0 # RPM at full-scale voltage
TURBO_VALVE_OPEN_RPM_MAX     = 35000   # inlet valve opens once RPM drops to/below this
TURBO_VALVE_CONFIRM_SAMPLES  = 3       # consecutive reads required before opening (debounce)

Tuning the Pressure PID Loop

Current tuned values (Kp=8, Kd=3) were arrived at empirically on hardware:

  • Started at Kp=40 (original, P-only) — produced slow, large-amplitude oscillation
  • Dropped to Kp=10 — oscillation reduced significantly but response was sluggish
  • Raised to Kp=20, added Kd=3–7 — eliminated overshoot at the 0.09 mbar (ARGON_FLUSH) setpoint, but undershoot persisted at 0.007 mbar (SPUTTER_READY) since the same gains behave differently at very different flow regimes
  • Settled at Kp=17→8 (further reduced after switching POLLING_INTERVAL from 0.5s to 0.2s, which made the derivative term ~2.5× more aggressive for the same physical Kd — gains must be retuned whenever POLLING_INTERVAL changes), Kd=7→3
  • I-term added to address a small persistent offset; large Ki caused overshoot, kept small with anti-windup clamping (PRESSURE_CONTROL_ICLAMP). Setting Ki=0 while tuning is safe (no divide-by-zero).

If you change POLLING_INTERVAL, expect to retune Kd and Ki — both depend on dt between samples.


GUI Guide

ElementDescription
Pi IPLive IP address shown in header
Process StateCurrent state, colour-coded
Safety Trip banner / Clear FaultAppears (red) in the Process State panel only when the global safety gate has forced a shutdown; see Global Safety Gate. Clearing does not fix the underlying condition — an unaddressed fault re-trips immediately
Start Argon FlushOpens MFC valve, starts PID loop to 0.09 mbar
Confirm PlasmaManually confirms plasma ignition; transitions to SPUTTER_READY. Highlights green once the PZEM confirms plasma-level current draw during PLASMA_IGNITING
Auto-Confirm togglePlasma panel. Off by default. When armed, a PZEM-confirmed detection auto-advances PLASMA_IGNITING → SPUTTER_READY without a click — see Energy Meter
PLASMA / PZEM readout / graphPlasma panel (replaces the old placeholder): live plasma_detected status, PZEM voltage/current/power/energy/frequency/power-factor, and one scrolling graph with a legend covering all but energy (shown as a numeric total instead)
Start SputterBegins sputtering from SPUTTER_READY
Stop SputterEnds sputtering, transitions directly to VENTING
VentVents chamber to atmosphere
Confirm Pump OffHidden until atmospheric pressure is reached during VENTING; click completes the vent (closes the turbo inlet valve, transitions to IDLE) — see Vent Completion Confirmation
E-STOPImmediate: kills flow, closes both valves (turbo valve by direct GPIO write — works even with a hung poll thread), drops opto, returns to IDLE and latches; Clear Fault to resume
Voltage + Pressure (mbar)Live Pirani readings; mbar via log-linear calibration table
OPTOTurbo Pump panel. Green = turbo enable opto on; Red = off
T-VALVETurbo Pump panel. Green CLOSED = inlet valve shut (normal); Orange OPEN (venting) = venting and turbo RPM has dropped to/below TURBO_VALVE_OPEN_RPM_MAX
T-RPM / T-RPM V / turbo graphOwn Turbo Pump panel (below Plasma, beside Pirani/MFC); turbo pump RPM (ADC A2), its raw ADC voltage, and a scrolling RPM graph, all read every tick
Sputter P (mbar)Operator-set target pressure; press Set or Enter. Rejected (warning dialog) if ≤ 0 or > 0.05 mbar
Argon PSIOperator-entered regulator pressure; ≥ 15 psi required for flush/sputter. Rejected (warning dialog) if < 15 psi
Pirani graphScrolling ADC voltage (lime, 0–4V, 60 samples)
MFC graphScrolling flow sccm (cyan) overlaid with DAC voltage (yellow dashed)
DAC OUTGreen = DAC found on I2C; Red = not detected
ValveGreen = released (MFC in control); Red = closed (emergency)
Status barErrors shown in red, auto-expire after 10 s

Known Limitations

  • Turbo enable opto (GPIO 17) is marginal: the enable input draws ~15–30 mA, beyond a 4N35's ~10 mA saturated capability at legal GPIO LED drive. Currently only works with excess LED current. Proper fix (BC547 output buffer) designed but not yet installed.
  • Argon PSI guard is honor-system: the ≥15 psi check reads an operator-entered value, not a sensor, and the entered value persists across runs. By design — this is a manual regulator reading, not something this rig senses directly.
  • No series protection resistors on ADC inputs yet — recommended as general input protection.
  • _ignite_plasma() is a stub: RF power supply trigger is not yet implemented.
  • PZEM plasma-detection thresholds are unvalidated placeholders (PZEM_PLASMA_CURRENT_ON_A/OFF_A): guessed values, not yet measured against the real supply's no-load vs. struck-plasma current. This is why Auto-Confirm defaults off — see Energy Meter.
  • PZEM CT clamp not yet confirmed on the real variac-output load line: a 2026-07-12 run with visually-confirmed plasma still read 0.00A and force-shut-down via pzem_plasma_absent_check(). Until the CT is verified clamped correctly on the live load conductor (and, if the real current is low relative to the CT's 100A rating, wound through with multiple turns — see Energy Meter bring-up notes), plasma auto-detection cannot be trusted; keep Auto-Confirm off and rely on the manual Confirm Plasma button.

Roadmap

Goals only, near-term → eventual. Inspired in part by how production fabs structure tool control: hardwired interlocks under software, standardized state/alarm/recipe models (SECS/GEM), and fault detection built on ruthless data collection.

Harden the platform

  • Software armor: global safety gate (_force_safe_shutdown()), software heartbeat/watchdog, startup self-test, persistent event log, config validation at launch — implemented in software; not yet validated on hardware. Still outstanding: a Pi-level hardware watchdog (/dev/watchdog, forced reboot if unpetted) — deliberately deferred, separate systemd/infra decision with its own tradeoffs (what state GPIOs land in across a forced reboot)
  • [~] Simulation mode: fake-hardware flag so the full state machine + GUI run on any laptop — sim/sim_hardware.py, SPUTTER_SIM=1 python main.py or --sim. Extends the unit suite's static test fakes into a live interactive sim with a simple first-order physics model (ChamberSim) that reacts to real GPIO/DAC/ serial writes from the actual control loop, not canned values
  • Trustworthy analog front-end: protected, calibrated sensor inputs with the divider ratios measured rather than assumed (Pirani atmosphere anomaly resolved)

Make it a deposition machine

  • First characterized film: complete a full run and measure the result — the milestone everything above serves
  • [~] Full plasma integration: automatic plasma detection via current sensing (PZEM-004T-100A on the variac output), cross-sensor checks, and a plasma GUI panel replacing the placeholder are implemented; auto-confirm is operator- armed and off by default until the current thresholds are bench-validated. Still open, deliberately deferred until after the shutter (below): supply trigger in _ignite_plasma() itself, with arc suppression confirmed in hardware
  • True turbo protection: RPM-based inlet valve interlock (ADC A2, tach output), replacing the pressure-based gate — implemented in software (turbo_valve_step(), TURBO_VALVE_OPEN_RPM_MAX); not yet validated against a real vent cycle on hardware
  • Full-range MFC control: level-shifted 5V DAC path removing the ~46% flow ceiling — cancelled 2026-07-10, not required
  • Per-state PID gain sets: ARGON_FLUSH and SPUTTER_READY dynamics differ; gains should switch with state. Correct, not urgent — low priority
  • Shutter: stepper-driven, blocks sputtering flux from the substrate during pre-sputter target cleaning. Blocked on hardware access and a few design decisions (vacuum-compatible actuation, driver hardware, position sensing, manual vs. state-machine-tied control) — not a software gap

Operate like a fab tool

  • Data collection & FDC-lite: log every run (pressure/flow/DAC traces), build known-good baselines, flag deviation — drift detection instead of surprise failures; dashboarding via Grafana/InfluxDB is the natural backend. Real requirement, sequenced after simulation mode lands
  • Process recipes: runs defined as recipe files (target pressure, power, time, ramp profiles) executed start-to-finish — repeatability over button-pressing. Sequenced after data collection above
  • Alarm model: separate operator-info messages from latched alarms that require acknowledgement, GEM-style
  • Maintenance counters: target erosion time, pump hours, cycles since vent — scheduled maintenance instead of run-to-failure

Eventual

  • Unattended operation: headless mode, remote monitoring and alarm notifications — the point where the watchdog and plasma detection stop being optional
  • Host interface: expose state/data/recipes over the network (simple JSON socket, or the secsgem Python library for a real SEMI E30 interface) so the tool can be driven like production fab equipment
  • Run-to-run control: adjust the next recipe from measurements of the last film — closing the outermost loop
  • MCU delegation layer: when commercial smart boxes (MFC, supply) get replaced by homebuilt hardware, delegate the fast loops to a dedicated microcontroller under the Pi (the SputterOS-shaped slot in this architecture)
  • Replicable kit: documentation and BOM good enough that another lab can build this machine from the repo alone

Pi 5 GPIO dependency note

Blinka 9.2.0 declares a dependency on legacy RPi.GPIO. Setup installs Blinka first, then replaces that package with rpi-lgpio for Pi 5 support. Do not install both together. pip check currently reports Blinka's missing RPi.GPIO distribution even though import RPi.GPIO is supplied by rpi-lgpio. Re-run setup after dependency upgrades that reinstall the legacy package.

Parts

36
QtyValueFootprintReferences
810kResistor_SMD:R_0805_2012MetricR3, R4, R5, R6, R7, R8, R11, R12
4MountingHole_2.7mm_M2.5MountingHole:MountingHole_2.7mm_M2.5MH1, MH2, MH3, MH4
23.9kResistor_SMD:R_0805_2012MetricR1, R2
2BC847BPackage_TO_SOT_SMD:SOT-23Q1, Q2
1100nCapacitor_SMD:C_0805_2012MetricC1
11N4148WDiode_SMD:D_SOD-123D2
120kResistor_SMD:R_0805_2012MetricR13
1330Resistor_SMD:R_0805_2012MetricR10
1470Resistor_SMD:R_0805_2012MetricR9
1ADC1_Conn_01x10_PinConnector_PinSocket_2.54mm:PinSocket_1x10_P2.54mm_VerticalJ2
1CAT24C256Package_SO:SOIC-8_3.9x4.9mm_P1.27mmU1
1DAC1_Conn_01x06_PinConnector_PinSocket_2.54mm:PinSocket_1x06_P2.54mm_VerticalJ3
1EL817 (SMD)Package_SO:SO-4_7.6x3.6mm_P2.54mmU2
1G5V-1 5VRelay_THT:Relay_SPDT_Omron_G5V-1K1
1GPIOConnector_PinSocket_2.54mm:PinSocket_2x20_P2.54mm_VerticalJ1
1ID_WPJumper:SolderJumper-2_P1.3mm_Open_TrianglePad1.0x1.5mmJP1
1LM358Package_SO:SOIC-8_3.9x4.9mm_P1.27mmU3
1M7 (1N4007)Diode_SMD:D_SMAD1
1MFC_JSTConnector_JST:JST_XH_B4B-XH-A_1x04_P2.50mm_VerticalJ9
1PIRANI_JSTConnector_JST:JST_XH_B2B-XH-A_1x02_P2.50mm_VerticalJ10
1RPM_JSTConnector_JST:JST_XH_B2B-XH-A_1x02_P2.50mm_VerticalJ11
1TURBO_ENTerminalBlock_Phoenix:TerminalBlock_Phoenix_MKDS-1,5-2-5.08_1x02_P5.08mm_HorizontalJ6
1UART_JSTConnector_JST:JST_XH_B4B-XH-A_1x04_P2.50mm_VerticalJ8
1VALVETerminalBlock_Phoenix:TerminalBlock_Phoenix_MKDS-1,5-3-5.08_1x03_P5.08mm_HorizontalJ5

Questions

How big is the Raspi HAT PCB?

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

What parts does the Raspi HAT use?

36 parts, built around the LM358, CAT24C256LI, BC847B and 1N4148W. 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 Raspi HAT KiCad files?

From the 4rynv/VacDepoctrl repository on GitHub, at the commit this page was made from: the board is hardware/Raspi_HAT/Raspi_HAT.kicad_pcb and its schematic hardware/Raspi_HAT/Raspi_HAT.kicad_sch. PCBKit opens the board in the browser, with nothing to install.

Can I use the Raspi HAT design in my own project?

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

Similar boards

  • 2 layers140 × 125 mmMinimood, the printed circuit board in 3D

    Minimood

    heolfief/minimood

    [WIP] STM32F4 based audio synthesizer

    142 parts2GPL-3.0
  • 2 layers80 × 60 mmIRIS C5 RevC, the printed circuit board in 3D

    IRIS C5 RevC

    Benv-B/photo-frame-hardware

    Open hardware carrier for an AirDrop ChLCD color e-paper frame: XIAO ESP32-C5, NFC wake, KiCad, schematic, BOM and Gerbers. CERN-OHL-P-2.0.

    66 partsCERN-OHL-P-2.0
  • 2 layers48 × 41.8 mmMoskvich ecu, the printed circuit board in 3D

    Moskvich ecu

    pachobg2/moskvich_ignition

    Programmable ignition ECU for Moskvich 412/2140 (ESP32-C3, web tuner)

    45 parts
  • 4 layers159.6 × 161 mmShack interface, the printed circuit board in 3D

    Shack interface

    PE1RGU/hf-antenna-switch

    Remote HF antenna switch: 3 radios x 4 antennas (path to 8), outdoor relay unit with a hardware safety chain, controlled over Cat6 from a shack interface. KiCad 10, open hardware, designed together with an AI.

    290 parts2CERN-OHL-S-2.0
  • 2 layers65 × 56.5 mmRpiampdacv1, the printed circuit board in 3D

    Rpiampdacv1

    orri93/electronics

    Electronics Projects

    32 parts1MIT
  • 6 layers160 × 100 mmAdrmu6layer, the printed circuit board in 3D

    Adrmu6layer

    marcoreps/ADRmu

    ADR1000 10V standard

    240 parts149
  • 4 layers123 × 86.5 mmInverterMainBoard, the printed circuit board in 3D

    InverterMainBoard

    NTURacingTeam/nturt_kicad

    kicad files

    228 parts4