Skip to content

Latest commit

 

History

History

devices

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

devices/ — one manifest per Canary, every fact joined

A Canary device is described in seven places, each with its own generator and its own byte-diff gate: the firmware envs (firmware/flavors.json, firmware/build_matrix.json, firmware/envs/platformio/*.ini), the board registry (firmware/boards/boards.json + boards/<id>/pins/pins.h), the emulator (canary-local/emulator/build.sh + dist/*.meta.json), the fleet figures and their confidence ladder (canary-local/devices/figures.json), the enclosure CAD (docs/hardware/enclosure/*.scad + canary-local/devices/enclosures.json), the flasher catalog (canary-local/devices/flash.json) and the website (its page and glTF model). The gates are good. What was missing is the join — nothing said that this env, this pins header, this figure, this flasher product and this case are the same object.

devices/<slug>/device.json is that join, one file per device. It carries the id each of those files uses for one device: no feature lists, no prose, and — deliberately — no status. The one thing in it that is not an id is cad.params, the board and module dimensions its case is built around, and it holds those as the owner, not a mirror: gen_cad_params.py writes them into the case .scad and its --check proves the file still says them — a board fact as a reference into the board registry that already carries each board's evidence, a case measurement as a number. Wave 1 ("Describe") of the roadmap's §4 wrote the manifests and the lint; wave 2 ("Consume") made the generators read them — see "What the manifests drive" below for exactly which facts, per generator; wave 3 ("Parametrize") put the manifest in front of the enclosure chain — the cad.params rows below.

The manifest

Validated by device.schema.json (JSON Schema 2020-12; the linter carries its own stdlib validator because CI has no jsonschema).

Key What it names Proven against
slug the directory name; where a build_matrix.json or flash.json product names this hardware, the same id the directory; uniqueness
name the product name as the flasher shows it (copy — not linted)
family the firmware product it is built from firmware/flavors.json name
board.mcu · psram_mb · flash_mb the silicon boards.json row for board_id
board.board_id the firmware/boards/<id>/ registry row boards.json; each env's resolved board = is that row's pio_board; each env's -I boards/<id>/pins include is this id (or a variant)
board.variants SKU siblings of the same panel module whose pins headers some envs compile (the Dash 4.3 / 4.3B / 4.3C) boards.json; same MCU
board.envs every [env:NAME] that targets this hardware the family's platformio.ini with extra_configs resolved; claimed by exactly one device; every flavors.json build env is claimed or explained in unclaimed.json
peripherals lower-cased HAS_<NAME> flags #define HAS_<NAME> 1 in boards/<board_id>/pins/pins.h
figure the fleet figure that draws this hardware figures.json: role device, same family, agrees with its hardware map for board_id and with every flash.json product that draws this device
cad.scad · cad.also · cad.enclosure_sets the enclosure source, any further case file the same knobs own (a second form of one build — the Vision's doorbell), and the printable sets — which is also where the Lab homes each set (gen_enclosures.py inverts the lists) each file exists; sets exist in enclosures.json; cad.scad and every cad.also file is the source of one of them; from the other side, every set enclosures.json homes on a device is claimed by a manifest, with device / devices equal to the inversion
cad.params the literal Customizer knobs of cad.scad (and of every cad.also file) this device asserts — the board and module dimensions the case is built around (board_l, vm_w, stack_sock_h), as plain numbers, strings or booleans, or as references into the board registry ({"brd": "xiao", "dim": "l"} is brd_l("xiao"); {"brd_fn": "brd_xiao_w_measured"} is that measured fact) that the generator resolves from docs/hardware/enclosure/canary_board_lib.scad before it writes. Never a selector (host, radar, preset, part — chosen per printable set at render time), never a computed value, never a design-language knob (wall_t, corner_r — case-owned, with deviates: reasons, lint_design_lang.py) gen_cad_params.py writes each value into the .scad's literal (the token on the knob's own line, nothing else) and its --check, which this linter also runs, proves the file still says it; the knob must be one gen_builder_manifest.parse_scad accepts; a reference must name a BRD_REGISTRY row and column or a brd_*() fact the library defines; manifests sharing one .scad may each assert a subset and must agree on shared keys
emulator.flavor the browser twin build.sh allowlist; dist/canary-display-<flavor>.meta.json exists; build.sh compiles this device's pins dir for that flavor; every dist display flavor is claimed
flasher.product · flasher.variants the browser-flasher catalog entries that install onto this hardware flash.json: exists, chip == board.mcu, tier.board_id is this board, flash_mb agrees; every catalog product is claimed exactly once
lab.card the canary.local Lab card (canary-local/devices/registry.json id) that presents this hardware, only where it is not the default — the family's card when the family is itself a manifest, else the slug. Today one manifest needs it: the Nightlight's card is canary-nightlight (the id the Lab, its fleet figure and the scene builder share), its manifest canary-display-nightlight-c3 (the flasher product and the build env), so gen_enclosures.py homes the C3 case on the card and lists the manifest under devices registry.json has the card; a key that restates the default is refused; and, for every manifest, a claimed enclosure set lands on a card — a manifest whose home is no card fails, so a case cannot sit on a page the Lab does not have
site.page · site.model the website page and AR model shape only — those files live in the securacv_website repository and cannot be verified from here

Plus a cross-file rule: every firmware/build_matrix.json product has a manifest (by id, else by flavor + env), and its board, mcu and env(s) agree with it.

The confidence ladder is never typed here. status, confidence and tier are rejected by the schema (additionalProperties: false). The verdict for a device is figures.json's confidence for its figure — derived from evidence on disk (committed STLs, a firmware config, a released catalog variant) — and scripts/lint_device_manifests.py prints it in its table. A device with no figure yet shows —; that is the gap, honestly.

A key whose value is unknown is omitted, not guessed. No figure has been drawn for the four canary boards; the Glance AMOLED has no enclosure yet; the C3 Super Mini has neither. Those keys are absent. The two Sentinel manifests (canary-sentinel, canary-sentinel-lite) carry less still. The family is Phase 1a, compile-gated and marked unreleased in flavors.json, so they name the board, its peripherals and the env CI compiles, and no more: no figure, case, flasher product or emulator flavor exists for them yet.

One key is absent by decision instead: the Nightstand C6's pocket case is drawn (device.canary-display-nightstand-c6, a sketch off its .scad, for its Lab card), but its manifest does not name that figure yet. Naming it maps the C6 board in the firmware's figure table (fleet_figures.h and a CANARY_FIGURE_HARDWARE line in its pins.h), a firmware change of its own.

A knob that is a board fact references the registry; a knob that is a case measurement is a number. docs/hardware/enclosure/canary_board_lib.scad already holds every board the catalog mounts, once, with its evidence rung (BRD_REGISTRY: id, along-USB length, width, PCB thickness, status, note) and the measured facts that refine a row, so a manifest does not retype 21.0 — it names the entry:

"xiao_l": { "brd": "xiao", "dim": "l" },        // brd_l("xiao"); dim: l (along USB), w, t (PCB)
"xiao_w": { "brd_fn": "brd_xiao_w_measured" },   // a fact: function brd_xiao_w_measured() = 17.8;
"xiao_below": 5.5                                // a case measurement: no registry home, a number

gen_cad_params.py resolves a reference to the registry's number before it writes, so the .scad still receives a literal and nothing at render time changes; what changes is that a registry correction now reaches every manifest-owned case — --check reports it as "registry says X, file says Y" and a write carries it into the file. A reference is declared only where the knob's help comment already cites the registry; a knob that merely equals a row by coincidence (the WAP's board_h 1.2, the Sense's uncommented pcb_t) stays a number. One reference leans on less than the others: the Vision's vm_w comment never spells brd_w("grove_v2") — it cites the [Boards] group header (which points at the registry) and board_selfcheck(), and the test accepts that as the citation. A design decision is which registry entry the manifest names: the WAP and Sense clips name brd_w("xiao") (the 17.5 spec width — the clips absorb the measured board) and the Vision pins name brd_xiao_w_measured() (17.8); the registry states the truth, the manifest states the decision, as the library's header asks each file to. The rows and facts no manifest references (the solar relay pod's heltec_v3 row, the doorbell's unmeasured stack guess, the two brass-pillar facts the C3 and the C6 cite by comment) are printed by --check as INFO, never an error — those entries are cited by comment only (the solar pod has no manifest; the doorbell's manifest — the Vision's, through cad.also — names the measured stack fact, not the guess; the C3 and the C6 own their board row but not the pillar fact — the C6's brass_h is maintainer-gated, the fact says 5.0 and the file prints at the C3's measured 3.0). The library is read, never written: correcting a board dimension is still an edit to the registry.

Run the gate

python3 scripts/lint_device_manifests.py          # table, then errors; exit 1 on any
python3 docs/hardware/enclosure/gen_cad_params.py --check   # the cad.params half alone; a
                                                  # failure prints the regen order to follow
python3 docs/hardware/enclosure/gen_cad_params.py --dry-run canary-wap:board_w=17.8
                                                  # the diff a manifest edit WOULD write; nothing written
python3 scripts/regen_cad.py --check              # every generator downstream of a knob, in order
python3 -m unittest scripts/tests/test_device_manifests.py scripts/tests/test_gen_cad_params.py \
    scripts/tests/test_regen_cad.py -v

CI runs the first in .github/workflows/lint.yml (Repo Lints) beside lint_build_matrix.py, and the second through unittest discover -s scripts/tests. The lint is grep-grade: it reads the ini files itself (no PlatformIO) and the emulator's build.sh (no emsdk). The ini resolver and the build-matrix→manifest resolution live once, in scripts/_device_join.py, and both lints import them — lint_build_matrix.py applies the matrix's side of the join (every lane resolves to one manifest; every manifest env is a real [env:NAME]) so the two gates cannot disagree about which device a lane is.

Decisions the manifests encode

  • One device = one piece of hardware. The three canary reach ports (ESP32-CAM, WROOM DevKit, Freenove S3) are separate devices, as build_matrix.json already treats them; so are the four Vision hosts (XIAO C3, C3 DevKit, C3 Super Mini, XIAO S3) — each has its own pins header and its own flasher product. canary-vision is the XIAO C3 host because that is the board build_matrix.json describes under that id; the flasher product literally named securacv-canary-vision is the DevKit build and belongs to canary-vision-devkit.
  • Feature-variant envs stay with their hardware. The Dash's dash-b/-rs485/-can/-vault/-sd/-rtc/-espnow/-modes/-mic/-ble5 envs are one device with board.variants naming the 4.3B and 4.3C pins headers they compile; peripherals are proven against the plain 4.3 only, so the mic-bearing 4.3C's microphone is deliberately not listed on the Dash. The same 7" board carries both canary-display-dash7 and canary-display-nightstand7 as distinct products (the OTA engine must never cross-grade them), so they are two manifests sharing a board_id, a figure and a case.
  • board.envs = what CI builds for this hardware plus what ships. The flavors.json build_envs the device claims, plus any env build_matrix.json / flash.json name as the published image that PR CI does not build (none today: the flagship's release_ha is in build_envs, so the shipped image and its OTA-slot guard run on every PR). Envs no flavors.json list builds are not listed: the debug envs (*-debug), the WAP's mobile, the canary's minimal, dev_ha and usb-onboard and its secure / secure_ha (each compiled by a compile-only firmware.yml step, not a flavor env) and the Sentinel's window / hallway / demo-head presets (door's image with other preset data).
  • peripherals come from the registry's pins header, even for the canary and canary-wap trees, whose builds carry their pins in build flags and the sketch rather than compiling boards/<id>/pins/pins.h. The header is still the declared capability map for that board (firmware/scripts/check_board_registry.py keeps it honest).
  • Website paths are declared, not verified. The canary-vision, canary-sense and canary-display-watch manifests name a glTF model. The website carries more than that: the WAP's and the 4.3" Dash's models exist with no site.model key yet (a one-line, shape-only manifest edit each, not yet made); the doorbell's has no manifest of its own (its case rides the Vision manifest's cad.also, and site.model holds one model); and the Combo's has no manifest to name it either (its catalog variant is a canary-vision build). No per-device page exists yet, so site.page is absent everywhere.
  • A shared case is owned as a union of subsets. Several manifests may name one cad.scad (the three Vision hosts; the 7" Dash and Nightstand). Each asserts the knobs about its hardware in cad.params — canary-vision the stacked-XIAO, module and camera knobs, canary-vision-devkit its dk_l / dk_w / stack_h — the union is written, and a key two manifests both assert must agree or the gate names both. canary-vision-xiao-s3 asserts nothing: same case, same host, so the union already covers it. Per-case design decisions stay per case, stated as which registry entry is named: xiao_w is brd_w("xiao") (the 17.5 spec width) in the WAP and Sense clips and brd_xiao_w_measured() (17.8) in the Vision pins, and each manifest says exactly what its case does.
  • One manifest may own two case files (cad.also). The doorbell is a second form of the stacked-XIAO Vision build — the same board, the same eleven board knobs name for name and value for value — not a second device: its env is canary-vision's, board.envs has a floor of one, and the hardware→figure map draws xiao-esp32c3 as device.canary-vision, so a devices/canary-vision-doorbell manifest is rejected by three joins and is deliberately not created. devices/canary-vision lists docs/hardware/enclosure/canary_vision_doorbell.scad under cad.also instead; gen_cad_params.py writes the same cad.params into every file of [cad.scad, *cad.also] and --checks each, and the linter proves each file is the source of a listed enclosure set (vision-doorbell). The first write moved zero doorbell bytes — the eleven knobs already agreed. Since v0.6 the two cases also share the OV5647-62's nine camera facts (brd_ov5647_*() in the registry: the hole grid and its offset, the lens offset, the barrel and holder) — the camera both hang on posts, owned once here so its hole cannot sit in a different place in either. The blast radius of a Vision knob edit is now two released, fit-checked cases: the edit writes both files, --dry-run prints both diffs and names both, and the change owes both preview sets (26 for the Vision, 12 for the doorbell) — the doorbell also being website-carried and sha256-pinned. The doorbell's knobs are not carried into cad-dims.json under device.canary-vision-doorbell (knobs ride on the figure a manifest draws, and the website's doorbell model consumes none); a later carry can add them.
  • The display cases own what is literal, and name what they cannot. Six display manifests carry cad.params; five joined with zero .scad bytes moved, the sixth (the C6, below) with six dead ternaries made literal. The Nightlight C3 and the Nightstand S3 name the ws147 family row for board_l / board_w / pcb_t (their comments already cite brd_l("ws147") and kin, MEASURE caveat and all — the row's drawing rung says the same). The Watch Station names brd_l("round_disp") for disc_d and copies its three measured stack numbers (disc_t, stack_t — the whole pinned stack on the calipers, glass front to USB-shell face; the socket zone the case derives from it — and xiao_t) — and its stand recline, tilt, the one non-board knob a manifest owns: it retired the Lab registry's hand-typed stand_tilt_deg, so the number the Lab shows is the literal the stand is cut to. The Touch 1.69 names the ws169 row for pcb_w / pcb_h / pcb_t and the brd_ws169_glass_*() facts for glass_w / glass_h — crosswise: the registry's axes follow the board's along-USB length, which is that case's Y, so the case's X-width is the registry's glass height, exactly as the file's own comment explains; glass_t, r_glass, aa_w, aa_h and — since its aa_dx = 0.0; aa_dy = 0.0; line was split into two lines, one knob each with MEASURE on both (the generator refuses a two-statement line by design, so the split was the .scad change, with its eight previews) — aa_dx / aa_dy are numbers: the 0.0 the case is cut to today, owned as the placeholders they are, not blessed. The Dash copies panel_l / panel_w / glass_t / stack_t as numbers: there is no registry row for the 4.3 panel and all four are MEASURE placeholders — owning them documents the value the case is cut to today, it does not bless it. The Nightstand C6 joined last: its board_l / board_w (and aa_l / aa_w / lcm_l / lcm_w) were model ternaries whose other branch held invented numbers and was forbidden by the file's own assert(model == "1.47"), so the six became the 1.47 literals they always evaluated to — no geometry moved, gen_assembled_dims.py --check and gen_figures.mjs --check say so — and the manifest names the ws147 row for board_l / board_w / pcb_t, the board trio only; the four screen numbers stay literal knobs in the file, ownable and deliberately not owned yet. The C6's manifest names no figure (its board is unmapped, no STL is committed; the Lab's sketch of its case is a figure no manifest names), so its knobs are owned, not carried: correct in the .scad, absent from the website's cad-dims.json until its manifest's figure draws it. A moved ws147 row now moves all three 1.47 cases, and the test suite pins it. Not owned, by construction rather than by omission: the selectors (headers, port, model, panel_variant) and the 7" Dash / Nightstand — "What a manifest cannot yet own" below says exactly why. One caution beside the Watch's and the Dash's knobs: canary_watch_station.scad and canary_dash_display.scad are REFERENCE_SCADS carries (gen_builder_manifest.py --site copies them to the website, sha256-pinned — the sources its AR models trace), so the first real edit to a Watch or Dash knob moves the website's carried copy. Their fleet figures follow the CAD: each is measured off its case as seated (gen_assembled_dims.py, dims_source: "assembled-cad"), so a knob edit that moves the case turns gen_assembled_dims.py --check red until the ledger is regenerated. canary_combo.scad is carried and measured the same way, though no manifest owns its knobs: the Combo has no device of its own (its catalog variant is a canary-vision build), so its figure draws no board and carries no knobs.

What a manifest cannot yet own

Written down so the gap is a list, not a memory. Each entry names the mechanism that stops the join today and the smallest honest next step.

  • The 7" Dash / Nightstand panel record (canary_s3_lcd7.scad reading canary_panel_lib.scad). Three things, any one of which is enough:

    1. every panel number in the case is a computed read — glass_w = pnl_glass_w(PANEL); with PANEL = panel(panel_variant); — so gen_builder_manifest.parse_scad, the eligibility set, returns none of them, and the generator has no literal token to write;
    2. the record itself is a nested positional list literal in panel_db() (["lcd7", …, [192.96, 110.76, 4.0, 8.2, 1.2], …]), outside the writer's token grammar (one name = <scalar literal>; per line) — owning it means a second grammar, and a second grammar is a second parser to keep honest;
    3. both files are gen_stamp.py-hashed: any byte moved in either is a human STAMP_REV bump plus the 7" fit matrix in CI.

    The smallest honest follow-up is a join, not ownership: a cad.panel: "lcd7" key checked against panel_db() and the case's panel_variant default — proving the manifest and the case name the same panel record without writing a number. That is a separate decision, not taken here.

  • The selectors (host, preset, radar, headers, port, model, panel_variant, part): chosen per printable set at render time in render.sh, canary_case_fitcheck.scad and gen_assembled_dims.py. Making them manifest-owned is the render-plan package ("What comes next").

  • The C6's four screen numbers and its brass_h. aa_l / aa_w / lcm_l / lcm_w are literal knobs the manifest could own and does not yet — whether a bonded module's active area is a board fact is a decision about the registry's shape, not a mechanism. brass_h is maintainer-gated: the registry fact brd_ws147_brass_c6() says 5.0, the file prints at the C3's measured 3.0, and owning either number would bless it.

What the manifests drive

Wave 2 ("Consume") pointed the generators at the manifests one at a time, with each generator's byte gate proving its output did not move. Honestly, per generator — what is read from here, and what is still typed elsewhere:

Consumer Reads from the manifest Still its own
canary-local/tools/gen_flash.py (→ flash.json) which device each flasher product installs onto (flasher.product / variants — a product no manifest claims refuses to generate); that device's board.mcu (the chip guard), board.flash_mb, board.board_id (the support tier, re-checked against boards.json's mcu), and its family → the PlatformIO project. The build's pins header must be the manifest's board_id or a listed variant, and a resolved figure must equal the manifest's figure. Loader: canary-local/tools/_devices.py. the human copy, asset_stem, env, the flasher's own family grouping, the PlatformIO board (still re-derived from the firmware tree and cross-checked); one board_id override for a product that installs onto a board.variants sibling (the modes build on the 4.3B); the WAP's figure, declared because its sketch compiles no pins header (kept in the table so figure.via stays a true sentence — checked against the manifest).
canary-local/tools/figures/gen_figures.mjs (→ figures.json, fleet_figures.h, FleetFigures.swift, …) the hardware→figure map (hardware.mapped, figure_for_hardware()): a manifest's figure draws its board.board_id — and only that; variants stay unmapped as different housings; two manifests drawing one board differently fail the build. the coarse config→device-type map (CONFIG_FIGURE, feeding device_types / configs_audit), because a config directory is not one board and the WAP's envs compile no configs/ include. The manifests validate it in both directions (every row backed by a device with that figure; every typed config a drawn device compiles has a row) and record its one dispute: canary-vision/default is compiled by the DevKit (own figure since its housing was traced) and the two XIAO hosts (the stacked-XIAO figure), all publishing device type canary-vision; the row keeps the XIAO figure pending a decision (unmapping the type moves the firmware and Swift tables).
scripts/lint_build_matrix.py every build_matrix.json lane resolves to one manifest (by id, else flavor + env — the same function this linter uses); every manifest's board.envs is an [env:NAME] of its family's project the feature-flag cells, which are platformio.ini / canary_config.h facts, not device facts
.github/workflows/firmware-release.yml, flasher-release.yml (not the manifests — firmware/flavors.json release_envs, via flavor_envs.py --release --json; roadmap item 23, landed in the same wave: neither workflow types a display env any more) —
docs/hardware/enclosure/gen_cad_params.py (→ the case .scad literals) cad.params: the board/module knobs of cad.scad and of every cad.also file (the Vision manifest owns canary_vision_doorbell.scad beside its own case — same knobs, same values, both written) — a number, or a reference the generator resolves from the board registry (canary_board_lib.scad, read and never written). The generator writes them into the file — the literal token on the knob's own line, nothing else — so the Customizer, render.sh, the fit check, the web builder's manifest and lint_design_lang.py all keep reading the same literal knob they always did; --check proves equality, and the first run over the committed tree changed zero bytes. A knob the builder's parser does not accept (computed, module-local, [Hidden]), a selector, a type mismatch, a two-knob line or a reference the registry does not define is refused by name. walls, tolerances and every feature knob (the design-language canon — case-owned, explained with deviates:); the enum selectors (host, radar, preset, part — per printable set, in render.sh and the fit check); computed knobs (board_stack_h) and knobs read from a registry (canary_s3_lcd7.scad's panel record — "What a manifest cannot yet own"); and envelope_mm, which is never an input — every case derives its outer size from board dims + walls, the ledger measures it off the STL (gen_assembled_dims.py), and the manifest reaches it through figure
canary-local/tools/gen_enclosures.py (→ enclosures.json, catalog.json, workshop.json) cad.enclosure_sets, inverted: each set's device is the first manifest (slug order) that lists it — homed on the Lab card its lab.card names (the C3 case on canary-nightlight), else on its family's device when the family is itself a manifest, so the DevKit's and the XIAO S3's cases stay on the canary-vision page — and devices every manifest that lists it whenever device alone does not say them (the 7" case serves the Dash 7 and the Nightstand 7); the catalog's device_compat is every claimant; the page-side twin is canary-local/assets/enclosure-sets.js the README variant tables (names, parts, previews) and a table of README-name hints, consulted only for a set no manifest lists — and the linter fails any set homed that way, so a hint can only ever point at a missing claim
docs/hardware/enclosure/gen_builder_manifest.py --site (→ the website's scad/cad-dims.json) cad.params, resolved and merged by gen_cad_params.py and joined through figure to the fleet figure it draws, carried as that figure's knobs (the DevKit's three land on its own figure, not the stacked-XIAO's, though both name one case); the carry refuses to run while a manifest disagrees with its case, so the site never reads a knob the released STLs were not rendered from the envelopes and assembled seams (seams_mm, measured — figures.json, gen_assembled_dims.py); an assembled case's face features (features_mm, the Combo's lens aperture and radome window — measured off the case's own cut variables by gen_assembled_dims.py, read from its assembled_dims.json); the board registry, carried whole with its evidence rung as board_registry / board_facts (canary_board_lib.scad); and reading those keys on the website side, which is the website repository's change

Still typed, deliberately: the confidence ladder (derived from evidence by the figures generator, never here), the emulator twin aliases in gen_flash.py (TWIN_ALIASES — a per-product judgment that a sibling's build shows the same face, not a device fact), and firmware/build_matrix.json's board / mcu cells (mirrored, and proven equal to the manifest by the linter; making the matrix generator read them is a wave of its own).

What comes next

  • Wave 3 — Parametrize (landed in part). The manifest owns the board knobs of the released cases (cad.params, above): a dimension edit in a manifest is written into the .scad by gen_cad_params.py, and from there the chain that already exists re-renders the enclosure (render.sh), re-measures the envelope (gen_assembled_dims.py), redraws the figure (gen_figures.mjs) and re-carries the CAD ledger the website's AR models are pinned to (gen_builder_manifest.py --site). That order is one command — scripts/regen_cad.py runs the thirteen generators and gates in the order each one's inputs dictate, stops before the emulator dist rebuild when the figure headers moved (the rebuild is upstream of the catalogs; --from gen_flash resumes), renders the owed PNG previews of every part of every changed case with --previews DIR, and --check runs every step's check form and names the first stale one — and gen_cad_params.py --dry-run SLUG:KNOB=VALUE shows the one-line diff an edit would write before anything moves. envelope_mm is deliberately not an input — every case derives its outer size from board dims + walls and the ledger measures it — so figure stays the join to the AR model. A knob that is a board fact is a reference into the board registry (canary_board_lib.scad), so a registry correction flows to every owned case through the same generator. The display cases joined with pure JSON (five manifests, twelve more references, zero .scad bytes — "The display cases own what is literal" above); wave 6 closed three of the four leftovers — the C6's board trio (six dead ternaries made literal), the 1.69's aa_dx / aa_dy (the line split), and the doorbell (owned through the Vision manifest's cad.also). Still open: the 7" frame (its panel record is computed from canary_panel_lib.scad — "What a manifest cannot yet own" below), the selectors — chosen per printable set in render.sh, the fit check and gen_assembled_dims.py; making them manifest-owned is a render-plan package that rewrites those three and enclosure CI, deliberately not built without the maintainer's yes (its other half — re-homing the Lab cards the README name regex mis-homed — landed on its own, see the render-plan note below) — and the website's own half of the ledger — gen_builder_manifest.py --site now carries each owned device's resolved knobs, its assembled seams_mm and the board registry with its evidence rung in scad/cad-dims.json (and --site <checkout> --check says whether the carry is current), and the site's reading of the new keys — its make-*-glb.mjs and cad-dims.mjs take seamsMeters, knobMeters and boardMm from the carry, models byte-identical — is website PR #197. A real dimension edit still owes the render previews AGENTS.md requires with every .scad change — the generator lists the changed lines, and regen_cad.py --previews renders them, so that obligation is a list and a directory, not a memory.
  • The render-plan package — its card half landed, its selector half is not built. The Lab used to home each enclosure set by a regex over its README name, and five display cases sat on the wrong page (the C6 pocket case and the 1.69 puck on the Watch, the 7" case on the Dash, the S3 hallway stick and the C3 pocket case on every page as "universal"). Now gen_enclosures.py inverts cad.enclosure_sets (above, "What the manifests drive"), the linter refuses a set homed any other way, and each of the five sits on the device whose manifest lists it — the 7" case on both the Dash 7 and the Nightstand 7. Two of those devices had no Lab page to show it on — the Nightstand C6 had no registry.json entry, and the C3 case's manifest (canary-display-nightlight-c3) and the registry's canary-nightlight named one device two ways. Both do now: registry.json carries cards for the Nightstand C6 and the Nightstand 7, and the C3 manifest names its card (lab.card: canary-nightlight — the registry id stays, it is the id the Lab's figure and scene builder share), so the C3 case is homed on the Nightlight's page and still listed under the manifest's slug, where the flasher looks it up; the linter refuses a claimed case whose home is no card. Two homings the regex had made by name are now stated in the Vision manifest instead, so neither moved: the combo witness (the case houses the Vision build beside a Sense one) and the Vision Pro mount — the reCamera bridge for the Vision line's Pro and Lite tiers (canary_vision_pro_recamera.md), which run no Canary firmware and so can never have a manifest of their own; left unclaimed it would turn universal and show on every device page. The selector half (option A — a cad.render_plan of {set, overrides: {host, preset, part}, parts} per manifest, generating render.sh's set list, gen_assembled_dims.py's overrides and enclosure.yml's fit families, each with a --check) is not built: each of the three hand-kept copies of the selector table already feeds an output CI gates (the rendered sets, the measured assembled envelopes, the fit matrix), and the plan would rewrite every case's render, measure and fit CI to remove a duplication whose every copy already sits behind a gate. Build it when a fourth consumer of the selector table appears, and with the maintainer's yes.

Adding a device

  1. Write devices/<slug>/device.json — copy the closest sibling, change only the ids. Every flavors.json build env for the new board goes in board.envs; a CI-only env goes in unclaimed.json with its reason. A flasher product needs board.flash_mb (the flasher names the module from chip + flash size; gen_flash.py refuses a manifest without it). If the device has a case, cad.params may assert the board knobs it is built around — copied from the .scad as they are today, because the generator writes the manifest into the file: asserting a different number is a geometry change, and owes previews. A knob whose help comment cites the registry is written as the reference it cites ({"brd": …, "dim": …} or {"brd_fn": …}), which resolves to the same literal; a case measurement is copied as the number. A second case file of the same build (a form, not a device — the doorbell) goes under cad.also and owns the same knobs; a different board is a different manifest.
  2. python3 scripts/lint_device_manifests.py until it is green. Each error names the file that owns the fact it disagrees with.
  3. Then python3 scripts/regen_cad.py --previews <dir> — the regeneration order CLAUDE.md prescribes for the rest of the tree, as one command: the manifest-owned knobs into the .scad, the STLs, the assembled envelopes, the figures and their firmware / Swift mirrors, the two flashers' models, then — because a new board usually moves flash.json, figures.json and the dist/, and the rebuild is upstream of the catalogs — it regenerates the display sketch mirror (firmware/projects/canary-display/setup.sh regen) and stops: push the branch, dispatch Actions → "Rebuild emulator dist (pinned emsdk)" on it, pull, and python3 scripts/regen_cad.py --from gen_flash finishes the catalogs. Commit what moved; the PNGs in <dir> go to the requester, never into the tree. Before any of that, gen_cad_params.py --dry-run <slug>:<knob>=<value> shows the exact line a cad.params edit would move.