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