From 9bc7f3fd24e0c4a989ed3331a1918e2cb4081f0b Mon Sep 17 00:00:00 2001 From: Boernsman <5207214+Boernsman@users.noreply.github.com> Date: Sun, 11 Oct 2026 09:30:13 +0200 Subject: [PATCH] feat: a printed enclosure, a knob, and a slicer run that checks its own work Panel-mount housing in three parts, parameterised, with every dimension read out of hardware/safekiln.kicad_pcb rather than guessed. Renders manifold, slices for PLA on a Prusa, and nothing has been printed. shell 54 x 81 x 115 mm, 8 h 49 m, open face up bezel 70 x 97 x 7 mm, 1 h 40 m, face down knob 25 x 25 x 16 mm, 27 m, bore up None needs support, which is a property of the geometry rather than of the profile: the shell's standoffs grow off its floor, the bezel's apertures are vertical walls, and the knob's bore is a hole going up. **The board's placement decided the shape, not a preference.** SW3, the rotary encoder, is ON the board at its left edge, and J1 and J2 are on that same edge. So that edge is the operator's face and the board stands on edge behind the panel, with the six terminal blocks on the right edge facing into the back of the box where the kiln's wiring belongs. Lying it flat would have put the knob on the side and the mains terminals on the operator's face. The first version of the model made the box as wide as the board's stack and nothing else, which gave a 23.4 mm front face for a 27 mm display module. It does not fit vertically either: that face already carries the DC input at y=11.1, USB at 23.9 and the encoder at 55.8, and the largest gap between them is about 25 mm. So the display takes its own lane beside the board's column, which is what a panel instrument looks like anyway. The render caught that before anything was printed, and the arithmetic is in the .scad beside `in_w`. **The knob** is for the EC11E's 6 mm shaft, printed bore up so the face the operator looks at is the one the bed made. Flutes rather than knurling, since a printed knurl at 0.2 mm layers is a texture nobody can feel; eight of them, findable with a glove on. The pointer is a moulded rib, not paint. **slice.sh checks four things that fail silently**, and three of them actually happened while writing it: A profile that is ignored. The first pla-0.2mm.ini used [print:]-style sections, which makes it a config BUNDLE: --load ignored it without a word and sliced at 0.3 mm, 200 degC and a cold bed, producing 891 kB of healthy looking G-code. The profile is flat now, and the script reads the settings back out of the finished G-code rather than trusting the file it passed in. A slicer that exits ZERO while refusing. PrusaSlicer rejected relative E without a per-layer G92 E0, printed the reason, wrote no file and returned success. So the existence of the output is the test, not the exit status. A duplicate key, which it does report, and a render that is not a closed solid or is too small to be the part, which it does not. Two hardware requirements fall out of the fit: HWR-24, the board has no mounting holes at all. The shell's four M3 standoffs are a proposal the board has to adopt, since the enclosure is parameterised and the board is not. HWR-25, 14 of 125 footprints sit outside the outline, including U4, the second MAX31856, at y=117.9 against an outline ending at 110. Both correct the task list, which also claimed the board had no tracks (it has 182 and 9 vias) and did not carry the supervisor (U5 is an STM32G031K8Tx, with both MAX31856 and the SWD tag-connect placed). A new section B carries the four things the enclosure needs before it can be screwed together. PLA is flagged as probably the wrong material: it softens at 55 to 60 degC and this bolts to a kiln's panel, which is what SWR-SAF-11 exists for. Print it for fit and feel; measure the panel before trusting it, and PETG or ASA slices from the same profile. --- .github/workflows/ci.yml | 1 + .gitignore | 4 + docs/03_hardware_req.sdoc | 32 ++ hardware/enclosure/README.md | 99 +++++ hardware/enclosure/pla-0.2mm.ini | 147 ++++++++ hardware/enclosure/safekiln-enclosure.scad | 420 +++++++++++++++++++++ hardware/enclosure/slice.sh | 190 ++++++++++ tasklist.md | 51 ++- 8 files changed, 933 insertions(+), 11 deletions(-) create mode 100644 hardware/enclosure/README.md create mode 100644 hardware/enclosure/pla-0.2mm.ini create mode 100644 hardware/enclosure/safekiln-enclosure.scad create mode 100755 hardware/enclosure/slice.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d9e828c..ff10f24 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -111,6 +111,7 @@ jobs: '*.cpp' '*.h' '*.md' '*.sdoc' 'CMakeLists.txt' '*/CMakeLists.txt' \ 'firmware/controller/**/Kconfig*' 'firmware/controller/*.csv' 'firmware/controller/sdkconfig.*' \ 'tools/*.sh' 'tools/*.py' '.github/workflows/*.yml' 'strictdoc.toml' \ + '*.scad' 'hardware/enclosure/*.ini' 'hardware/enclosure/*.sh' \ | grep -v '^LICENSE' \ | grep -vE '^fixture/(board|firmware)/') # fixture/board and fixture/firmware are generated verbatim by diff --git a/.gitignore b/.gitignore index 8b3ab31..5ddad4f 100644 --- a/.gitignore +++ b/.gitignore @@ -71,3 +71,7 @@ firmware/supervisor/latex/ # Python bytecode from the tools/ scripts. __pycache__/ *.pyc + +# Rendered STLs and sliced G-code: generated by hardware/enclosure/slice.sh, +# reproducible from the .scad and the profile, and large. +hardware/enclosure/build/ diff --git a/docs/03_hardware_req.sdoc b/docs/03_hardware_req.sdoc index aaa463c..6b934fa 100644 --- a/docs/03_hardware_req.sdoc +++ b/docs/03_hardware_req.sdoc @@ -382,6 +382,38 @@ RELATIONS: VALUE: SYS-HW-24 ROLE: Refines +[REQUIREMENT] +UID: HWR-24 +TITLE: The board has mounting holes +STATEMENT: >>> +The PCB shall carry **four M3 mounting holes**, 4 mm in from each corner of the 100 × 70 outline, plated or with keep-out to the nearest copper. + +There are none today. Nothing fixes the board to anything, so the enclosure's standoffs ([`hardware/enclosure`](../hardware/enclosure/README.md)) are a proposal the board has to adopt rather than a fit to something that exists. A controller held in place by its own wiring is a controller whose terminal blocks take the weight of the loom. + +The positions are the enclosure's, not the other way round, because the enclosure is printed from a parameterised model and the board is not. +<<< +PRIORITY: M +VERIFICATION: I +RELATIONS: +- TYPE: Parent + VALUE: SYS-HW-05 + ROLE: Refines + +[REQUIREMENT] +UID: HWR-25 +TITLE: Every footprint is inside the outline +STATEMENT: >>> +Every footprint shall sit inside the board outline, and the placement shall be reviewed against it before routing continues. + +**14 of 125 do not**, measured 2026-10-11 against the `gr_rect` at 50,40 to 150,110: `U4` (the second MAX31856) at y = 117.9 and `J7` (the second thermocouple terminal) at y = 73.2 are the two that matter, with a cluster of decoupling capacitors and test points beside them. A part outside the outline is not placed at all: it will not be fabricated where it appears, and the netlist that routes to it is routing into free space. +<<< +PRIORITY: M +VERIFICATION: I +RELATIONS: +- TYPE: Parent + VALUE: SYS-HW-10 + ROLE: Refines + [[/SECTION]] [[SECTION]] diff --git a/hardware/enclosure/README.md b/hardware/enclosure/README.md new file mode 100644 index 0000000..25272ac --- /dev/null +++ b/hardware/enclosure/README.md @@ -0,0 +1,99 @@ + + +# Enclosure + +Panel-mount housing for the Safe Kiln Controller, in three printed parts. + +```sh +./slice.sh # render and slice all three for PLA +./slice.sh knob # just one +./slice.sh --stl-only # STLs only, slice them yourself +``` + +| Part | Size | 0.2 mm PLA | Prints | +|---|---|---|---| +| `shell` | 54 × 81 × 115 mm | 8 h 49 m | open face up, no support | +| `bezel` | 70 × 97 × 7 mm | 1 h 40 m | face down, no support | +| `knob` | 25 × 25 × 16 mm | 27 m | bore up, no support | + +Panel cut-out **54.4 × 81.3 mm**; outside size 70 × 97 × 115 mm. + +## ⚠ PLA is probably the wrong material here + +PLA softens at **55 to 60 °C**. This box bolts to the panel of a kiln, and +`SWR-SAF-11` exists because that panel can get hot enough to matter: the +firmware raises a fault on enclosure over-temperature at a configurable limit +whose default is well inside PLA's softening range. + +So print PLA for **fit, feel and review**, which is what the knob's bore and +the panel cut-out need, and it is cheap and accurate. Before a real +installation, measure the panel at temperature, and if it runs warm use PETG +or ASA. Both slice from the same profile with their own filament settings; +nothing in the geometry assumes PLA. + +A softened enclosure is not a safety failure on its own: nothing in the +protection chain depends on the plastic. It is a mounting failure, which means +a board hanging off a hot panel by its wiring. + +## Print it in this order + +1. **The knob**, alone, and try it on the encoder. `knob_bore` is 6.15 mm, + which suits a splined EC11E shaft on a well-calibrated machine; if it is + tight or loose, change that one number. Four minutes, and it saves + discovering the same error at the end of an 8 hour print. +2. **The bezel**, which is the part you look at. Check the display window + against the module you actually bought before printing the shell: the three + `CHECK` parameters in the `.scad` are the common 0.96″ variant and modules + differ. +3. **The shell**, last, because it is the long one. + +## What the design is built around + +Every dimension comes out of `hardware/safekiln.kicad_pcb`, not from a guess, +and the board's own placement decides the shape: + +- the outline is **100 × 70 mm**; +- **SW3, the rotary encoder, is on the board** at its left edge, so that edge + is the operator's face and the board stands on edge behind the panel; +- `J1` (5 V in) and `J2` (USB-C) are on that same edge, under the knob; +- six 5 mm terminal blocks leave the **right** edge (coil, SSR, lid, CT and + the two thermocouples), so that edge faces into the back of the box, which + is where the kiln's wiring belongs; +- `J5` is an OLED module **on a cable**, so the display is the one thing free + to go where it should. + +The display sits in its own lane beside the board's column rather than above +the knob, because the front face is only as wide as the board's thickness plus +that lane, and the first version of this model put a 27 mm module on a 23 mm +face. The render caught it; see the note at `in_w` in the `.scad`. + +## Before this can be screwed together + +**The PCB has no mounting holes.** Nothing fixes the board to anything, so the +four M3 standoffs in the shell are a *proposal* the board has to adopt: holes +4 mm in from each corner of the 100 × 70 outline. Tracked as `HWR-24`. + +The supervisor's SWD tag-connect (`J4`) needs a probe to reach it through the +shell's side opening. That opening is positioned from `J4`'s coordinates, but +a TC2030 needs the probe held square to the pads, so check the clearance with +the probe in hand before trusting it (`HWR-19`). + +## Files + +| | | +|---|---| +| `safekiln-enclosure.scad` | all three parts, parameterised, `-D part="…"` | +| `pla-0.2mm.ini` | PrusaSlicer configuration, flat so `--load` applies it | +| `slice.sh` | render, slice, and verify the profile was actually used | + +`slice.sh` checks more than it looks like it should, and each check is there +because the failure it catches is silent: a render that is not a closed solid, +a G-code file too small to be a part, a slicer that exits **zero** while +refusing a configuration and writing nothing, and a profile that was ignored. +The last one happened: the first `pla-0.2mm.ini` used `[print:]`-style +sections, which makes it a config *bundle*, and `--load` ignored it without a +word and sliced at 0.3 mm with a cold bed. The script now reads the settings +back out of the finished G-code. diff --git a/hardware/enclosure/pla-0.2mm.ini b/hardware/enclosure/pla-0.2mm.ini new file mode 100644 index 0000000..850a44d --- /dev/null +++ b/hardware/enclosure/pla-0.2mm.ini @@ -0,0 +1,147 @@ +; SPDX-FileCopyrightText: 2026 Bitcrush Testing +; SPDX-License-Identifier: GPL-3.0-or-later +; +; PrusaSlicer configuration for the Safe Kiln Controller enclosure in PLA, on +; a 0.4 mm nozzle at 0.2 mm layers. Load it in the GUI or let +; tools/slice.sh use it. +; +; =========================================================================== +; WHY THESE VALUES AND NOT THE STOCK PROFILE +; =========================================================================== +; The stock 0.2 mm SPEED profile prints this enclosure perfectly well. The +; departures below are deliberate and each has a reason tied to this part +; rather than to a general preference for quality: +; +; PERIMETERS = 3. The walls are 2.4 mm, which is exactly three extrusions at +; 0.45 mm. Two would leave a gap filled with infill, and the part that gets +; handled is the wall a terminal screw is torqued against. +; +; TOP/BOTTOM = 5/4. The bezel's visible face is a bottom surface and the +; display window's chamfer is a top surface, both of which show every +; missing layer. +; +; NO SUPPORT. The geometry is drawn so that nothing needs it: the shell +; prints open-face-up and its standoffs grow off the floor, the bezel prints +; face-down, the knob prints bore-up. If a future change needs support, the +; change is wrong rather than the profile. +; +; BRIM 3 mm ON THE SHELL ONLY, set by the script per part. The shell is +; 115 mm tall on a 54 x 81 footprint, which is exactly the aspect ratio that +; lets a knock mid-print take it off the bed. +; +; FAN 100 % after the first layers. PLA in a part with small bosses and thin +; ribs needs the cooling more than it needs the layer adhesion, and this +; enclosure is not structural. +; +; What this profile is NOT for: anything that gets hot. PLA softens around +; 55 to 60 degC, and an enclosure bolted to a kiln's panel can exceed that. +; See the warning in README.md before printing this in PLA for real use. +; +; =========================================================================== +; WHY THIS FILE IS FLAT AND NOT A BUNDLE +; =========================================================================== +; No [print:...] / [filament:...] / [printer:...] sections. Those make it a +; config BUNDLE, which the GUI imports and which `--load` silently ignores: +; the first version of this file had them, PrusaSlicer accepted it without a +; word, and sliced at 0.3 mm layers, 200 degC and a cold bed. The G-code was +; 891 kB and looked perfectly healthy. +; +; `--load` wants a flat configuration, which is what the GUI's "Export Config" +; writes. slice.sh now reads the settings back out of the finished G-code and +; fails if they are not these, because a profile that is quietly not applied is +; indistinguishable from one that is until the print is on the bed. + +layer_height = 0.2 +first_layer_height = 0.25 +perimeters = 3 +top_solid_layers = 5 +bottom_solid_layers = 4 +fill_density = 20% +fill_pattern = gyroid +top_fill_pattern = monotonic +bottom_fill_pattern = monotonic +solid_infill_every_layers = 0 +infill_every_layers = 1 +ensure_vertical_shell_thickness = 1 +thin_walls = 0 +gap_fill_enabled = 1 +support_material = 0 +support_material_auto = 0 +brim_width = 0 +brim_type = outer_only +skirts = 1 +skirt_distance = 2 +skirt_height = 1 +seam_position = aligned +external_perimeters_first = 0 +perimeter_speed = 45 +external_perimeter_speed = 25 +infill_speed = 80 +solid_infill_speed = 80 +top_solid_infill_speed = 40 +travel_speed = 180 +first_layer_speed = 20 +bridge_speed = 25 +bridge_flow_ratio = 1 +extrusion_width = 0.45 +first_layer_extrusion_width = 0.42 +external_perimeter_extrusion_width = 0.45 +perimeter_extrusion_width = 0.45 +infill_extrusion_width = 0.45 +solid_infill_extrusion_width = 0.45 +top_infill_extrusion_width = 0.4 +elefant_foot_compensation = 0.2 +; Holes print undersize. 0.1 mm of compensation gets an M3 clearance hole to +; actually pass an M3 and the knob's bore onto the shaft, and is the one +; setting most likely to need adjusting for a particular machine. +hole_size_compensation = 0.1 +xy_size_compensation = 0 + +filament_type = PLA +filament_diameter = 1.75 +temperature = 210 +first_layer_temperature = 215 +bed_temperature = 60 +first_layer_bed_temperature = 60 +fan_always_on = 1 +cooling = 1 +min_fan_speed = 100 +max_fan_speed = 100 +bridge_fan_speed = 100 +disable_fan_first_layers = 2 +slowdown_below_layer_time = 8 +min_print_speed = 15 +filament_retract_length = nil +filament_colour = #FF8000 +filament_settings_id = "SafeKiln PLA" + +printer_technology = FFF +printer_model = +nozzle_diameter = 0.4 +; A generic Prusa i3 envelope: MK3S is 250 x 210 x 210 and MK4 250 x 210 x 220, +; so this is the smaller of the two and every part fits either. +bed_shape = 0x0,250x0,250x210,0x210 +max_print_height = 210 +z_offset = 0 +retract_length = 0.8 +retract_speed = 35 +deretract_speed = 25 +retract_before_travel = 1.5 +retract_layer_change = 1 +wipe = 1 +use_relative_e_distances = 1 +; Required with relative E, and PrusaSlicer refuses to slice without it: +; relative extrusion accumulates floating-point error unless the position is +; reset each layer. Worth knowing how it refuses -- it printed the reason and +; exited ZERO, writing no file, which is why slice.sh checks that the G-code +; exists and contains a plausible number of moves rather than trusting the +; exit status. +layer_gcode = G92 E0 +gcode_flavor = marlin2 +; Deliberately minimal: the real start sequence belongs to the machine's own +; profile, and a start G-code copied into a project file is a start G-code +; that goes stale. The script warns when it is slicing with this one. +start_gcode = M115 U3.13.2 ; tell the firmware the slicer version\nG28 ; home\nG29 ; mesh bed level (ignored by firmware that does not have it)\nM104 S[first_layer_temperature] ; set nozzle\nM140 S[first_layer_bed_temperature] ; set bed\nM109 S[first_layer_temperature] ; wait nozzle\nM190 S[first_layer_bed_temperature] ; wait bed\nG92 E0 ; zero the extruder +end_gcode = M104 S0 ; nozzle off\nM140 S0 ; bed off\nG1 X0 Y200 F3000 ; present the part\nM84 ; motors off +between_objects_gcode = +printer_settings_id = "SafeKiln Prusa 0.4" diff --git a/hardware/enclosure/safekiln-enclosure.scad b/hardware/enclosure/safekiln-enclosure.scad new file mode 100644 index 0000000..11f192b --- /dev/null +++ b/hardware/enclosure/safekiln-enclosure.scad @@ -0,0 +1,420 @@ +// SPDX-FileCopyrightText: 2026 Bitcrush Testing +// SPDX-License-Identifier: GPL-3.0-or-later +// +// Panel-mount enclosure for the Safe Kiln Controller, in two printed parts. +// +// openscad -D part=\"bezel\" -o bezel.stl safekiln-enclosure.scad +// openscad -D part=\"shell\" -o shell.stl safekiln-enclosure.scad +// openscad -D part=\"all\" -o both.stl safekiln-enclosure.scad // preview +// +// Or let the Makefile do it: `make -C hardware/enclosure`. +// +// =========================================================================== +// WHY THE BOARD STANDS ON EDGE +// =========================================================================== +// Every dimension below is read from hardware/safekiln.kicad_pcb rather than +// guessed, and the placement decides the whole shape: +// +// the board outline is 100 x 70 mm (gr_rect 50,40 to 150,110); +// SW3, the rotary encoder, is ON the board at its LEFT edge (4.5, 55.8); +// J1 (5 V DC in) and J2 (USB-C) are on that same left edge; +// six 5 mm terminal blocks leave the RIGHT edge, which is the kiln wiring: +// J8 coil, J6 SSR, J9 lid switch, J10 CT, J3 TC1, J7 TC2; +// J5 is an OLED module on a 4-pin cable, NOT a board-mounted display. +// +// So the operator's face is the board's left edge and the wiring face is its +// right edge. The board therefore stands vertically behind the panel rather +// than lying flat: the encoder shaft, the USB socket and the DC input all come +// through the front, and the terminal blocks face away into the back of the +// enclosure, which is where the kiln's wires belong. +// +// Lying the board flat would have been easier to print and would have put the +// knob on the side of the box, where nobody looks for it, and the mains-side +// terminals on the operator's face, which is worse than awkward. +// +// The display is the one part free to go anywhere, being on a cable. It cannot +// go above or below the knob, for the reason worked through at `in_w` below, +// so it takes its own lane beside the board's column: screen on one side, knob +// on the other, which is what a panel instrument looks like anyway. +// +// =========================================================================== +// WHAT IS NOT YET KNOWN +// =========================================================================== +// The board has NO mounting holes. Nothing in the PCB fixes it to anything, so +// the standoff positions below are a PROPOSAL this enclosure makes and the +// board has to adopt: four M3 holes inset `pcb_hole_inset` from the corners. +// Until they exist, print the shell and the board will not screw into it. +// Recorded as a requirement rather than a comment: HWR-24. +// +// The OLED module's own dimensions are the common 0.96" I2C variant and must +// be checked against the module actually bought. They are parameters for that +// reason, and the three that matter are marked CHECK. + +// -------------------------------------------------------------------------- +// what to render +// -------------------------------------------------------------------------- +part = "all"; // "bezel" | "shell" | "knob" | "all" +$fn = 48; + +// -------------------------------------------------------------------------- +// the board, measured from hardware/safekiln.kicad_pcb +// -------------------------------------------------------------------------- +pcb_w = 100.0; // outline x, becomes the enclosure's DEPTH +pcb_h = 70.0; // outline y, becomes the enclosure's HEIGHT +pcb_t = 1.6; +pcb_hole_inset = 4.0; // proposed M3 centres, inset from each corner +pcb_hole_d = 3.4; // M3 clearance + +// Component heights above the board, for the clearance the shell needs. +// The terminal blocks are the tallest thing and they decide the width. +h_terminal = 11.0; // MX126-5.0 body above the board +h_esp_module = 3.5; // ESP32-S3-WROOM-1 can +h_encoder = 14.0; // EC11E body above the board, before the shaft +h_buzzer = 9.5; // TMB12A05 +h_below = 2.5; // THT lead protrusion under the board + +// Positions on the board, x from the left edge, y from the bottom edge. +// Straight out of the PCB file; see the header. +enc_y = 55.75; // SW3 +usb_y = 23.90; // J2, USB-C +dcin_y = 11.10; // J1, 5 V DC in +term_ys = [11.25, 24.75, 37.05, 50.20, 61.75, 73.15]; // J8 J6 J9 J10 J3 J7 +term_names= ["COIL", "SSR", "LID", "CT", "TC1", "TC2"]; +swd_x = 82.73; // J4, the supervisor's tag-connect +swd_y = 36.11; + +// The module's width is needed before the enclosure's, so it is declared +// here and the rest of its dimensions below with the other display numbers. +oled_pcb_w = 27.0; // CHECK + +// -------------------------------------------------------------------------- +// the enclosure +// -------------------------------------------------------------------------- +wall = 2.4; // 3 perimeters at 0.45 mm line width, and stiff enough + // that a terminal screw being torqued does not flex it +floor_t = 2.4; +clear_x = 4.0; // board edge to inner wall, operator side +clear_rear= 6.0; // behind the terminal blocks, for wire bend radius +clear_y = 3.0; // top and bottom +clear_z = 3.0; // across the board's thickness + +// Internal volume. Depth runs along the board's x, height along its y, and +// the width is across the board's thickness. +in_d = pcb_w + clear_x + clear_rear; +in_h = pcb_h + 2 * clear_y; + +// The board's own stack: leads under it, the board, the tallest part on it. +pcb_stack = h_below + pcb_t + h_terminal; +pcb_z = h_below + pcb_t / 2; // the board's mid-plane, from the wall + +// --------------------------------------------------------------------------- +// WHY THE BOX IS WIDER THAN THE BOARD NEEDS +// --------------------------------------------------------------------------- +// The first version of this made in_w the board's stack and nothing else, +// which gave a front face 23.4 mm wide. The OLED module is 27 mm. The display +// did not fit, and the render said so before anything was printed. +// +// Nor does it fit the other way round. The front face is the board's left +// edge, which already carries three things at fixed heights: the DC input at +// y = 11.1, the USB socket at y = 23.9 and the encoder at y = 55.8. The +// largest clear gap between them is about 25 mm, and a 27 mm module needs +// more than that plus a margin. +// +// So the display goes BESIDE that column rather than above or below it, and +// the box is as wide as the two need together. That is also what a panel +// instrument normally looks like: a screen on one side, a knob on the other, +// and the sockets under the knob where a cable hangs clear of the glass. +oled_lane = oled_pcb_w + 2 * 2.0; +in_w = pcb_stack + clear_z + oled_lane; + +bezel_t = 3.0; // the plate that sits against the kiln's panel +bezel_lip = 8.0; // flange beyond the cut-out, all round +bezel_screw_d = 3.4; // M3 through the panel +corner_r = 3.0; + +// The two parts meet with a lip so the joint is not a straight butt seam: +// a printed butt joint is a light gap and a dust path. +lip_h = 3.0; +lip_t = wall / 2; + +// -------------------------------------------------------------------------- +// the OLED module, J5, on a cable. CHECK against the module bought. +// -------------------------------------------------------------------------- +oled_pcb = [oled_pcb_w, 27.3]; // CHECK +oled_window = [25.0, 14.5]; // CHECK: glass aperture, generous on the + // active 21.7 x 10.9 so the bezel never + // clips a row of pixels +oled_window_off_y = 3.0; // CHECK: window centre above module centre +oled_hole_grid = [23.0, 23.5]; // CHECK: 4 x M2 +oled_hole_d = 2.2; +oled_boss_h = 4.0; // standoff behind the bezel plate + +// Where things sit on the front face, measured from the inner bottom-left. +// +// The knob is not free: it has to land on the encoder, so it follows enc_y, +// and the two sockets follow theirs. The display is the only free one, and it +// takes the lane beside them, centred over the height. +knob_y = clear_y + enc_y; +oled_x = pcb_stack + clear_z + oled_lane / 2; +oled_y = in_h / 2; + +// EC11E: 6 mm shaft, 7 mm bushing. 7.6 lets a printed hole take it without +// reaming, which a 7.2 will not at this wall thickness. +enc_shaft_d = 7.6; +enc_boss_d = 13.0; // raised ring, keeps the knob off the paint +enc_boss_h = 1.2; + +usb_cut = [10.0, 4.0]; // USB-C receptacle aperture, generous +dcin_cut = [11.0, 11.0]; // MX126-5.0-02 body, wire entry forward + +// -------------------------------------------------------------------------- +// helpers +// -------------------------------------------------------------------------- +module rrect(size, r, h) { + linear_extrude(h) + offset(r = r) offset(r = -r) + square([size[0], size[1]], center = false); +} + +// A screw boss with a blind hole: printed vertically so the hole needs no +// support and the layers are in shear, not in tension, when it is torqued. +module boss(d_out, d_in, h) { + difference() { + cylinder(d = d_out, h = h); + translate([0, 0, -0.1]) cylinder(d = d_in, h = h + 0.2); + } +} + +// Sound holes for the buzzer: a grid, because one big hole in a panel is a +// weak point and a dust entry, and the buzzer only needs the air to move. +module speaker_grid(d = 2.0, n = 3, pitch = 4.0, h = 10) { + for (i = [0 : n - 1], j = [0 : n - 1]) + translate([(i - (n - 1) / 2) * pitch, (j - (n - 1) / 2) * pitch, -h / 2]) + cylinder(d = d, h = h); +} + +// -------------------------------------------------------------------------- +// the rear shell: holds the board, takes the wiring +// -------------------------------------------------------------------------- +// Printed open-face-up, so every overhang is a wall and the standoffs grow +// off the floor. No support anywhere. +module shell() { + difference() { + union() { + // outer box + rrect([in_w + 2 * wall, in_h + 2 * wall], corner_r, in_d + floor_t); + // the lip that enters the bezel + translate([wall / 2, wall / 2, in_d + floor_t - 0.01]) + difference() { + rrect([in_w + wall, in_h + wall], corner_r, lip_h); + translate([lip_t, lip_t, -0.1]) + rrect([in_w + wall - 2 * lip_t, in_h + wall - 2 * lip_t], + corner_r, lip_h + 0.2); + } + } + + // the cavity + translate([wall, wall, floor_t]) + rrect([in_w, in_h], corner_r, in_d + lip_h + 1); + + // --- the wiring face: one opening per terminal block -------------- + // Each is a slot rather than one long cut-out, so the wall between + // two mains-adjacent terminals is still there. That wall is the only + // thing standing between the coil wiring and the thermocouple pair. + for (i = [0 : len(term_ys) - 1]) + translate([wall + h_below + pcb_t + h_terminal / 2 + 0.5, + wall + clear_y + term_ys[i], + floor_t / 2]) + cube([h_terminal + 4, 12.0, floor_t + 2], center = true); + + // --- the supervisor's tag-connect, J4 ----------------------------- + // HWR-19 wants SWD reachable. A TC2030 is pressed onto pads, so the + // probe needs room above the board at that point and nothing else. + translate([wall + h_below + pcb_t + 6, wall + clear_y + swd_y, + floor_t + swd_x]) + rotate([0, 90, 0]) cylinder(d = 11.0, h = 20, center = true); + + // --- ventilation -------------------------------------------------- + // Slots in the two long sides, none in the floor: a kiln sheds dust + // and it falls downwards. Along the print direction, so they cost no + // bridging. + for (s = [0, 1]) + for (i = [0 : 5]) + translate([s ? in_w + wall + 1 : -1, + wall + clear_y + 8 + i * 10, + floor_t + 20 + i * 2]) + rotate([0, 90, 0]) cube([30, 2.5, wall + 4], center = true); + } + + // --- the board's standoffs ------------------------------------------- + // M3 bosses off the floor. The board stands on edge, so these take it at + // its four corners: see HWR-24, the holes do not exist on the PCB yet. + for (x = [pcb_hole_inset, pcb_w - pcb_hole_inset]) + for (y = [pcb_hole_inset, pcb_h - pcb_hole_inset]) + translate([wall + pcb_z, wall + clear_y + y, + floor_t + clear_x + x]) + rotate([0, -90, 0]) + boss(7.0, 2.6, h_below + pcb_t + 1.5); // 2.6 for an M3 tap +} + +// -------------------------------------------------------------------------- +// the front bezel: what the operator sees and touches +// -------------------------------------------------------------------------- +// Printed face-down on the plate, so the surface somebody looks at is the one +// the bed made, and the apertures are all vertical walls. +module bezel() { + difference() { + union() { + // the flange that covers the panel cut-out + translate([-bezel_lip, -bezel_lip, 0]) + rrect([in_w + 2 * wall + 2 * bezel_lip, + in_h + 2 * wall + 2 * bezel_lip], corner_r + 2, bezel_t); + // the skirt that receives the shell's lip + translate([0, 0, bezel_t - 0.01]) + difference() { + rrect([in_w + 2 * wall, in_h + 2 * wall], corner_r, lip_h + 1); + translate([wall / 2 + lip_t, wall / 2 + lip_t, -0.1]) + rrect([in_w + wall - 2 * lip_t, in_h + wall - 2 * lip_t], + corner_r, lip_h + 1.2); + } + // the ring the knob sits against + translate([wall + pcb_z, wall + knob_y, 0]) + cylinder(d = enc_boss_d, h = bezel_t + enc_boss_h); + // standoffs for the OLED module + for (sx = [-1, 1], sy = [-1, 1]) + translate([wall + oled_x + sx * oled_hole_grid[0] / 2, + wall + oled_y + sy * oled_hole_grid[1] / 2, + bezel_t - 0.01]) + boss(5.0, 1.7, oled_boss_h); // 1.7 for an M2 tap + } + + // --- the display window ------------------------------------------ + // Chamfered on the outside: a straight-walled window in a 3 mm plate + // reads as a tunnel and clips the viewing angle. + translate([wall + oled_x, wall + oled_y + oled_window_off_y, -0.1]) + union() { + cube([oled_window[0], oled_window[1], bezel_t + 0.2], center = true); + translate([0, 0, bezel_t * 0.45]) + cube([oled_window[0] + 2.0, oled_window[1] + 2.0, + bezel_t * 0.55 + 0.2], center = true); + } + + // --- the encoder shaft ------------------------------------------- + translate([wall + pcb_z, wall + knob_y, -1]) + cylinder(d = enc_shaft_d, h = bezel_t + enc_boss_h + 2); + + // --- USB-C and the DC input -------------------------------------- + translate([wall + pcb_z, wall + clear_y + usb_y, bezel_t / 2]) + cube([usb_cut[1] + pcb_t, usb_cut[0], bezel_t + 2], center = true); + translate([wall + pcb_z + 2, wall + clear_y + dcin_y, bezel_t / 2]) + cube([dcin_cut[1], dcin_cut[0], bezel_t + 2], center = true); + + // --- the buzzer --------------------------------------------------- + // BZ1 is mid-board, so its sound leaves through the front plate near + // the display rather than through a face it does not touch. + translate([wall + oled_x, wall + clear_y + 6.0, bezel_t / 2]) + speaker_grid(); + + // --- panel screws ------------------------------------------------- + // Four, in the flange, clear of the cut-out. + for (sx = [-1, 1], sy = [-1, 1]) + translate([in_w / 2 + wall + sx * (in_w / 2 + wall + bezel_lip / 2), + in_h / 2 + wall + sy * (in_h / 2 + wall + bezel_lip / 2), + -0.1]) + cylinder(d = bezel_screw_d, h = bezel_t + 0.2); + } +} + +// -------------------------------------------------------------------------- +// the knob +// -------------------------------------------------------------------------- +// For the Alps EC11E's 6 mm shaft. Printed BORE UP: the bore is then a +// vertical hole needing no support, the flutes are vertical walls, and the +// face the operator looks at is the one the bed made, which is the best +// surface a printer produces. +// +// Three things a printed knob usually gets wrong, and what is done instead: +// +// THE BORE. 6.0 mm nominal prints undersize on most machines and a knob that +// needs reaming is a knob that ends up glued on. `knob_bore` is 6.15 with a +// shallow lead-in chamfer, which presses onto a splined shaft and holds on +// the splines rather than on a friction fit. For a plain round shaft, set +// `knob_grub = true` for an M3 radial screw instead. +// +// THE GRIP. Flutes, not knurling: a printed knurl at 0.4 mm layers is a +// texture you cannot feel and a place dust sits. Eight flutes at this +// diameter are findable without looking, which is the whole point on a kiln +// where the operator may be wearing a glove. +// +// THE MARK. A moulded pointer, not a painted one. It is a raised rib that +// the print makes for free and nothing wears off. +knob_d = 26.0; // across the flutes +knob_h = 16.0; // tall enough to grip, short enough not to lever the + // encoder's bushing when somebody leans on it +knob_bore = 6.15; // CHECK on the first print: see above +knob_bore_h = 12.0; // leaves a 4 mm solid cap over the shaft +knob_flutes = 8; +knob_flute_d = 5.0; +knob_skirt = 1.5; // thickness over the bore, at the open end +knob_grub = false; // true: add a radial M3 tapping hole for a round shaft +knob_mark = true; + +module knob() { + difference() { + union() { + // the body, slightly tapered so the fingers find the top + cylinder(d1 = knob_d, d2 = knob_d - 2.0, h = knob_h); + // the pointer: a rib up one flank, ending flush with the top + if (knob_mark) + translate([0, knob_d / 2 - 1.2, 0]) + hull() { + cylinder(d = 3.0, h = knob_h); + translate([0, 1.5, 0]) cylinder(d = 1.5, h = knob_h); + } + } + + // the flutes + for (i = [0 : knob_flutes - 1]) + rotate([0, 0, i * 360 / knob_flutes]) + translate([0, knob_d / 2, -1]) + cylinder(d = knob_flute_d, h = knob_h + 2); + + // the bore, with a lead-in so it starts on the shaft without a press + translate([0, 0, -0.1]) cylinder(d = knob_bore, h = knob_bore_h); + translate([0, 0, -0.1]) + cylinder(d1 = knob_bore + 1.2, d2 = knob_bore, h = 1.5); + + // the grub screw, when the shaft is plain round + if (knob_grub) + translate([0, 0, 4.0]) rotate([0, 90, 0]) + cylinder(d = 2.5, h = knob_d); // 2.5 taps M3 + + // relieve the skirt so the wall over the bore is even + translate([0, 0, -0.1]) + difference() { + cylinder(d = knob_bore + 2 * knob_skirt + 0.4, h = 0.6); + cylinder(d = knob_bore, h = 1); + } + } +} + +// -------------------------------------------------------------------------- +// render +// -------------------------------------------------------------------------- +if (part == "shell") shell(); +else if (part == "bezel") bezel(); +else if (part == "knob") knob(); +else { + // Preview: the two parts apart, as they are printed and not as assembled, + // because what matters first is whether each one prints. + shell(); + translate([0, in_h + 2 * wall + 2 * bezel_lip + 10, 0]) bezel(); + translate([-knob_d - 10, 0, 0]) knob(); +} + +// The panel cut-out this needs, for whoever makes the hole: +// width = in_w + 2 * wall + 0.5 mm clearance +// height = in_h + 2 * wall + 0.5 mm clearance +echo(str("PANEL CUT-OUT: ", in_w + 2 * wall + 0.5, " x ", in_h + 2 * wall + 0.5, " mm")); +echo(str("OUTSIDE SIZE: ", in_w + 2 * wall + 2 * bezel_lip, " x ", + in_h + 2 * wall + 2 * bezel_lip, " x ", in_d + floor_t + bezel_t, " mm")); diff --git a/hardware/enclosure/slice.sh b/hardware/enclosure/slice.sh new file mode 100755 index 0000000..b9510a2 --- /dev/null +++ b/hardware/enclosure/slice.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: 2026 Bitcrush Testing +# SPDX-License-Identifier: GPL-3.0-or-later +# +# Render the enclosure and slice it for PLA on a Prusa, from .scad to G-code. +# +# ./slice.sh everything: shell, bezel, knob +# ./slice.sh knob one part +# ./slice.sh --stl-only render, do not slice +# +# Output lands in build/. Nothing is committed from here. +# +# =========================================================================== +# WHY THIS IS A SCRIPT +# =========================================================================== +# Because the per-part arguments are the whole point and a GUI cannot record +# them. The shell gets a brim and the other two do not; the knob is small +# enough to want a slower first layer. Those are decisions about these parts, +# and a decision that lives in somebody's slicer window is a decision that is +# made differently next time. +# +# It also fails loudly rather than producing a quietly wrong result, which for +# a slicer means: no profile, no slicer, a non-manifold render, or a G-code +# file that came out suspiciously small all stop the script. A 2 kB G-code +# file looks like success to a shell and is an empty plate in practice. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$here" + +SCAD=${SCAD:-openscad} +PROFILE=${PROFILE:-pla-0.2mm.ini} +OUT=${OUT:-build} +MODEL=safekiln-enclosure.scad + +# PrusaSlicer ships under several names depending on how it was installed. +find_slicer() { + for c in "${PRUSA_SLICER:-}" prusa-slicer PrusaSlicer prusa-slicer-console \ + "/Applications/PrusaSlicer.app/Contents/MacOS/PrusaSlicer" \ + "/Applications/Original Prusa Drivers/PrusaSlicer.app/Contents/MacOS/PrusaSlicer"; do + [ -n "$c" ] || continue + if command -v "$c" >/dev/null 2>&1; then command -v "$c"; return 0; fi + if [ -x "$c" ]; then echo "$c"; return 0; fi + done + return 1 +} + +stl_only=0 +parts=() +while [ $# -gt 0 ]; do + case "$1" in + --stl-only) stl_only=1; shift ;; + -h|--help) sed -n '4,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;; + shell|bezel|knob) parts+=("$1"); shift ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +[ ${#parts[@]} -gt 0 ] || parts=(shell bezel knob) + +command -v "$SCAD" >/dev/null 2>&1 || { + echo "openscad is not on PATH." >&2 + echo " macOS: brew install openscad" >&2 + echo " Debian: apt-get install openscad" >&2 + exit 1 +} +[ -f "$PROFILE" ] || { echo "no profile at $PROFILE" >&2; exit 1; } + +mkdir -p "$OUT" +echo "openscad: $("$SCAD" --version 2>&1 | head -1)" + +# --- per-part slicer arguments -------------------------------------------- +# +# The shell is 115 mm tall on a 54 x 81 footprint. That aspect ratio is what +# takes a part off the bed when somebody walks past it, so it gets a brim and +# nothing else does: a brim on the bezel would have to be cut off the face +# the operator looks at. +slicer_args_for() { + case "$1" in + shell) echo "--brim-width 3" ;; + bezel) echo "--brim-width 0" ;; + # The knob is 25 mm across and 16 tall, so its first layer is small and + # its grip ends up at the top where any wobble shows. Slower first layer, + # and one more top layer than the profile for the face under a thumb. + knob) echo "--brim-width 2 --first-layer-speed 15 --top-solid-layers 6" ;; + esac +} + +fail=0 +for p in "${parts[@]}"; do + stl="$OUT/$p.stl" + echo + echo "=== $p ===" + "$SCAD" -D "part=\"$p\"" -o "$stl" "$MODEL" 2>&1 \ + | grep -E "WARNING|ERROR|Simple:|Volumes:" || true + + # A render that is not a closed solid slices into something unpredictable, + # so it stops here rather than at the printer. + if ! grep -qi "solid\|facet" "$stl" 2>/dev/null; then + echo "::error::$stl is not an STL" >&2; fail=1; continue + fi + facets=$(grep -c "facet normal" "$stl" || true) + echo "facets: $facets" + if [ "${facets:-0}" -lt 100 ]; then + echo "::error::$p rendered only $facets facets, which is not this part" >&2 + fail=1; continue + fi + + [ "$stl_only" = "1" ] && continue + + slicer=$(find_slicer || true) + if [ -z "$slicer" ]; then + echo "PrusaSlicer not found; $stl is ready to slice by hand." >&2 + echo " Set PRUSA_SLICER=/path/to/prusa-slicer, or install it." >&2 + echo " Then: $0 $p" >&2 + continue + fi + + gcode="$OUT/$p.gcode" + # shellcheck disable=SC2046 # the per-part arguments are meant to split + rm -f "$gcode" + # shellcheck disable=SC2046 # the per-part arguments are meant to split + "$slicer" --export-gcode --load "$PROFILE" $(slicer_args_for "$p") \ + --output "$gcode" "$stl" >"$OUT/$p.slicer.log" 2>&1 || true + # Not `|| { ... }`: PrusaSlicer exits ZERO when it refuses a configuration. + # It printed "Relative extruder addressing requires resetting the extruder + # position at each layer", wrote no file, and returned success. So the + # existence of the output is the test, not the exit status. + if [ ! -f "$gcode" ]; then + echo "::error::slicing $p produced no G-code (the slicer exits 0 when "\ + "it refuses a configuration); see $OUT/$p.slicer.log" >&2 + tail -3 "$OUT/$p.slicer.log" >&2 || true + fail=1; continue + fi + + # A G-code file that exists is not a G-code file that prints. These three + # lines are what distinguish a sliced part from an empty plate. + bytes=$(wc -c < "$gcode" | tr -d ' ') + moves=$(grep -c "^G1 " "$gcode" || true) + if [ "$bytes" -lt 20000 ] || [ "${moves:-0}" -lt 500 ]; then + echo "::error::$gcode is $bytes bytes with $moves moves, which is an "\ + "empty plate rather than a printed part" >&2 + fail=1; continue + fi + # --- did the profile actually apply? ------------------------------ + # + # The reason this check exists: the first version of pla-0.2mm.ini had + # [print:]/[filament:]/[printer:] sections, which makes it a config BUNDLE. + # `--load` ignored them without a word and sliced at 0.3 mm, 200 degC and a + # cold bed. The G-code was 891 kB with 30 000 moves and passed every check + # above it. + # + # PrusaSlicer writes its whole resolved configuration into the G-code + # footer, so the settings can be read back from the artefact rather than + # assumed from the file that was passed in. Four are checked, chosen + # because each would ruin the print on its own and none is visible in a + # preview: the layer height, both temperatures and the perimeter count. + for want in "layer_height = 0.2" "temperature = 210" \ + "bed_temperature = 60" "perimeters = 3"; do + if ! grep -qx "; $want" "$gcode"; then + got=$(grep -E "^; ${want%% =*} = " "$gcode" | head -1 | sed 's/^; //') + echo "::error::$p was sliced with '${got:-nothing}' where the "\ + "profile says '$want': $PROFILE was not applied" >&2 + fail=1 + fi + done + + # The estimate and the filament, straight from the slicer's own summary. + grep -E "^; (estimated printing time|filament used \[g\])" "$gcode" \ + | sed 's/^; / /' || true + echo " $gcode ($bytes bytes, $moves moves)" +done + +echo +if [ "$fail" -ne 0 ]; then + echo "one or more parts did not complete" >&2 + exit 1 +fi +cat <<'NOTE' +Before printing, two things worth a moment: + + PLA SOFTENS AROUND 55 TO 60 degC. This enclosure bolts to the panel of a + kiln. Measure the panel at temperature before trusting PLA on a real + installation, and read the note in README.md: PETG or ASA is the answer if + it runs warm, and the same profile slices either with its own filament + settings. + + THE KNOB'S BORE IS THE ONE DIMENSION TO CHECK FIRST. Print the knob alone, + try it on the encoder, and adjust knob_bore in the .scad before committing + to the two large parts. It costs four minutes and saves two hours. +NOTE diff --git a/tasklist.md b/tasklist.md index 15ff3d5..df1658c 100644 --- a/tasklist.md +++ b/tasklist.md @@ -19,8 +19,8 @@ requirements are met. **P3** polish. |---|---| | **Firmware logic** | Complete and tested. 26 host suites green plain and under ASan/UBSan, `kiln_core` lines 91.8 %, clang-tidy clean on host and target, both IDF configurations build. QEMU boots and fires the simulated image with seven assertions. | | **Firmware on hardware** | 15 of 16 ports wired; only `update` has nothing behind it (`H2`). **None of it has run against hardware** (`L2`, `O3`, `P4`, `M3`). | -| **Schematic** | ERC clean apart from one known `SDO` false positive. Carries the lid interlock; does not carry the supervisor (`R6`). | -| **PCB** | Placement started, **not routed at all** (`A23`). | +| **Schematic** | ERC clean apart from one known `SDO` false positive. Carries the lid interlock, the supervisor (`U5`, STM32G031K8Tx), both MAX31856 and the SWD tag-connect. | +| **PCB** | 182 tracks and 9 vias placed, so routing is started and not finished. 14 of 125 footprints sit outside the outline (`A24`), and there are no mounting holes (`A25`). | | **Documents** | Requirements, architecture, safety, security and the supervisor's unit design all build in CI and gate it. | --- @@ -65,8 +65,37 @@ changes against it. can disagree. `HWR-21`. - [ ] **A23a. Finish the KiCad rename to `safekiln`.** Files are renamed; check no stale references remain. -- [ ] **A23. Route the board, and review the mains-adjacent nets. P1.** No - tracks and no vias exist yet. `HWR-23`. +- [ ] **A23. Finish routing, and review the mains-adjacent nets. P1.** 182 + tracks and 9 vias exist, so this is unfinished rather than unstarted. + `HWR-23`. +- [ ] **A24. 14 of 125 footprints sit outside the outline.** `U4`, the second + MAX31856, is at y = 117.9 against an outline ending at 110, and `J7`, the + second thermocouple terminal, at y = 73.2 against 70. A part outside the + outline is not placed: it will not be fabricated where it appears. + `HWR-25`. +- [ ] **A25. The board has no mounting holes. P1.** Nothing fixes it to + anything. The enclosure proposes four M3 at 4 mm in from each corner; + `HWR-24`. + +--- + +## B. Enclosure + +Designed in [`hardware/enclosure`](hardware/enclosure/README.md): panel-mount, +three printed parts, every dimension read from the PCB. Renders and slices, and +nothing has been printed. + +- [ ] **B1. Print the knob and check its bore.** `knob_bore` is 6.15 mm for a + splined EC11E shaft. One 27-minute print decides it, and it gates the other + two. +- [ ] **B2. Check the OLED module against the three `CHECK` parameters.** The + window, the module outline and the 4 × M2 grid are the common 0.96″ variant + and modules differ. +- [ ] **B3. Decide the material.** PLA softens at 55 to 60 °C and this bolts + to a kiln's panel, which `SWR-SAF-11` exists because of. Measure the panel + at temperature; PETG or ASA slices from the same profile. +- [ ] **B4. Check the SWD probe clearance.** The shell's opening is positioned + from `J4`, but a TC2030 needs the probe square to the pads (`HWR-19`). --- @@ -298,13 +327,13 @@ Inventory in [`docs/security.md` §13](docs/security.md). The firmware, the requirement deltas and the link tests are done. What is left is hardware. -- [ ] **R6. Draw the supervisor. P1.** Part and pin map are settled: - STM32G031K8T6, LQFP32, assignment in - [`firmware/supervisor/README.md`](firmware/supervisor/README.md). Three - things the pin map asks of the board: the permit line needs an external - pull-down (`HWR-03`), SWD must come out to test points (`HWR-19`), and on the - ESP32 side the link lands on one receive-only pin, and `UART0` is the console - and must not be used. +- [ ] **R6. Finish the supervisor's schematic. P1.** It is drawn, not + finished: `U5` (STM32G031K8Tx, LQFP32), both MAX31856 and the SWD + tag-connect `J4` are placed. What the pin map still asks of the board is the + permit line's external pull-down (`HWR-03`), SWD out to test points + (`HWR-19`), and the link landing on one receive-only ESP32 pin, since + `UART0` is the console and must not be used. `U4` is also outside the + outline (`A24`). - [ ] **R11. The clear button on the panel.** The firmware implements it (edge triggered, held 0.5 s, a line stuck low never arms). What is left is physical: the button, its position relative to the HMI, and whether it is